From 8b171191822605b0f5fa58fe8968dc2162252ba6 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Sat, 4 Apr 2026 13:11:29 +0800 Subject: [PATCH 1/5] {"schema":"delivery/1","type":"refactor","scope":"rsnap-overlay","summary":"split overlay and scroll-capture hotspots into focused modules","intent":"land the current rsnap-overlay modularization lane as a reviewable checkpoint before continuing the remaining hotspot extractions in follow-up issues","impact":"extracts overlay runtime, rendering, and test clusters plus scroll-capture helper and downward-resolution slices into dedicated modules while keeping the main hotspot files smaller and the existing verification gates green","breaking":false,"risk":"medium","authority":"linear","delivery_mode":"status-only","refs":[{"system":"linear","id":"XY-226","role":"authority"},{"system":"linear","id":"XY-228","role":"followup"},{"system":"linear","id":"XY-229","role":"followup"}]} --- packages/rsnap-overlay/src/overlay.rs | 21496 +++------------- .../src/overlay/aux_window_runtime.rs | 422 + .../src/overlay/capture_window_runtime.rs | 85 + .../src/overlay/config_runtime.rs | 276 + .../src/overlay/cursor_context_runtime.rs | 219 + .../src/overlay/cursor_runtime.rs | 261 + .../rsnap-overlay/src/overlay/hud_runtime.rs | 519 + .../rsnap-overlay/src/overlay/rendering.rs | 1538 ++ .../src/overlay/rendering/affordances.rs | 2056 ++ .../src/overlay/rendering/hud_rendering.rs | 571 + .../src/overlay/rendering/hud_surface.rs | 562 + .../rendering/scroll_preview_window.rs | 373 + .../src/overlay/scroll_preview_runtime.rs | 240 + packages/rsnap-overlay/src/overlay/tests.rs | 1546 ++ .../src/overlay/tests/live_runtime.rs | 791 + .../src/overlay/tests/rendering_behaviors.rs | 1761 ++ .../src/overlay/tests/scroll_input_runtime.rs | 473 + .../src/overlay/tests/self_capture_runtime.rs | 285 + .../overlay/tests/stream_refresh_runtime.rs | 308 + .../tests/worker_observation_runtime.rs | 438 + .../src/overlay/tests/worker_tick_runtime.rs | 621 + .../src/overlay/toolbar_runtime.rs | 325 + .../src/overlay/window_position_runtime.rs | 252 + .../src/overlay/worker_runtime.rs | 771 + packages/rsnap-overlay/src/scroll_capture.rs | 6560 +---- .../src/scroll_capture/bench_support.rs | 253 + .../src/scroll_capture/downward_resolution.rs | 1918 ++ .../src/scroll_capture/support.rs | 906 + .../rsnap-overlay/src/scroll_capture/tests.rs | 2920 +++ 29 files changed, 24359 insertions(+), 24387 deletions(-) create mode 100644 packages/rsnap-overlay/src/overlay/aux_window_runtime.rs create mode 100644 packages/rsnap-overlay/src/overlay/capture_window_runtime.rs create mode 100644 packages/rsnap-overlay/src/overlay/config_runtime.rs create mode 100644 packages/rsnap-overlay/src/overlay/cursor_context_runtime.rs create mode 100644 packages/rsnap-overlay/src/overlay/cursor_runtime.rs create mode 100644 packages/rsnap-overlay/src/overlay/hud_runtime.rs create mode 100644 packages/rsnap-overlay/src/overlay/rendering.rs create mode 100644 packages/rsnap-overlay/src/overlay/rendering/affordances.rs create mode 100644 packages/rsnap-overlay/src/overlay/rendering/hud_rendering.rs create mode 100644 packages/rsnap-overlay/src/overlay/rendering/hud_surface.rs create mode 100644 packages/rsnap-overlay/src/overlay/rendering/scroll_preview_window.rs create mode 100644 packages/rsnap-overlay/src/overlay/scroll_preview_runtime.rs create mode 100644 packages/rsnap-overlay/src/overlay/tests.rs create mode 100644 packages/rsnap-overlay/src/overlay/tests/live_runtime.rs create mode 100644 packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs create mode 100644 packages/rsnap-overlay/src/overlay/tests/scroll_input_runtime.rs create mode 100644 packages/rsnap-overlay/src/overlay/tests/self_capture_runtime.rs create mode 100644 packages/rsnap-overlay/src/overlay/tests/stream_refresh_runtime.rs create mode 100644 packages/rsnap-overlay/src/overlay/tests/worker_observation_runtime.rs create mode 100644 packages/rsnap-overlay/src/overlay/tests/worker_tick_runtime.rs create mode 100644 packages/rsnap-overlay/src/overlay/toolbar_runtime.rs create mode 100644 packages/rsnap-overlay/src/overlay/window_position_runtime.rs create mode 100644 packages/rsnap-overlay/src/overlay/worker_runtime.rs create mode 100644 packages/rsnap-overlay/src/scroll_capture/bench_support.rs create mode 100644 packages/rsnap-overlay/src/scroll_capture/downward_resolution.rs create mode 100644 packages/rsnap-overlay/src/scroll_capture/support.rs create mode 100644 packages/rsnap-overlay/src/scroll_capture/tests.rs diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index ffd5323a..20a78843 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -1,12 +1,23 @@ pub(crate) mod replay_support; +mod aux_window_runtime; +mod capture_window_runtime; +mod config_runtime; +mod cursor_context_runtime; +mod cursor_runtime; mod hud_helpers; +mod hud_runtime; mod image_helpers; mod output; +mod rendering; +mod scroll_preview_runtime; mod scroll_runtime; mod session_state; +mod toolbar_runtime; mod trace_recording; +mod window_position_runtime; mod window_runtime; +mod worker_runtime; #[cfg(target_os = "macos")] use std::collections::VecDeque; @@ -122,6 +133,15 @@ use winit::{ window::{WindowId, WindowLevel}, }; +use self::rendering::{ + GpuContext, HudOverlayWindow, HudPillGeometry, HudRedrawSummary, OverlayWindow, + ScrollPreviewView, ScrollPreviewWindow, StartupLiveRgbPlan, WindowRenderer, +}; +#[cfg(test)] +use self::rendering::{ + SelectionDashedBorderCache, SelectionDashedBorderMetrics, SelectionFlowGeometryCache, + SelectionSizeBadgeTarget, +}; #[cfg(all(target_os = "macos", test))] use self::session_state::InflightScrollCaptureObservation; use self::session_state::{ @@ -1053,18971 +1073,4317 @@ impl OverlaySession { ); } - /// Applies updated runtime configuration to an existing session. - pub fn set_config(&mut self, config: OverlayConfig) { - let prev = self.config.clone(); - let previous_loupe_patch = self.loupe_patch_width_px; - let loupe_sample_side = Self::normalized_loupe_sample_side_px(config.loupe_sample_side_px); - #[cfg(target_os = "macos")] - let self_capture_exception_window_ids_changed = - prev.self_capture_exception_window_ids != config.self_capture_exception_window_ids; - - self.config = config; - self.loupe_patch_width_px = loupe_sample_side; - self.loupe_patch_height_px = loupe_sample_side; - self.state.loupe_patch_side_px = loupe_sample_side; - - let patch_changed = self.loupe_patch_width_px != previous_loupe_patch; - - if patch_changed { - self.state.loupe = None; - } - if !self.is_active() { - return; - } - - self.configure_hud_windows_for_config(); - - let prev_fake_blur = prev.show_hud_blur && !cfg!(target_os = "macos"); - let new_fake_blur = self.use_fake_hud_blur(); - - self.handle_fake_hud_blur_toggle(prev_fake_blur, new_fake_blur); - - if patch_changed { - self.request_loupe_sample_for_patch_change(); - } - #[cfg(target_os = "macos")] - if self_capture_exception_window_ids_changed { - self.apply_self_capture_exception_window_ids_to_active_streams(); - } + #[must_use] + pub(crate) fn is_active(&self) -> bool { + !self.windows.is_empty() + } - self.request_redraw_all(); + fn use_fake_hud_blur(&self) -> bool { + self.config.show_hud_blur && !cfg!(target_os = "macos") } #[cfg(target_os = "macos")] - fn apply_self_capture_exception_window_ids_to_active_streams(&mut self) { - self.invalidate_window_list_snapshot_for_self_capture_exception_window_ids_change(); + fn macos_hud_window_blur_enabled(&self) -> bool { + self.config.show_hud_blur + } - self.live_sample_stream = Some(MacLiveFrameStream::with_self_capture_exception_window_ids( - self.config.self_capture_exception_window_ids.clone(), - )); + fn normalized_loupe_sample_side_px(side_px: u32) -> u32 { + let side_px = side_px.max(3); - if self.scroll_capture.active { - self.scroll_capture.live_stream = if self.should_use_scroll_capture_worker_sampling() { - None - } else { - match ( - self.scroll_capture.capture_rect_points, - self.scroll_capture.capture_rect_pixels, - ) { - (Some(capture_rect_points), Some(capture_rect_pixels)) => { - Some(MacLiveFrameStream::with_scroll_capture_region_and_waker( - self.config.self_capture_exception_window_ids.clone(), - capture_rect_points, - capture_rect_pixels, - self.scroll_frame_waker.clone(), - )) - }, - _ => { - Some(MacLiveFrameStream::with_self_capture_exception_window_ids_and_waker( - self.config.self_capture_exception_window_ids.clone(), - self.scroll_frame_waker.clone(), - )) - }, - } - }; + if side_px & 1 == 0 { side_px + 1 } else { side_px } + } - self.scroll_capture.live_stream_backlog.clear(); - - self.scroll_capture.last_stream_frame_seq = 0; - self.scroll_capture.last_stream_frame_fingerprint = None; - self.scroll_capture.consecutive_identical_stream_frames = 0; - self.scroll_capture.last_consumed_stream_frame_captured_at = None; - self.scroll_capture.last_stream_event_at = None; - self.scroll_capture.last_stream_poll_at = None; - self.scroll_capture.pending_post_stall_burst_after_seq = None; - self.scroll_capture.live_stream_stale_grace = None; - self.scroll_capture.last_duplicate_stream_refresh_at = None; + fn maybe_keep_frozen_capture_redraw(&self) { + if !matches!(self.state.mode, OverlayMode::Frozen) { + return; + } + if self.state.frozen_image.is_some() { + return; } - self.refresh_active_worker_for_self_capture_exception_window_ids_if_safe(); - } + // Keep producing redraw events while the frozen background is being captured. + // On some platforms the worker response won't wake the winit event loop, so we + // must ensure `handle_overlay_window_redraw` + `drain_worker_responses` keep + // running even with no input events. + if let Some(monitor) = self.state.monitor { + self.request_redraw_for_monitor(monitor); + } else { + self.request_redraw_all(); + } - #[cfg(target_os = "macos")] - fn invalidate_window_list_snapshot_for_self_capture_exception_window_ids_change(&mut self) { - self.window_list_snapshot = None; - self.drop_next_window_list_refresh_snapshot = self.window_list_refresh_inflight; - self.last_window_list_refresh_request_at = - Instant::now() - self.window_list_refresh_interval; + self.schedule_egui_repaint_after(self.repaint_interval_for_monitor(self.state.monitor)); } - #[cfg(target_os = "macos")] - fn refresh_active_worker_for_self_capture_exception_window_ids_if_safe(&mut self) { - if self.has_inflight_worker_response_state() { - self.pending_self_capture_exception_window_ids_worker_refresh = true; - + fn maybe_tick_toolbar_window_warmup_redraw(&mut self) { + if self.toolbar_window_warmup_redraws_remaining == 0 { return; } - self.rebuild_active_worker_for_self_capture_exception_window_ids(); - } - - #[cfg(target_os = "macos")] - fn maybe_apply_pending_self_capture_exception_window_ids_worker_refresh(&mut self) { - if self.pending_self_capture_exception_window_ids_worker_refresh - && !self.has_inflight_worker_response_state() + #[cfg(not(target_os = "macos"))] { - self.rebuild_active_worker_for_self_capture_exception_window_ids(); + self.toolbar_window_warmup_redraws_remaining = 0; } - } - - #[cfg(target_os = "macos")] - fn rebuild_active_worker_for_self_capture_exception_window_ids(&mut self) { - self.worker = Some(OverlayWorker::new( - backend::default_capture_backend_with_self_capture_exception_window_ids( - self.config.self_capture_exception_window_ids.clone(), - ), - self.response_waker.clone(), - )); - self.pending_self_capture_exception_window_ids_worker_refresh = false; - } - - #[cfg(target_os = "macos")] - fn has_inflight_worker_response_state(&self) -> bool { - self.inflight_freeze_capture.is_some() - || self.pending_click_hit_test_request_id.is_some() - || self.window_list_refresh_inflight - || self.ocr_inflight - || self.png_encode_inflight - } - - fn configure_hud_windows_for_config(&mut self) { - if let Some(hud_window) = self.hud_window.as_ref() { - let window = Arc::clone(&hud_window.window); + #[cfg(target_os = "macos")] + { + if !matches!(self.state.mode, OverlayMode::Frozen) + || !self.toolbar_state.visible + || self.state.frozen_image.is_none() + || self.state.monitor.is_none() + { + self.toolbar_window_warmup_redraws_remaining = 0; - self.configure_hud_window_common(window.as_ref(), None); - } - if let Some(loupe_window) = self.loupe_window.as_ref() { - let window = Arc::clone(&loupe_window.window); + return; + } - self.configure_hud_window_common( - window.as_ref(), - Some(LOUPE_TILE_CORNER_RADIUS_POINTS), - ); - } - if let Some(toolbar_window) = self.toolbar_window.as_ref() { - let window = Arc::clone(&toolbar_window.window); + self.toolbar_window_warmup_redraws_remaining = + self.toolbar_window_warmup_redraws_remaining.saturating_sub(1); - self.configure_hud_window_common( - window.as_ref(), - Some(f64::from(HUD_PILL_CORNER_RADIUS_POINTS)), - ); + self.request_redraw_toolbar_window(); + self.schedule_egui_repaint_after(self.repaint_interval_for_monitor(self.state.monitor)); } } - fn configure_hud_window_common( + #[cfg(test)] + fn observe_scroll_capture_frame( &mut self, - window: &winit::window::Window, - corner_radius: Option, - ) { - window.set_transparent(true); - - #[cfg(not(target_os = "macos"))] - let _ = corner_radius; - - #[cfg(not(target_os = "macos"))] - window.set_blur(self.config.show_hud_blur); - #[cfg(target_os = "macos")] - self.configure_macos_hud_window_cached( - window, - self.macos_hud_window_blur_enabled(), - self.config.hud_fog_amount, - corner_radius, - ); + frame: RgbaImage, + ) -> Option> { + self.observe_scroll_capture_frame_at(frame, Instant::now()) } - #[cfg(target_os = "macos")] - fn configure_macos_hud_window_cached( + #[cfg(test)] + fn observe_scroll_capture_frame_at( &mut self, - window: &winit::window::Window, - blur_enabled: bool, - blur_amount: f32, - corner_radius: Option, - ) { - let effective_corner_radius = corner_radius.unwrap_or_else(|| { - let scale = window.scale_factor().max(1.0); - let size = window.inner_size(); + frame: RgbaImage, + observation_at: Instant, + ) -> Option> { + self.observe_scroll_capture_frame_with_gate(frame, false, observation_at, false) + } - ((size.height as f64) / scale) * 0.5 - }); - let desired = - MacOSHudWindowConfigState::new(blur_enabled, blur_amount, effective_corner_radius); + fn observe_scroll_capture_frame_with_gate( + &mut self, + frame: RgbaImage, + allow_stale_input: bool, + observation_at: Instant, + allow_post_stall_burst_search: bool, + ) -> Option> { + let prior_block_reason = self.scroll_capture_observation_block_reason_at(observation_at); + #[cfg(target_os = "macos")] + let consumed_live_stream_stale_grace = !allow_stale_input + && prior_block_reason == Some("stale_input") + && self.consume_live_stream_stale_grace_if_current(); + #[cfg(not(target_os = "macos"))] + let consumed_live_stream_stale_grace = false; + let allow_gate_bypass = allow_stale_input || consumed_live_stream_stale_grace; + let motion_rows_hint = self.scroll_capture_commit_motion_rows_hint_at(observation_at); - if self - .macos_hud_window_config_cache - .get(&window.id()) - .is_some_and(|cached| cached.same(&desired)) - { - return; + if !allow_gate_bypass && prior_block_reason.is_some() { + return Some(Ok(ScrollObserveOutcome::NoChange)); } - let started_at = Instant::now(); + let result = { + let Some(session) = self.scroll_capture.session.as_mut() else { + self.scroll_capture_set_error("Scroll capture session is unavailable."); - macos_configure_hud_window( - window, - blur_enabled, - blur_amount, - Some(effective_corner_radius), - ); + return None; + }; - let elapsed = started_at.elapsed(); + session.observe_downward_sample_with_motion_hint_and_burst( + frame, + motion_rows_hint, + allow_post_stall_burst_search, + ) + }; - self.slow_op_logger.warn_if_slow( - "overlay.macos_hud_window_configure", - elapsed, - SLOW_OP_WARN_HUD_CONFIG, - || { - format!( - "window_id={:?} blur_enabled={} blur_amount={} corner_radius={effective_corner_radius}", - window.id(), - blur_enabled, - blur_amount, - ) - }, - ); + if let Ok(outcome) = &result { + self.consume_scroll_capture_downward_motion_rows_for_outcome(outcome); + } - let _ = self.macos_hud_window_config_cache.insert(window.id(), desired); + Some(result) } - fn handle_fake_hud_blur_toggle(&mut self, prev_fake_blur: bool, new_fake_blur: bool) { - if prev_fake_blur == new_fake_blur { - return; + fn scroll_capture_commit_motion_rows_hint_at(&self, observation_at: Instant) -> Option { + if self.scroll_capture.input_direction != Some(ScrollDirection::Down) { + return None; } - if new_fake_blur { - self.last_live_bg_request_at = Instant::now() - self.live_bg_request_interval; - if matches!(self.state.mode, OverlayMode::Live) - && let Some(_cursor) = self.state.cursor - && let Some(monitor) = self.active_cursor_monitor() - { - self.maybe_request_live_bg(monitor); - } + let input_direction_at = self.scroll_capture.input_direction_at?; - return; + if !self.scroll_capture.input_gesture_active + && observation_at.saturating_duration_since(input_direction_at) + > SCROLL_CAPTURE_INPUT_FRESHNESS + { + return None; + } + if !self.scroll_capture.downward_motion_rows_pending.is_finite() + || self.scroll_capture.downward_motion_rows_pending <= 0.0 + { + return None; } - self.state.live_bg_monitor = None; - self.state.live_bg_image = None; - } - - fn request_loupe_sample_for_patch_change(&mut self) { - let cursor = match self.state.cursor { - Some(cursor) => cursor, - None => return, - }; - let monitor = match self.active_cursor_monitor() { - Some(monitor) => monitor, - None => return, - }; - let _ = self.apply_live_hover_cache_state(monitor, cursor); - let _ = self.request_live_cursor_sample(monitor, cursor, true); - let _ = self.request_live_window_list_refresh_if_needed(); + Some(self.scroll_capture.downward_motion_rows_pending.ceil() as u32) } - #[must_use] - pub(crate) fn is_active(&self) -> bool { - !self.windows.is_empty() - } + fn scroll_capture_set_error(&mut self, message: impl Into) { + let message = message.into(); - fn use_fake_hud_blur(&self) -> bool { - self.config.show_hud_blur && !cfg!(target_os = "macos") - } + tracing::warn!( + op = "scroll_capture.error", + error = %message, + "Scroll capture paused on error." + ); - #[cfg(target_os = "macos")] - fn macos_hud_window_blur_enabled(&self) -> bool { - self.config.show_hud_blur - } + if let Some(trace_recorder) = self.scroll_capture.trace_recorder.as_mut() { + trace_recorder.record_error(&message); + } - fn normalized_loupe_sample_side_px(side_px: u32) -> u32 { - let side_px = side_px.max(3); + self.scroll_capture.paused = true; - if side_px & 1 == 0 { side_px + 1 } else { side_px } + self.state.set_error(message); + self.request_redraw_all(); } - fn live_loupe_uses_hud_window(&self) -> bool { - false + fn pending_freeze_capture_matches(&self, monitor: MonitorRect) -> bool { + self.pending_freeze_capture == Some(monitor) + && matches!(self.state.mode, OverlayMode::Frozen) + && self.state.monitor == Some(monitor) } - fn live_loupe_renders_in_hud_window(&self) -> bool { - self.live_loupe_uses_hud_window() && self.state.alt_held + #[cfg(target_os = "macos")] + fn should_dispatch_pending_freeze_capture(&self, monitor: MonitorRect) -> bool { + self.pending_freeze_capture_matches(monitor) } - fn maybe_tick_loupe_window_warmup_redraw(&mut self) { - if self.loupe_window_warmup_redraws_remaining == 0 { - return; - } - if !matches!(self.state.mode, OverlayMode::Frozen) - || !self.loupe_window_visible - || self.state.frozen_image.is_none() - || self.state.monitor.is_none() - { - self.loupe_window_warmup_redraws_remaining = 0; - - return; - } + #[cfg(not(target_os = "macos"))] + fn should_dispatch_pending_freeze_capture(&self, monitor: MonitorRect) -> bool { + self.pending_freeze_capture_matches(monitor) && self.state.frozen_image.is_none() + } - self.loupe_window_warmup_redraws_remaining = - self.loupe_window_warmup_redraws_remaining.saturating_sub(1); + fn frozen_final_capture_ready(&self) -> bool { + matches!(self.state.mode, OverlayMode::Frozen) + && self.authoritative_frozen_capture_ready + && self.state.frozen_image.is_some() + && self.pending_freeze_capture.is_none() + && self.inflight_freeze_capture.is_none() + } - self.request_redraw_loupe_window(); - self.schedule_egui_repaint_after(self.repaint_interval_for_monitor(self.state.monitor)); + fn should_force_pending_hud_and_loupe_moves(&self) -> bool { + matches!(self.state.mode, OverlayMode::Frozen) + && self.state.monitor.is_some() + && !self.frozen_final_capture_ready() } - fn maybe_start_loupe_window_warmup_redraw(&mut self) { - if self.loupe_window_warmup_redraws_remaining > 0 { - return; - } - if !matches!(self.state.mode, OverlayMode::Frozen) - || !self.state.alt_held - || !self.loupe_window_visible - || self.state.frozen_image.is_none() - || self.state.monitor.is_none() + #[cfg(target_os = "macos")] + fn try_latest_live_freeze_preview(&mut self, monitor: MonitorRect) -> Option { + if self.state.live_bg_monitor == Some(monitor) + && let Some(image) = self.state.live_bg_image.take() { - return; + return Some(image); } - self.loupe_window_warmup_redraws_remaining = LOUPE_WINDOW_WARMUP_REDRAWS; + self.live_sample_stream + .as_ref() + .and_then(|stream| stream.peek_latest_rgba_snapshot(monitor)) + .map(|snapshot| snapshot.image.as_ref().clone()) } - fn reset_loupe_window_warmup_redraws(&mut self) { - self.loupe_window_warmup_redraws_remaining = 0; - } - - /// Advances periodic session work before the event loop goes idle. - pub fn about_to_wait(&mut self) -> OverlayControl { - let now = Instant::now(); - - self.maybe_log_event_loop_stall(now); - self.mark_progress(OverlayEventLoopPhase::AboutToWait); - self.maybe_clear_loupe_activation_after_focus_loss(); - self.maybe_request_keepalive_redraw(); - self.maybe_keep_selection_flow_repaint(); - self.maybe_keep_frozen_capture_redraw(); - self.maybe_tick_toolbar_window_warmup_redraw(); - self.maybe_tick_loupe_window_warmup_redraw(); - self.maybe_tick_live_cursor_tracking(); - self.maybe_tick_live_sampling(); - self.maybe_tick_frozen_cursor_tracking(); - self.maybe_apply_pending_hud_and_loupe_moves(); - self.maybe_tick_scroll_capture(); - self.maybe_keep_live_cursor_sample_redraw(); - - self.drain_worker_responses() - } - - fn mark_progress(&mut self, phase: OverlayEventLoopPhase) { - self.mark_progress_with_detail(phase, None); - } - - fn mark_progress_with_detail( + #[cfg(target_os = "macos")] + fn commit_frozen_preview( &mut self, - phase: OverlayEventLoopPhase, - detail: Option<&'static str>, + monitor: MonitorRect, + image: RgbaImage, + cursor: Option, ) { - self.event_loop_phase = phase; - self.event_loop_last_progress_detail = detail; - self.event_loop_progress_seq = self.event_loop_progress_seq.saturating_add(1); - self.event_loop_last_progress_at = Instant::now(); - } - - fn maybe_log_event_loop_stall(&mut self, now: Instant) { - let stall = now.duration_since(self.event_loop_last_progress_at); - - if stall < OVERLAY_EVENT_LOOP_STALL_THRESHOLD { - return; - } - if self - .event_loop_last_stall_warn_at - .is_none_or(|last| now.duration_since(last) >= SLOW_OP_WARN_INTERVAL) - { - let _ = self.event_loop_last_stall_warn_at.insert(now); + self.state.finish_freeze(monitor, image); - tracing::warn!( - op = "overlay.event_loop_stall", - stall_ms = stall.as_millis(), - phase = %self.event_loop_phase.as_str(), - progress_seq = self.event_loop_progress_seq, - mode = ?self.state.mode, - window_id = ?self.event_loop_last_progress_window_id, - monitor_id = ?self.event_loop_last_progress_monitor_id, - detail = ?self.event_loop_last_progress_detail, - "Event loop stalled" - ); + if let Some(cursor) = cursor { + self.update_cursor_state(monitor, cursor); + self.update_hud_window_position(monitor, cursor); } } - fn window_event_kind(event: &WindowEvent) -> &'static str { - match event { - WindowEvent::ActivationTokenDone { .. } => "activation_token_done", - WindowEvent::CloseRequested => "close_requested", - WindowEvent::Destroyed => "destroyed", - WindowEvent::DroppedFile(_) => "dropped_file", - WindowEvent::HoveredFile(_) => "hovered_file", - WindowEvent::HoveredFileCancelled => "hovered_file_cancelled", - WindowEvent::Focused(_) => "focused", - WindowEvent::Moved(_) => "moved", - WindowEvent::Resized(_) => "resized", - WindowEvent::ScaleFactorChanged { .. } => "scale_factor_changed", - WindowEvent::Ime(_) => "ime", - WindowEvent::CursorEntered { .. } => "cursor_entered", - WindowEvent::CursorLeft { .. } => "cursor_left", - WindowEvent::CursorMoved { .. } => "cursor_moved", - WindowEvent::MouseWheel { .. } => "mouse_wheel", - WindowEvent::MouseInput { .. } => "mouse_input", - WindowEvent::PinchGesture { .. } => "pinch_gesture", - WindowEvent::PanGesture { .. } => "pan_gesture", - WindowEvent::DoubleTapGesture { .. } => "double_tap_gesture", - WindowEvent::RotationGesture { .. } => "rotation_gesture", - WindowEvent::TouchpadPressure { .. } => "touchpad_pressure", - WindowEvent::AxisMotion { .. } => "axis_motion", - WindowEvent::Touch(_) => "touch", - WindowEvent::ThemeChanged(_) => "theme_changed", - WindowEvent::KeyboardInput { .. } => "keyboard_input", - WindowEvent::ModifiersChanged(_) => "modifiers_changed", - WindowEvent::Occluded(_) => "occluded", - WindowEvent::RedrawRequested => "redraw_requested", - } - } + fn seed_frozen_toolbar_default_position( + &mut self, + monitor: MonitorRect, + capture_rect: RectPoints, + ) { + let default_pos = + self.frozen_toolbar_default_position_for_capture_rect(monitor, capture_rect); - fn maybe_keep_live_cursor_sample_redraw(&mut self) { - if !matches!(self.state.mode, OverlayMode::Live) { - return; - } - if self.latest_live_cursor_sample_request_id.is_none() { - return; - } - if !self.live_sample_request_pending() { - return; - } + self.toolbar_state.default_slot_position = Some(default_pos); + self.toolbar_state.floating_position = Some(default_pos); + + let _ = self.update_toolbar_outer_position(monitor, default_pos); - self.schedule_egui_repaint_after( - self.repaint_interval_for_monitor(self.active_cursor_monitor()), + tracing::debug!( + monitor_id = monitor.id, + frozen_generation = self.state.frozen_generation, + toolbar_size_points = + ?WindowRenderer::frozen_toolbar_size(&self.toolbar_state), + default_pos = ?default_pos, + "Frozen toolbar default position preseeded." ); } - fn maybe_keep_selection_flow_repaint(&self) { - if !self.is_active() || !self.config.selection_flow_enabled { - return; - } - - let keep_repaint = match self.state.mode { - OverlayMode::Live => self.live_overlay_selection_flow_repaint_active(), - OverlayMode::Frozen => self.state.frozen_capture_rect.is_some(), - }; + fn frozen_toolbar_default_position_for_capture_rect( + &self, + monitor: MonitorRect, + capture_rect_points: RectPoints, + ) -> Pos2 { + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let capture_rect = Rect::from_min_size( + Pos2::new(capture_rect_points.x as f32, capture_rect_points.y as f32), + Vec2::new(capture_rect_points.width as f32, capture_rect_points.height as f32), + ); + let toolbar_size = WindowRenderer::frozen_toolbar_size(&self.toolbar_state); - if keep_repaint { - let monitor = match self.state.mode { - OverlayMode::Live => self.active_cursor_monitor(), - OverlayMode::Frozen => self.state.monitor, - }; - let repaint_interval = self.selection_flow_repaint_interval(monitor); + WindowRenderer::frozen_toolbar_default_pos( + screen_rect, + capture_rect, + toolbar_size, + self.config.toolbar_placement, + ) + } - if let Some(monitor) = monitor { - self.request_redraw_for_monitor(monitor); - } else { - self.request_redraw_all(); - } + fn initial_session_runtime(config: &OverlayConfig) -> InitialSessionRuntime { + let (live_bg_request_interval, window_list_refresh_interval, now) = Self::initial_timing(); + let (loupe_sample_side_px, state) = Self::overlay_state_with_config(config); - self.schedule_egui_repaint_after(repaint_interval); + InitialSessionRuntime { + live_bg_request_interval, + window_list_refresh_interval, + now, + loupe_sample_side_px, + state, } } - fn live_overlay_selection_flow_repaint_active(&self) -> bool { - if !self.config.selection_flow_enabled { - return false; + fn refresh_frozen_helper_windows_for_transition( + &mut self, + monitor: MonitorRect, + cursor: Option, + ) { + if let Some(cursor) = cursor { + self.update_hud_window_position(monitor, cursor); } - self.state.hovered_window_rect.is_some_and(|hovered| { - self.active_cursor_monitor().is_some_and(|monitor| hovered.monitor_id == monitor.id) - }) - } + if self.should_force_pending_hud_and_loupe_moves() { + self.force_apply_pending_hud_and_loupe_moves(); + } - fn live_overlay_redraw_needed_for_cursor_update( - old_monitor: Option, - monitor: MonitorRect, - previous_drag_rect: Option, - next_drag_rect: Option, - ) -> bool { - old_monitor != Some(monitor) || previous_drag_rect != next_drag_rect - } + self.schedule_egui_repaint_after(self.repaint_interval_for_monitor(Some(monitor))); + self.request_redraw_for_monitor(monitor); + self.request_redraw_hud_window(); - fn live_hud_redraw_needed_for_cursor_update( - old_cursor: Option, - cursor: GlobalPoint, - old_monitor: Option, - monitor: MonitorRect, - ) -> bool { - old_cursor != Some(cursor) || old_monitor != Some(monitor) + if self.state.alt_held || self.loupe_window_visible { + self.request_redraw_loupe_window(); + } } - fn repaint_interval_for_monitor(&self, monitor: Option) -> Duration { - let monitor_fps = monitor - .and_then(|target| { - self.windows.values().find_map(|window| { - (target == window.monitor).then_some(window.refresh_rate_millihertz) - }) - }) - .flatten() - .and_then(|hz| { - let fps = (hz as f32) / 1_000.0; - - if fps.is_finite() && fps > 0.0 { Some(fps) } else { None } - }); - let fallback_fps = self - .windows - .values() - .filter_map(|window| window.refresh_rate_millihertz) - .filter_map(|hz| { - let fps = (hz as f32) / 1_000.0; + fn begin_frozen_capture_with_rect( + &mut self, + monitor: MonitorRect, + rect: Option, + window_target: Option, + cursor: Option, + ) { + self.frozen_capture_source = if rect.is_none() { + FrozenCaptureSource::FullscreenFallback + } else if window_target.is_some() { + FrozenCaptureSource::Window + } else { + FrozenCaptureSource::DragRegion + }; - if fps.is_finite() && fps > 0.0 { Some(fps) } else { None } - }) - .max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); - let fps = Self::interactive_repaint_fps(monitor_fps, fallback_fps); + let capture_rect = rect.unwrap_or(RectPoints::new(0, 0, monitor.width, monitor.height)); + let frozen_rgb = self.state.rgb; + let frozen_loupe = self.state.loupe.as_ref().map(|loupe| crate::state::LoupeSample { + center: loupe.center, + patch: loupe.patch.clone(), + }); - Duration::from_secs_f32(1.0 / fps) - } + self.state.clear_error(); + self.state.begin_freeze(monitor); - fn interactive_repaint_fps(monitor_fps: Option, fallback_fps: Option) -> f32 { - monitor_fps - .or(fallback_fps) - .map_or(INTERACTIVE_REPAINT_FPS_CAP, |fps| fps.min(INTERACTIVE_REPAINT_FPS_CAP)) - } + self.state.frozen_capture_rect = Some(capture_rect); + self.state.drag_rect = None; + self.state.hovered_window_rect = None; + self.frozen_selection_drag = FrozenSelectionDragState::default(); - fn selection_flow_repaint_interval(&self, monitor: Option) -> Duration { - self.repaint_interval_for_monitor(monitor) - } + tracing::debug!( + monitor_id = monitor.id, + origin = ?monitor.origin, + width_points = monitor.width, + height_points = monitor.height, + monitor_scale_factor = monitor.scale_factor(), + cursor = ?cursor, + capture_rect = ?capture_rect, + "Freeze begin." + ); - fn frozen_cursor_tracking_interval(&self, monitor: Option) -> Duration { - self.repaint_interval_for_monitor(monitor) - } + self.toolbar_state.floating_position = None; + self.toolbar_state.default_slot_position = None; + self.toolbar_state.dragging = false; + self.toolbar_state.needs_redraw = true; + self.toolbar_state.pill_height_points = None; + self.toolbar_state.layout_last_screen_size_points = None; + self.toolbar_state.layout_stable_frames = 0; - /// Returns the active repaint cadence that keeps interactive overlays responsive. - pub fn interactive_wait_interval(&self) -> Duration { - let monitor = if self.scroll_capture.active { - self.scroll_capture.monitor.or(self.state.monitor) - } else { - self.active_cursor_monitor() - }; + self.sync_frozen_toolbar_state(); + // Spawn the toolbar immediately at the default position (capture aware). This avoids any + // dependency on egui viewport stabilization or additional input events (mouse move) to + // finish the initial layout. + self.seed_frozen_toolbar_default_position(monitor, capture_rect); + self.request_redraw_toolbar_window(); - self.repaint_interval_for_monitor(monitor) - } + self.state.rgb = frozen_rgb; + self.state.loupe = frozen_loupe; + self.pending_freeze_capture = Some(monitor); + self.pending_freeze_capture_armed = false; + self.inflight_freeze_capture = None; + self.authoritative_frozen_capture_ready = false; + self.pending_window_freeze_capture = window_target; + self.inflight_window_freeze_capture = None; + self.frozen_window_image = None; + self.capture_windows_hidden = false; + self.pending_click_hit_test_request_id = None; + self.left_mouse_button_down = false; + self.left_mouse_button_down_monitor = None; + self.left_mouse_button_down_global = None; - fn live_sample_request_pending(&self) -> bool { - self.latest_live_cursor_sample_request_id.is_some() - && self.applied_live_cursor_sample_request_id - != self.latest_live_cursor_sample_request_id - } + self.refresh_frozen_helper_windows_for_transition(monitor, cursor); - fn note_live_cursor_sample_request_started(&mut self, request_id: u64) { - self.live_cursor_sample_request_id = request_id; - self.latest_live_cursor_sample_request_id = Some(request_id); - self.latest_live_cursor_sample_requested_at = Some(Instant::now()); - } + #[cfg(target_os = "macos")] + { + if let Some(image) = self.try_latest_live_freeze_preview(monitor) { + self.state.live_bg_monitor = None; + self.state.live_bg_image = None; - #[cfg(target_os = "macos")] - fn finish_sync_live_cursor_sample_attempt(&mut self, request_id: u64) { - // Synchronous latest-frame reads on the current thread either produce a sample now or miss - // now. They must not leave async-style "pending" bookkeeping behind. + self.commit_frozen_preview(monitor, image, cursor); + self.force_apply_pending_hud_and_loupe_moves(); + } else { + self.state.live_bg_monitor = None; + self.state.live_bg_image = None; + self.capture_windows_hidden = true; + } + } + #[cfg(not(target_os = "macos"))] + { + if self.use_fake_hud_blur() + && window_target.is_none() + && self.state.live_bg_monitor == Some(monitor) + && let Some(image) = self.state.live_bg_image.take() + { + self.state.live_bg_monitor = None; - debug_assert_eq!(self.latest_live_cursor_sample_request_id, Some(request_id)); + self.state.finish_freeze(monitor, image); - self.applied_live_cursor_sample_request_id = Some(request_id); - } + self.pending_freeze_capture = None; + self.pending_freeze_capture_armed = false; + self.authoritative_frozen_capture_ready = true; - fn maybe_apply_pending_hud_and_loupe_moves(&mut self) { - let now = Instant::now(); + if let Some(cursor) = cursor { + self.update_cursor_state(monitor, cursor); + self.update_hud_window_position(monitor, cursor); + } - self.maybe_apply_pending_hud_window_move(now); - self.maybe_apply_pending_loupe_window_move(now); - } + if self.should_force_pending_hud_and_loupe_moves() { + self.force_apply_pending_hud_and_loupe_moves(); + } + } else { + self.state.live_bg_monitor = None; + self.state.live_bg_image = None; + self.capture_windows_hidden = true; - fn maybe_apply_pending_hud_window_move(&mut self, now: Instant) { - self.apply_pending_hud_window_move(now, false); + self.hide_capture_windows(); + } + } } - fn force_apply_pending_hud_window_move(&mut self) { - self.apply_pending_hud_window_move(Instant::now(), true); - } + fn update_live_drag_rect(&mut self, monitor: MonitorRect, global: GlobalPoint) { + if !matches!(self.state.mode, OverlayMode::Live) { + self.state.drag_rect = None; - fn apply_pending_hud_window_move(&mut self, now: Instant, force: bool) { - let Some(desired) = self.pending_hud_outer_pos else { return; - }; - let elapsed = now.duration_since(self.last_hud_window_move_at); - let interval = self - .repaint_interval_for_monitor(self.active_cursor_monitor()) - .max(HUD_LOUPE_MOVE_INTERVAL_MIN); - - if !force && elapsed < interval { - let delay = interval.saturating_sub(elapsed); - - self.schedule_egui_repaint_after(delay); + } + if !self.left_mouse_button_down || self.left_mouse_button_down_monitor != Some(monitor) { + self.state.drag_rect = None; return; } - let Some(hud_window) = self.hud_window.as_ref() else { + let Some(start_global) = self.left_mouse_button_down_global else { + self.state.drag_rect = None; + return; }; - let started_at = Instant::now(); + let Some(rect) = monitor.local_rect_from_points(start_global, global) else { + self.state.drag_rect = None; - hud_window - .window - .set_outer_position(LogicalPosition::new(desired.x as f64, desired.y as f64)); + return; + }; - let elapsed = started_at.elapsed(); + if rect.is_empty() { + self.state.drag_rect = None; - self.slow_op_logger.warn_if_slow( - "overlay.hud_window_set_outer_position", - elapsed, - SLOW_OP_WARN_OUTER_POSITION, - || format!("window_id={:?} pos=({}, {})", hud_window.window.id(), desired.x, desired.y), - ); + return; + } - self.pending_hud_outer_pos = None; - self.last_hud_window_move_at = now; + self.state.drag_rect = Some(MonitorRectPoints { monitor_id: monitor.id, rect }); } - fn force_apply_pending_hud_and_loupe_moves(&mut self) { - self.force_apply_pending_hud_window_move(); - self.force_apply_pending_loupe_window_move(); - } + fn frozen_selection_drag_target(&self) -> Option<(MonitorRect, RectPoints)> { + if !matches!(self.state.mode, OverlayMode::Frozen) + || self.frozen_capture_source != FrozenCaptureSource::DragRegion + || self.scroll_capture.active + || self.state.frozen_image.is_none() + { + return None; + } - fn maybe_apply_pending_loupe_window_move(&mut self, now: Instant) { - self.apply_pending_loupe_window_move(now, false); + let monitor = self.state.monitor?; + let capture_rect = self.state.frozen_capture_rect?; + + if capture_rect.is_empty() { + return None; + } + + Some((monitor, capture_rect)) } - fn force_apply_pending_loupe_window_move(&mut self) { - self.apply_pending_loupe_window_move(Instant::now(), true); + fn frozen_auto_center_available(&self) -> bool { + self.frozen_selection_drag_target().is_some() } - fn apply_pending_loupe_window_move(&mut self, now: Instant, force: bool) { - let Some(desired) = self.pending_loupe_outer_pos else { - return; + fn begin_frozen_selection_drag(&mut self, global: GlobalPoint) -> bool { + let Some((monitor, capture_rect)) = self.frozen_selection_drag_target() else { + return false; + }; + let Some((cursor_x, cursor_y)) = monitor.local_u32(global) else { + return false; }; - let elapsed = now.duration_since(self.last_loupe_window_move_at); - let interval = self - .repaint_interval_for_monitor(self.active_cursor_monitor()) - .max(HUD_LOUPE_MOVE_INTERVAL_MIN); - - if !force && elapsed < interval { - let delay = interval.saturating_sub(elapsed); - - self.schedule_egui_repaint_after(delay); - return; + if !capture_rect.contains((cursor_x, cursor_y)) { + return false; } - let Some(loupe_window) = self.loupe_window.as_ref() else { - return; + self.frozen_selection_drag = FrozenSelectionDragState { + active: true, + pointer_offset_x: cursor_x.saturating_sub(capture_rect.x), + pointer_offset_y: cursor_y.saturating_sub(capture_rect.y), }; - let started_at = Instant::now(); - - loupe_window - .window - .set_outer_position(LogicalPosition::new(desired.x as f64, desired.y as f64)); - - let elapsed = started_at.elapsed(); - - self.slow_op_logger.warn_if_slow( - "overlay.loupe_window_set_outer_position", - elapsed, - SLOW_OP_WARN_OUTER_POSITION, - || { - format!( - "window_id={:?} pos=({}, {})", - loupe_window.window.id(), - desired.x, - desired.y - ) - }, - ); - self.pending_loupe_outer_pos = None; - self.last_loupe_window_move_at = now; + true } - fn schedule_egui_repaint_after(&self, delay: Duration) { - let deadline = Instant::now() + delay; - let mut next_repaint = - self.egui_repaint_deadline.lock().unwrap_or_else(|err| err.into_inner()); - - if next_repaint.is_none_or(|next| deadline < next) { - *next_repaint = Some(deadline); - } + fn stop_frozen_selection_drag(&mut self) { + self.frozen_selection_drag = FrozenSelectionDragState::default(); } - fn maybe_keep_frozen_capture_redraw(&self) { - if !matches!(self.state.mode, OverlayMode::Frozen) { - return; - } - if self.state.frozen_image.is_some() { - return; + fn update_frozen_selection_drag_rect(&mut self, global: GlobalPoint) -> bool { + if !self.frozen_selection_drag.active { + return false; } - // Keep producing redraw events while the frozen background is being captured. - // On some platforms the worker response won't wake the winit event loop, so we - // must ensure `handle_overlay_window_redraw` + `drain_worker_responses` keep - // running even with no input events. - if let Some(monitor) = self.state.monitor { - self.request_redraw_for_monitor(monitor); - } else { - self.request_redraw_all(); - } + let Some((monitor, capture_rect)) = self.frozen_selection_drag_target() else { + self.stop_frozen_selection_drag(); - self.schedule_egui_repaint_after(self.repaint_interval_for_monitor(self.state.monitor)); - } + return false; + }; + let (cursor_x, cursor_y) = Self::clamped_local_point_in_monitor(monitor, global); + let desired_x = + i64::from(cursor_x) - i64::from(self.frozen_selection_drag.pointer_offset_x); + let desired_y = + i64::from(cursor_y) - i64::from(self.frozen_selection_drag.pointer_offset_y); + let next_rect = Self::clamp_frozen_capture_rect_to_monitor( + monitor, + capture_rect.width, + capture_rect.height, + desired_x, + desired_y, + ); - fn maybe_tick_toolbar_window_warmup_redraw(&mut self) { - if self.toolbar_window_warmup_redraws_remaining == 0 { - return; - } + self.apply_frozen_capture_rect_update(monitor, next_rect) + } - #[cfg(not(target_os = "macos"))] - { - self.toolbar_window_warmup_redraws_remaining = 0; - } - #[cfg(target_os = "macos")] - { - if !matches!(self.state.mode, OverlayMode::Frozen) - || !self.toolbar_state.visible - || self.state.frozen_image.is_none() - || self.state.monitor.is_none() - { - self.toolbar_window_warmup_redraws_remaining = 0; + fn clamped_local_point_in_monitor(monitor: MonitorRect, global: GlobalPoint) -> (u32, u32) { + let max_x = i64::from(monitor.width.saturating_sub(1)); + let max_y = i64::from(monitor.height.saturating_sub(1)); + let local_x = (i64::from(global.x) - i64::from(monitor.origin.x)).clamp(0, max_x) as u32; + let local_y = (i64::from(global.y) - i64::from(monitor.origin.y)).clamp(0, max_y) as u32; - return; - } + (local_x, local_y) + } - self.toolbar_window_warmup_redraws_remaining = - self.toolbar_window_warmup_redraws_remaining.saturating_sub(1); + fn clamp_frozen_capture_rect_to_monitor( + monitor: MonitorRect, + width: u32, + height: u32, + desired_x: i64, + desired_y: i64, + ) -> RectPoints { + let max_x = i64::from(monitor.width.saturating_sub(width)); + let max_y = i64::from(monitor.height.saturating_sub(height)); + let x = desired_x.clamp(0, max_x) as u32; + let y = desired_y.clamp(0, max_y) as u32; - self.request_redraw_toolbar_window(); - self.schedule_egui_repaint_after(self.repaint_interval_for_monitor(self.state.monitor)); - } + RectPoints::new(x, y, width, height) } - fn maybe_tick_frozen_cursor_tracking(&mut self) { - if !self.is_active() || !matches!(self.state.mode, OverlayMode::Frozen) { - return; + fn apply_frozen_capture_rect_update( + &mut self, + monitor: MonitorRect, + next_rect: RectPoints, + ) -> bool { + if self.state.frozen_capture_rect == Some(next_rect) { + return false; } - let interval = - self.frozen_cursor_tracking_interval(self.state.monitor).max(CURSOR_POLL_INTERVAL_MIN); - let now = Instant::now(); - - self.schedule_egui_repaint_after(interval); + self.state.frozen_capture_rect = Some(next_rect); - if let Some((monitor, global)) = self.last_fresh_event_cursor() { - let old_monitor = self.active_cursor_monitor(); + let toolbar_pos = self.frozen_toolbar_default_position_for_capture_rect(monitor, next_rect); - if tracing::enabled!(tracing::Level::TRACE) { - tracing::trace!( - mode = "frozen", - source = DeviceCursorPointSource::EventRecentFallback.as_str(), - monitor_id = monitor.id, - "Resolved event cursor for frozen tick." - ); - } - if self.state.cursor == Some(global) && old_monitor == Some(monitor) { - return; - } + self.toolbar_state.default_slot_position = Some(toolbar_pos); + self.toolbar_state.floating_position = Some(toolbar_pos); - let previous_drag_rect = self.state.drag_rect; + let _ = self.update_toolbar_outer_position(monitor, toolbar_pos); - self.update_cursor_state(monitor, global); - self.update_hud_window_position(monitor, global); - self.update_live_drag_rect(monitor, global); - self.update_frozen_selection_drag_rect(global); - self.force_apply_pending_hud_and_loupe_moves(); - self.request_redraw_hud_window(); + self.request_redraw_for_monitor(monitor); + self.request_redraw_toolbar_window(); + self.request_redraw_scroll_preview_window(); - if self.state.alt_held || self.loupe_window_visible { - self.request_redraw_loupe_window(); - } + true + } - if let Some(old_monitor) = old_monitor - && old_monitor != monitor - { - self.request_redraw_for_monitor(old_monitor); - } + fn auto_center_frozen_capture_rect(&mut self) -> bool { + let Some((monitor, capture_rect)) = self.frozen_selection_drag_target() else { + return false; + }; + let Some(capture_image) = self.cropped_frozen_capture_image() else { + return false; + }; + let Some(content_bounds) = Self::detect_auto_center_content_bounds(&capture_image) else { + return false; + }; + let delta_x_points = Self::auto_center_shift_points( + content_bounds.x, + content_bounds.width, + capture_image.width(), + capture_rect.width, + ); + let delta_y_points = Self::auto_center_shift_points( + content_bounds.y, + content_bounds.height, + capture_image.height(), + capture_rect.height, + ); + let next_rect = Self::clamp_frozen_capture_rect_to_monitor( + monitor, + capture_rect.width, + capture_rect.height, + i64::from(capture_rect.x) + delta_x_points, + i64::from(capture_rect.y) + delta_y_points, + ); - if Self::live_overlay_redraw_needed_for_cursor_update( - old_monitor, - monitor, - previous_drag_rect, - self.state.drag_rect, - ) { - self.request_redraw_for_monitor(monitor); - } + self.apply_frozen_capture_rect_update(monitor, next_rect) + } - return; + fn auto_center_shift_points( + content_origin_px: u32, + content_size_px: u32, + crop_size_px: u32, + capture_size_points: u32, + ) -> i64 { + if crop_size_px == 0 || capture_size_points == 0 { + return 0; } - if now.duration_since(self.last_frozen_cursor_poll_at) < interval { - return; - } + let content_center_px = content_origin_px as f32 + (content_size_px as f32 * 0.5); + let crop_center_px = crop_size_px as f32 * 0.5; + let delta_px = content_center_px - crop_center_px; - self.last_frozen_cursor_poll_at = now; + ((delta_px * capture_size_points as f32) / crop_size_px as f32).round() as i64 + } - let raw = self.sample_mouse_location(); - let old_monitor = self.active_cursor_monitor(); - let Some((monitor, global, source)) = self.resolve_device_cursor_point(raw) else { - return; - }; + fn detect_auto_center_content_bounds(image: &RgbaImage) -> Option { + let width = image.width(); + let height = image.height(); - if tracing::enabled!(tracing::Level::TRACE) { - tracing::trace!( - mode = "frozen", - source = source.as_str(), - monitor_id = monitor.id, - "Resolved device cursor for frozen tick." - ); - } - if self.state.cursor == Some(global) && old_monitor == Some(monitor) { - return; + if width < 2 || height < 2 { + return None; } - let previous_drag_rect = self.state.drag_rect; - - self.update_cursor_state(monitor, global); - self.update_hud_window_position(monitor, global); - self.update_live_drag_rect(monitor, global); - self.update_frozen_selection_drag_rect(global); - self.force_apply_pending_hud_and_loupe_moves(); - self.request_redraw_hud_window(); - - if self.state.alt_held || self.loupe_window_visible { - self.request_redraw_loupe_window(); - } + let edge_strip = Self::auto_center_edge_strip_extent(width.min(height)); + let top_mean = Self::region_rgb_mean(image, 0, width, 0, edge_strip)?; + let bottom_mean = + Self::region_rgb_mean(image, 0, width, height.saturating_sub(edge_strip), height)?; + let left_mean = Self::region_rgb_mean(image, 0, edge_strip, 0, height)?; + let right_mean = + Self::region_rgb_mean(image, width.saturating_sub(edge_strip), width, 0, height)?; + let threshold = { + let edge_noise = [ + Self::region_rgb_mean_distance(image, 0, width, 0, edge_strip, top_mean), + Self::region_rgb_mean_distance( + image, + 0, + width, + height.saturating_sub(edge_strip), + height, + bottom_mean, + ), + Self::region_rgb_mean_distance(image, 0, edge_strip, 0, height, left_mean), + Self::region_rgb_mean_distance( + image, + width.saturating_sub(edge_strip), + width, + 0, + height, + right_mean, + ), + ] + .into_iter() + .fold(0.0, f32::max); - if let Some(old_monitor) = old_monitor - && old_monitor != monitor - { - self.request_redraw_for_monitor(old_monitor); - } + (edge_noise * 3.0).round().clamp(24.0, 96.0) as u32 + }; + let min_salient_per_row = (width / 64).max(1) as usize; + let min_salient_per_column = (height / 64).max(1) as usize; + let mut row_counts = vec![0_usize; height as usize]; + let mut column_counts = vec![0_usize; width as usize]; - if Self::live_overlay_redraw_needed_for_cursor_update( - old_monitor, - monitor, - previous_drag_rect, - self.state.drag_rect, - ) { - self.request_redraw_for_monitor(monitor); + for (x, y, pixel) in image.enumerate_pixels() { + let salient_distance = [ + Self::rgb_distance_to_mean(pixel, top_mean), + Self::rgb_distance_to_mean(pixel, bottom_mean), + Self::rgb_distance_to_mean(pixel, left_mean), + Self::rgb_distance_to_mean(pixel, right_mean), + ] + .into_iter() + .min() + .unwrap_or(0); + + if salient_distance < threshold { + continue; + } + + row_counts[y as usize] += 1; + column_counts[x as usize] += 1; } - } - fn maybe_tick_live_cursor_tracking(&mut self) { - if !self.is_active() || !matches!(self.state.mode, OverlayMode::Live) { - return; + let top = row_counts.iter().position(|count| *count >= min_salient_per_row)?; + let bottom = row_counts.iter().rposition(|count| *count >= min_salient_per_row)?; + let left = column_counts.iter().position(|count| *count >= min_salient_per_column)?; + let right = column_counts.iter().rposition(|count| *count >= min_salient_per_column)?; + + if left > right || top > bottom { + return None; } - let interval = self - .repaint_interval_for_monitor(self.active_cursor_monitor()) - .max(CURSOR_POLL_INTERVAL_MIN); - let now = Instant::now(); + let bounds = RectPoints::new( + left as u32, + top as u32, + (right - left + 1) as u32, + (bottom - top + 1) as u32, + ); + let fills_crop_width = bounds.width.saturating_mul(100) >= width.saturating_mul(92); + let fills_crop_height = bounds.height.saturating_mul(100) >= height.saturating_mul(92); - // Keep this loop alive even if CursorMoved events are sparse or coalesced. - self.schedule_egui_repaint_after(interval); + if fills_crop_width && fills_crop_height { + return None; + } - if let Some((monitor, global)) = self.last_fresh_event_cursor() { - let old_monitor = self.active_cursor_monitor(); + Some(bounds) + } - if tracing::enabled!(tracing::Level::TRACE) { - tracing::trace!( - mode = "live", - source = DeviceCursorPointSource::EventRecentFallback.as_str(), - monitor_id = monitor.id, - "Resolved event cursor for live tick." - ); - } - if self.state.cursor == Some(global) && old_monitor == Some(monitor) { - return; - } + fn auto_center_edge_strip_extent(length: u32) -> u32 { + ((length as f32) * 0.08).round().clamp(1.0, 24.0) as u32 + } - let previous_drag_rect = self.state.drag_rect; - let old_cursor = self.state.cursor; + fn region_rgb_mean(image: &RgbaImage, x0: u32, x1: u32, y0: u32, y1: u32) -> Option<[f32; 3]> { + if x0 >= x1 || y0 >= y1 { + return None; + } - self.update_cursor_for_live_move(old_monitor, old_cursor, monitor, global); - self.update_live_drag_rect(monitor, global); + let mut r_total = 0_u64; + let mut g_total = 0_u64; + let mut b_total = 0_u64; + let mut sample_count = 0_u64; - if let Some(old_monitor) = old_monitor - && old_monitor != monitor - { - self.request_redraw_for_monitor(old_monitor); - } + for y in y0..y1 { + for x in x0..x1 { + let pixel = image.get_pixel(x, y); - if Self::live_overlay_redraw_needed_for_cursor_update( - old_monitor, - monitor, - previous_drag_rect, - self.state.drag_rect, - ) { - self.request_redraw_for_monitor(monitor); + r_total += u64::from(pixel[0]); + g_total += u64::from(pixel[1]); + b_total += u64::from(pixel[2]); + sample_count += 1; } - - return; } - // If we're already repainting at a higher cadence (for example selection flow), avoid - // sampling the OS cursor position at that same cadence. - if now.duration_since(self.last_live_cursor_poll_at) < interval { - return; + if sample_count == 0 { + return None; } - self.last_live_cursor_poll_at = now; - - let raw = self.sample_mouse_location(); - let old_monitor = self.active_cursor_monitor(); - let Some((monitor, global, source)) = self.resolve_live_cursor_point(raw) else { - return; - }; + Some([ + r_total as f32 / sample_count as f32, + g_total as f32 / sample_count as f32, + b_total as f32 / sample_count as f32, + ]) + } - if tracing::enabled!(tracing::Level::TRACE) { - tracing::trace!( - mode = "live", - source = source.as_str(), - monitor_id = monitor.id, - "Resolved device cursor for live tick." - ); - } - if self.state.cursor == Some(global) && old_monitor == Some(monitor) { - return; + fn region_rgb_mean_distance( + image: &RgbaImage, + x0: u32, + x1: u32, + y0: u32, + y1: u32, + mean: [f32; 3], + ) -> f32 { + if x0 >= x1 || y0 >= y1 { + return 0.0; } - let previous_drag_rect = self.state.drag_rect; - let old_cursor = self.state.cursor; - - self.update_cursor_for_live_move(old_monitor, old_cursor, monitor, global); - self.update_live_drag_rect(monitor, global); + let mut total_distance = 0_u64; + let mut sample_count = 0_u64; - if let Some(old_monitor) = old_monitor - && old_monitor != monitor - { - self.request_redraw_for_monitor(old_monitor); + for y in y0..y1 { + for x in x0..x1 { + total_distance += + u64::from(Self::rgb_distance_to_mean(image.get_pixel(x, y), mean)); + sample_count += 1; + } } - if Self::live_overlay_redraw_needed_for_cursor_update( - old_monitor, - monitor, - previous_drag_rect, - self.state.drag_rect, - ) { - self.request_redraw_for_monitor(monitor); - } + if sample_count == 0 { 0.0 } else { total_distance as f32 / sample_count as f32 } } - fn maybe_request_keepalive_redraw(&mut self) { - // Avoid a tight present loop if the OS delivers spurious redraws. - if self.is_active() && self.last_present_at.elapsed() > Duration::from_secs(30) { - self.request_redraw_all(); - } + fn rgb_distance_to_mean(pixel: &image::Rgba, mean: [f32; 3]) -> u32 { + (pixel[0] as f32 - mean[0]).abs().round() as u32 + + (pixel[1] as f32 - mean[1]).abs().round() as u32 + + (pixel[2] as f32 - mean[2]).abs().round() as u32 } - fn maybe_tick_live_sampling(&mut self) { - if !matches!(self.state.mode, OverlayMode::Live) { - return; - } - if self.pending_click_hit_test_request_id.is_some() { - return; - } - - let now = Instant::now(); - let Some(cursor) = self.state.cursor else { - return; - }; - let Some(monitor) = self.active_cursor_monitor() else { - return; - }; - - if self - .last_event_cursor_at - .is_some_and(|at| now.duration_since(at) <= LIVE_HOVER_HIT_TEST_INTERVAL) + fn cropped_frozen_capture_image(&self) -> Option { + if self.frozen_capture_source != FrozenCaptureSource::FullscreenFallback + && let Some(window_image) = self.frozen_window_image.as_ref() { - return; - } - if self.live_sample_request_pending() { - return; - } - if !self.idle_live_sampling_request_allowed(now, monitor) { - return; + match self.config.window_capture_alpha_mode { + WindowCaptureAlphaMode::Background => {}, + WindowCaptureAlphaMode::MatteLight => { + return Some(Self::flatten_window_image_with_matte( + window_image, + WINDOW_CAPTURE_MATTE_LIGHT_RGBA, + )); + }, + WindowCaptureAlphaMode::MatteDark => { + return Some(Self::flatten_window_image_with_matte( + window_image, + WINDOW_CAPTURE_MATTE_DARK_RGBA, + )); + }, + } } - self.record_live_sample_stall(cursor, monitor); + let frozen_image = self.state.frozen_image.as_ref()?; + let Some(monitor) = self.state.monitor else { + return Some(frozen_image.clone()); + }; + let capture_rect = self + .state + .frozen_capture_rect + .unwrap_or_else(|| RectPoints::new(0, 0, monitor.width, monitor.height)); + let capture_rect = monitor.local_rect_to_pixels(capture_rect); + let x = capture_rect.x.min(frozen_image.width()); + let y = capture_rect.y.min(frozen_image.height()); + let max_width = frozen_image.width().saturating_sub(x); + let max_height = frozen_image.height().saturating_sub(y); + let width = capture_rect.width.min(max_width); + let height = capture_rect.height.min(max_height); - if self.use_fake_hud_blur() { - self.maybe_request_live_bg(monitor); - } - if self.request_live_samples_for_cursor(monitor, cursor) { - self.last_idle_live_sample_request_at = Some(now); + if width == 0 || height == 0 { + None + } else { + Some(imageops::crop_imm(frozen_image, x, y, width, height).to_image()) } } - #[cfg(test)] - fn observe_scroll_capture_frame( - &mut self, - frame: RgbaImage, - ) -> Option> { - self.observe_scroll_capture_frame_at(frame, Instant::now()) - } - - #[cfg(test)] - fn observe_scroll_capture_frame_at( - &mut self, - frame: RgbaImage, - observation_at: Instant, - ) -> Option> { - self.observe_scroll_capture_frame_with_gate(frame, false, observation_at, false) - } - - fn observe_scroll_capture_frame_with_gate( - &mut self, - frame: RgbaImage, - allow_stale_input: bool, - observation_at: Instant, - allow_post_stall_burst_search: bool, - ) -> Option> { - let prior_block_reason = self.scroll_capture_observation_block_reason_at(observation_at); - #[cfg(target_os = "macos")] - let consumed_live_stream_stale_grace = !allow_stale_input - && prior_block_reason == Some("stale_input") - && self.consume_live_stream_stale_grace_if_current(); - #[cfg(not(target_os = "macos"))] - let consumed_live_stream_stale_grace = false; - let allow_gate_bypass = allow_stale_input || consumed_live_stream_stale_grace; - let motion_rows_hint = self.scroll_capture_commit_motion_rows_hint_at(observation_at); - - if !allow_gate_bypass && prior_block_reason.is_some() { - return Some(Ok(ScrollObserveOutcome::NoChange)); - } + #[cfg(target_os = "macos")] + fn cropped_monitor_frozen_region_image( + &self, + monitor: MonitorRect, + capture_rect_pixels: RectPoints, + ) -> Option { + let frozen_image = self.state.frozen_image.as_ref()?; + let x = capture_rect_pixels.x.min(frozen_image.width()); + let y = capture_rect_pixels.y.min(frozen_image.height()); + let max_width = frozen_image.width().saturating_sub(x); + let max_height = frozen_image.height().saturating_sub(y); + let width = capture_rect_pixels.width.min(max_width); + let height = capture_rect_pixels.height.min(max_height); - let result = { - let Some(session) = self.scroll_capture.session.as_mut() else { - self.scroll_capture_set_error("Scroll capture session is unavailable."); + if width == 0 || height == 0 { + tracing::debug!( + monitor_id = monitor.id, + capture_rect_pixels = ?capture_rect_pixels, + frozen_image_size = ?(frozen_image.width(), frozen_image.height()), + "Scroll capture base-frame crop resolved to an empty region." + ); - return None; - }; + None + } else { + Some(imageops::crop_imm(frozen_image, x, y, width, height).to_image()) + } + } - session.observe_downward_sample_with_motion_hint_and_burst( - frame, - motion_rows_hint, - allow_post_stall_burst_search, - ) - }; + fn flatten_window_image_with_matte(image: &RgbaImage, matte: image::Rgba) -> RgbaImage { + let mut out = RgbaImage::from_pixel(image.width(), image.height(), matte); - if let Ok(outcome) = &result { - self.consume_scroll_capture_downward_motion_rows_for_outcome(outcome); - } + imageops::overlay(&mut out, image, 0, 0); - Some(result) + out } - fn scroll_capture_commit_motion_rows_hint_at(&self, observation_at: Instant) -> Option { - if self.scroll_capture.input_direction != Some(ScrollDirection::Down) { - return None; + fn compose_window_preview_layer( + window_image: &RgbaImage, + alpha_mode: WindowCaptureAlphaMode, + ) -> RgbaImage { + match alpha_mode { + WindowCaptureAlphaMode::Background => window_image.clone(), + WindowCaptureAlphaMode::MatteLight => { + Self::flatten_window_image_with_matte(window_image, WINDOW_CAPTURE_MATTE_LIGHT_RGBA) + }, + WindowCaptureAlphaMode::MatteDark => { + Self::flatten_window_image_with_matte(window_image, WINDOW_CAPTURE_MATTE_DARK_RGBA) + }, } + } - let input_direction_at = self.scroll_capture.input_direction_at?; + fn composite_window_capture_preview( + mut monitor_image: RgbaImage, + window_image: &RgbaImage, + monitor: MonitorRect, + capture_rect_points: RectPoints, + alpha_mode: WindowCaptureAlphaMode, + ) -> RgbaImage { + let capture_rect_px = monitor.local_rect_to_pixels(capture_rect_points); - if !self.scroll_capture.input_gesture_active - && observation_at.saturating_duration_since(input_direction_at) - > SCROLL_CAPTURE_INPUT_FRESHNESS - { - return None; + if capture_rect_px.width == 0 || capture_rect_px.height == 0 { + return monitor_image; } - if !self.scroll_capture.downward_motion_rows_pending.is_finite() - || self.scroll_capture.downward_motion_rows_pending <= 0.0 + + let window_overlay = if window_image.width() == capture_rect_px.width + && window_image.height() == capture_rect_px.height { - return None; - } + window_image.clone() + } else { + imageops::resize( + window_image, + capture_rect_px.width, + capture_rect_px.height, + FilterType::Triangle, + ) + }; + let preview_layer = Self::compose_window_preview_layer(&window_overlay, alpha_mode); - Some(self.scroll_capture.downward_motion_rows_pending.ceil() as u32) + imageops::overlay( + &mut monitor_image, + &preview_layer, + i64::from(capture_rect_px.x), + i64::from(capture_rect_px.y), + ); + + monitor_image } - fn sync_scroll_preview_segments(&mut self) { - let image = self.current_scroll_preview_render_image(); + fn handle_captured_freeze_response( + &mut self, + monitor: MonitorRect, + image: RgbaImage, + window_image: Option, + captured_window_id: Option, + ) { + if matches!(self.state.mode, OverlayMode::Frozen) && self.state.monitor == Some(monitor) { + self.inflight_freeze_capture = None; + self.authoritative_frozen_capture_ready = true; - { - let Some(preview) = self.scroll_preview_window.as_mut() else { - return; - }; + let window_capture_target = self.inflight_window_freeze_capture.take(); + let mut frozen_preview_image = image; - preview.sync_image(image); - preview.window.request_redraw(); - } + self.pending_window_freeze_capture = None; + self.frozen_window_image = None; - if let Some(monitor) = self.scroll_capture.monitor.or(self.state.monitor) { - #[cfg(target_os = "macos")] + if let (Some(target), Some(window_capture_image), Some(window_id)) = + (window_capture_target, window_image, captured_window_id) + && target.monitor == monitor + && target.window_id == window_id { - self.position_scroll_preview_window(monitor); - } + match self.config.window_capture_alpha_mode { + WindowCaptureAlphaMode::Background => {}, + WindowCaptureAlphaMode::MatteLight | WindowCaptureAlphaMode::MatteDark => { + self.frozen_window_image = Some(window_capture_image); - #[cfg(not(target_os = "macos"))] - { - let _ = monitor; + if let Some(window_capture_image) = self.frozen_window_image.as_ref() { + frozen_preview_image = Self::composite_window_capture_preview( + frozen_preview_image, + window_capture_image, + monitor, + target.rect, + self.config.window_capture_alpha_mode, + ); + } + }, + } } - } - } - fn refresh_scroll_preview_committed_image(&mut self) { - self.scroll_capture.preview_committed_image = - self.scroll_capture.session.as_ref().map(|session| session.export_image().clone()); - } + self.state.finish_freeze(monitor, frozen_preview_image); + self.restore_capture_windows_visibility(); - fn refresh_scroll_preview_display_image(&mut self) { - let motion_rows_hint = None; - - self.scroll_capture.last_overlay_preview_motion_rows_hint = motion_rows_hint; - self.scroll_capture.last_overlay_preview_provisional_motion_rows_hint = None; - self.scroll_capture.last_overlay_preview_existing_candidate_height = None; - self.scroll_capture.last_overlay_preview_existing_candidate_motion_rows_hint = None; - self.scroll_capture.last_overlay_preview_ledger_candidate_height = None; - self.scroll_capture.last_overlay_preview_ledger_candidate_motion_rows_hint = None; - self.scroll_capture.last_overlay_preview_retained_candidate_height = None; - self.scroll_capture.last_overlay_preview_retained_candidate_motion_rows_hint = None; - self.scroll_capture.last_overlay_preview_retained_hint_matches_motion_rows = false; - self.scroll_capture.last_overlay_preview_fresh_latest_frame_can_drive = false; - self.scroll_capture.last_overlay_preview_strong_unresolved_registration = false; - self.scroll_capture.last_overlay_preview_latest_frame_present = - self.scroll_capture.preview_latest_frame.is_some(); - self.scroll_capture.last_overlay_preview_used_provisional = false; - - if let Some(session) = self.scroll_capture.session.as_mut() { - self.scroll_capture.preview_committed_image = Some(session.export_image().clone()); - self.scroll_capture.preview_display_image = - self.scroll_capture.preview_committed_image.clone(); + self.toolbar_state.needs_redraw = true; - return; - } + #[cfg(target_os = "macos")] + if self.toolbar_state.visible { + self.toolbar_window_warmup_redraws_remaining = + self.toolbar_window_warmup_redraws_remaining.max(TOOLBAR_WINDOW_WARMUP_REDRAWS); + } - self.scroll_capture.preview_display_image = - self.scroll_capture.preview_committed_image.as_ref().map(|base_preview| { - scroll_capture::compose_provisional_preview_image( - base_preview, - self.scroll_capture.preview_latest_frame.as_ref(), - motion_rows_hint, - SCROLL_CAPTURE_PREVIEW_WIDTH_PX, + if let Some(cursor) = self.state.cursor { + self.state.rgb = + image_helpers::frozen_rgb(&self.state.frozen_image, Some(monitor), cursor); + self.state.loupe = image_helpers::frozen_loupe_patch( + &self.state.frozen_image, + Some(monitor), + cursor, + self.loupe_patch_width_px, + self.loupe_patch_height_px, ) - }); - } - - fn scroll_capture_preview_dimensions(&self) -> Option<[u32; 2]> { - self.current_scroll_preview_render_image() - .as_ref() - .map(|image| [image.width(), image.height()]) - } - - fn scroll_preview_display_size_points(&self) -> Option { - let [width_px, height_px] = self.scroll_capture_preview_dimensions()?; - - if width_px == 0 || height_px == 0 { - return None; - } + .map(|patch| crate::state::LoupeSample { center: cursor, patch }); - let width_points = SCROLL_PREVIEW_WINDOW_WIDTH_POINTS as f32; - let scale = width_points / width_px as f32; + self.update_hud_window_position(monitor, cursor); + } - Some(Vec2::new(width_points, (height_px as f32 * scale).max(1.0))) - } + self.maybe_start_loupe_window_warmup_redraw(); + self.request_redraw_hud_window(); - fn scroll_capture_set_error(&mut self, message: impl Into) { - let message = message.into(); + if self.state.alt_held || self.loupe_window_visible { + self.request_redraw_loupe_window(); + } - tracing::warn!( - op = "scroll_capture.error", - error = %message, - "Scroll capture paused on error." - ); + self.request_redraw_toolbar_window(); + self.request_redraw_for_monitor(monitor); + #[cfg(not(target_os = "macos"))] + self.raise_hud_windows(); - if let Some(trace_recorder) = self.scroll_capture.trace_recorder.as_mut() { - trace_recorder.record_error(&message); + return; } - - self.scroll_capture.paused = true; - - self.state.set_error(message); - self.request_redraw_all(); - } - - fn drain_worker_responses(&mut self) -> OverlayControl { - #[cfg(target_os = "macos")] - if self.worker.is_none() && self.live_sample_worker.is_none() { - return OverlayControl::Continue; + if self.inflight_freeze_capture == Some(monitor) { + self.inflight_freeze_capture = None; } - #[cfg(not(target_os = "macos"))] - if self.worker.is_none() { - return OverlayControl::Continue; + if self.inflight_window_freeze_capture.is_some_and(|inflight| inflight.monitor == monitor) { + self.inflight_window_freeze_capture = None; + self.pending_window_freeze_capture = None; } - - #[cfg(target_os = "macos")] - while let Some(resp) = self.live_sample_worker.as_ref().and_then(|worker| worker.try_recv()) + if matches!(self.state.mode, OverlayMode::Live) + && self.use_fake_hud_blur() + && self.active_cursor_monitor() == Some(monitor) { - let control = self.maybe_tick_worker_response_limiter(resp); + self.state.live_bg_monitor = Some(monitor); + self.state.live_bg_image = Some(image); + self.state.live_bg_generation = self.state.live_bg_generation.wrapping_add(1); - if !matches!(control, OverlayControl::Continue) { - return control; - } + self.request_redraw_for_monitor(monitor); } + } - #[cfg(not(target_os = "macos"))] - let queued_recognize_text = false; - #[cfg(target_os = "macos")] - let queued_recognize_text = self.pending_recognize_text.is_some(); + fn handle_encoded_png_response(&mut self, png_bytes: Vec) -> OverlayControl { + let Some(action) = self.pending_png_action.take() else { + return OverlayControl::Continue; + }; - #[cfg(target_os = "macos")] - if !self.ocr_inflight - && let Some(request) = self.pending_recognize_text.take() - { - if let Some(worker) = self.worker.as_ref() { - if let Err((request_id, image)) = - worker.request_recognize_text(request.request_id, request.image) - { - self.pending_recognize_text = - Some(PendingRecognizeTextRequest { request_id, image }); - } else { - self.ocr_inflight = true; - } - } else { - self.pending_recognize_text = Some(request); - } - } - if !queued_recognize_text && let Some(image) = self.pending_encode_png.take() { - if let Some(worker) = self.worker.as_ref() { - if let Err(image) = worker.request_encode_png(image) { - self.pending_encode_png = Some(image); - } else { - #[cfg(target_os = "macos")] - { - self.png_encode_inflight = true; - } - } - } else { - self.pending_encode_png = Some(image); - } - } + match action { + PngAction::Copy => match output::write_png_bytes_to_clipboard(&png_bytes) { + Ok(()) => self.exit(OverlayExit::PngBytes(png_bytes)), + Err(err) => { + self.state.set_error(format!("{err:#}")); + self.request_redraw_all(); - while let Some(resp) = - self.worker.as_ref().and_then(|worker| worker.try_recv_captured_monitor_region()) - { - match resp.result { - CapturedMonitorRegionResult::Image(image) => { - self.handle_captured_scroll_region( - resp.monitor, - resp.rect_px, - resp.request_id, - image, - ); - }, - CapturedMonitorRegionResult::NoNewFrame => { - self.handle_missing_scroll_region(resp.monitor, resp.rect_px, resp.request_id); + OverlayControl::Continue }, - } - } - while let Some(resp) = self.worker.as_ref().and_then(|worker| worker.try_recv()) { - let control = self.maybe_tick_worker_response_limiter(resp); + }, + PngAction::Save => { + match output::save_png_bytes_to_configured_dir(&png_bytes, &self.config) { + Ok(path) => self.exit(OverlayExit::Saved(path)), + Err(err) => { + self.state.set_error(format!("{err:#}")); + self.request_redraw_all(); - if !matches!(control, OverlayControl::Continue) { - return control; - } + OverlayControl::Continue + }, + } + }, } - - OverlayControl::Continue } - fn request_live_samples_for_cursor( - &mut self, - monitor: MonitorRect, - cursor: GlobalPoint, - ) -> bool { - if self.pending_click_hit_test_request_id.is_some() { - return false; - } - - let is_dragging_window = matches!(self.state.mode, OverlayMode::Live) - && self.left_mouse_button_down - && self.left_mouse_button_down_monitor == Some(monitor); - let had_snapshot_update = if is_dragging_window || self.state.alt_held { - false - } else { - self.apply_live_hover_cache_state(monitor, cursor) - }; - let sample_updated = self.request_live_cursor_sample(monitor, cursor, self.state.alt_held); + #[cfg(target_os = "macos")] + fn handle_recognized_text_response(&mut self, text: String) -> OverlayControl { + if text.trim().is_empty() { + self.state.set_error(String::from("No text recognized.")); + self.request_redraw_all(); - if !is_dragging_window && !self.state.alt_held { - let _ = self.request_live_window_list_refresh_if_needed(); + return OverlayControl::Continue; } - let apply = self.live_sample_request_redraw_intent( - had_snapshot_update, - sample_updated, - self.state.alt_held || self.loupe_window_visible, - ); + match output::write_text_to_clipboard(&text) { + Ok(()) => self.exit(OverlayExit::TextCopied(text.chars().count())), + Err(err) => { + self.state.set_error(format!("{err:#}")); + self.request_redraw_all(); - if apply.any_changed() { - self.request_redraw_live_sample_targets(monitor, apply); + OverlayControl::Continue + }, } - - sample_updated } - fn request_live_window_list_refresh_if_needed(&mut self) -> bool { - #[cfg(target_os = "macos")] - if self.window_list_refresh_inflight { - return false; - } - - let now = Instant::now(); - let needs_refresh = self.window_list_snapshot.as_ref().is_none_or(|snapshot| { - now.duration_since(snapshot.captured_at) > self.window_list_refresh_interval - || self.state.alt_held - }); - let throttled = now.duration_since(self.last_window_list_refresh_request_at) - < self.window_list_refresh_interval; - - if !needs_refresh || throttled { - return false; - } - - let Some(worker) = self.worker.as_ref() else { - return false; - }; - - if !worker.request_refresh_window_list() { - return false; - } + #[cfg(target_os = "macos")] + fn next_ocr_request_id(&mut self) -> u64 { + let request_id = self.next_ocr_request_id; - self.last_window_list_refresh_request_at = now; - #[cfg(target_os = "macos")] - { - self.window_list_refresh_inflight = true; - } + self.next_ocr_request_id = self.next_ocr_request_id.wrapping_add(1); - true + request_id } - fn log_live_sample_apply_timing( - &self, - path: &'static str, - monitor: MonitorRect, - point: GlobalPoint, - request_id: u64, - elapsed: Duration, - apply: LiveSampleApplyResult, - ) { - tracing::trace!( - op = "overlay.live_sample_apply_phase", - path, - request_id, - monitor_id = monitor.id, - point = ?point, - latency_us = elapsed.as_micros(), - alt_held = self.state.alt_held, - overlay_changed = apply.overlay_changed, - hud_changed = apply.hud_changed, - loupe_changed = apply.loupe_changed, - "Live sample apply phase timing." - ); - - if elapsed >= Duration::from_millis(12) { - tracing::debug!( - op = "overlay.live_sample_apply_latency", - path, - request_id, - monitor_id = monitor.id, - point = ?point, - latency_ms = elapsed.as_millis(), - alt_held = self.state.alt_held, - overlay_changed = apply.overlay_changed, - hud_changed = apply.hud_changed, - loupe_changed = apply.loupe_changed, - "Live cursor sample apply latency exceeded the target frame budget." - ); - } + #[cfg(target_os = "macos")] + fn cancel_ocr_output_intent(&mut self) { + self.active_ocr_request_id = None; + self.pending_recognize_text = None; } - fn request_live_cursor_sample( - &mut self, - monitor: MonitorRect, - cursor: GlobalPoint, - want_patch: bool, - ) -> bool { - if !monitor.contains(cursor) { - return false; - } - - #[cfg(target_os = "macos")] - { - let Some(stream) = self.live_sample_stream.as_ref() else { - return false; - }; - let request_id = self.live_cursor_sample_request_id.wrapping_add(1); - let patch_width_px = if want_patch { self.loupe_patch_width_px } else { 0 }; - let patch_height_px = if want_patch { self.loupe_patch_height_px } else { 0 }; - let Some((x_px, y_px)) = monitor.local_u32_pixels(cursor) else { - return false; - }; - let sample = stream.latest_cursor_sample( - monitor, - CursorSampleRequest::with_optional_patch( - x_px, - y_px, - want_patch, - patch_width_px, - patch_height_px, - ), - ); - - self.note_live_cursor_sample_request_started(request_id); - - let Some(sample) = sample else { - self.finish_sync_live_cursor_sample_attempt(request_id); - - return false; - }; - - self.finish_sync_live_cursor_sample_attempt(request_id); - - let apply = self.apply_live_cursor_sample_detail(monitor, cursor, sample); - let sample_latency = self - .latest_live_cursor_sample_requested_at - .take() - .map_or(Duration::ZERO, |requested_at| requested_at.elapsed()); - - self.log_live_sample_apply_timing( - "macos_stream", - monitor, - cursor, - request_id, - sample_latency, - apply, - ); - - if apply.any_changed() { - self.request_redraw_live_sample_targets(monitor, apply); - } - - true - } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "macos")] + fn maybe_request_redraw_for_pending_output(&mut self) { + if !self.ocr_inflight + && (self.pending_recognize_text.is_some() || self.pending_encode_png.is_some()) { - if self.live_sample_request_pending() { - return false; - } - - let Some(worker) = self.worker.as_ref() else { - return false; - }; - let request_id = self.live_cursor_sample_request_id.wrapping_add(1); - let patch_width_px = if want_patch { self.loupe_patch_width_px } else { 0 }; - let patch_height_px = if want_patch { self.loupe_patch_height_px } else { 0 }; - - match worker.request_sample_live_cursor( - monitor, - cursor, - request_id, - want_patch, - patch_width_px, - patch_height_px, - ) { - Ok(()) => { - self.note_live_cursor_sample_request_started(request_id); - - true - }, - Err(WorkerRequestSendError::Full) => { - tracing::debug!( - request_id, - monitor_id = monitor.id, - point = ?cursor, - "Live cursor sample request dropped: worker queue full." - ); - - false - }, - Err(WorkerRequestSendError::Disconnected) => { - tracing::debug!( - request_id, - monitor_id = monitor.id, - point = ?cursor, - "Live cursor sample request dropped: worker queue disconnected." - ); - - false - }, - } + self.request_redraw_all(); } } - fn apply_live_cursor_sample_detail( + #[cfg(target_os = "macos")] + fn handle_recognized_text_worker_response( &mut self, - monitor: MonitorRect, - point: GlobalPoint, - sample: LiveCursorSample, - ) -> LiveSampleApplyResult { - if !matches!(self.state.mode, OverlayMode::Live) { - return LiveSampleApplyResult::default(); - } - if self.active_cursor_monitor() != Some(monitor) { - return LiveSampleApplyResult::default(); - } - - let is_dragging_window = self.left_mouse_button_down - && self.left_mouse_button_down_monitor == Some(monitor) - && matches!(self.state.mode, OverlayMode::Live); - let mut changed = LiveSampleApplyResult::default(); + request_id: u64, + text: String, + ) -> OverlayControl { + self.ocr_inflight = false; - if is_dragging_window { - if self.state.hovered_window_rect.is_some() { - self.state.hovered_window_rect = None; - changed.overlay_changed = true; - changed.hud_changed = true; - } - } else if self.apply_live_hover_cache_state(monitor, point) { - changed.overlay_changed = true; - changed.hud_changed = true; - } - if self.state.rgb != sample.rgb && sample.rgb.is_some() { - self.state.rgb = sample.rgb; - changed.hud_changed = true; + if self.active_ocr_request_id != Some(request_id) { + return OverlayControl::Continue; } - if self.state.alt_held { - let loupe = - sample.patch.map(|patch| crate::state::LoupeSample { center: point, patch }); - let loupe_changed = match (&self.state.loupe, &loupe) { - (Some(current), Some(next)) => { - current.center != next.center || current.patch != next.patch - }, - (None, None) => false, - _ => true, - }; - if loupe_changed { - self.state.loupe = loupe; - changed.loupe_changed = true; - } - } else if self.state.loupe.is_some() { - self.state.loupe = None; - changed.loupe_changed = true; - } + self.active_ocr_request_id = None; - changed + self.handle_recognized_text_response(text) } - fn apply_live_hover_cache_state(&mut self, monitor: MonitorRect, cursor: GlobalPoint) -> bool { - if !matches!(self.state.mode, OverlayMode::Live) { - return false; - } - if !monitor.contains(cursor) { - return false; - } - - let hovered_window_rect = self - .hovered_window_hit_from_window_list_snapshot(monitor, cursor) - .map(|hit| MonitorRectPoints { monitor_id: monitor.id, rect: hit.rect }); - let mut updated = false; - - if self.state.hovered_window_rect != hovered_window_rect { - self.state.hovered_window_rect = hovered_window_rect; - updated = true; - } + #[cfg(target_os = "macos")] + fn handle_recognized_text_worker_error(&mut self) -> bool { + self.ocr_inflight = false; - updated - } + if self.active_ocr_request_id.is_none() || self.pending_recognize_text.is_some() { + self.maybe_request_redraw_for_pending_output(); - fn live_sample_request_redraw_intent( - &self, - hover_changed: bool, - _sample_requested: bool, - _loupe_active: bool, - ) -> LiveSampleApplyResult { - let mut apply = LiveSampleApplyResult::default(); - - if hover_changed { - apply.overlay_changed = true; - apply.hud_changed = true; + return true; } - apply - } - - fn idle_live_sampling_interval(&self, monitor: MonitorRect) -> Duration { - self.repaint_interval_for_monitor(Some(monitor)).max(CURSOR_POLL_INTERVAL_MIN) - } + self.active_ocr_request_id = None; - fn idle_live_sampling_request_allowed(&self, now: Instant, monitor: MonitorRect) -> bool { - self.last_idle_live_sample_request_at.is_none_or(|last_request_at| { - now.duration_since(last_request_at) >= self.idle_live_sampling_interval(monitor) - }) + false } - fn hovered_window_hit_from_window_list_snapshot( - &self, - monitor: MonitorRect, - cursor: GlobalPoint, - ) -> Option { - let (local_x, local_y) = monitor.local_u32(cursor)?; - let window_list_snapshot = self.window_list_snapshot.as_ref()?; - - window_list_snapshot.windows.iter().find_map(|window| { - let rect = monitor.clip_global_rect_i64( - window.x, - window.y, - window.x.saturating_add(window.width), - window.y.saturating_add(window.height), - )?; - - if !rect.contains((local_x, local_y)) { - return None; - } - - Some(WindowHit { window_id: window.window_id, rect }) - }) + fn maybe_stop_frozen_selection_drag_for_mouse_input( + &mut self, + state: ElementState, + button: MouseButton, + ) { + if state == ElementState::Released && button == MouseButton::Left { + self.stop_frozen_selection_drag(); + } } - fn record_live_sample_stall(&mut self, cursor: GlobalPoint, monitor: MonitorRect) { + /// Handles a winit window event for one of the overlay-owned windows. + pub fn handle_window_event( + &mut self, + window_id: WindowId, + event: &WindowEvent, + ) -> OverlayControl { + let started_at = Instant::now(); + let kind = Self::window_event_kind(event); let now = Instant::now(); - match self.last_live_sample_cursor { - Some(last_cursor) if last_cursor == cursor => { - let stall_started_at = self.live_sample_stall_started_at; - - if self.live_sample_stall_started_at.is_none() { - self.live_sample_stall_started_at = Some(now); - } else if stall_started_at - .is_some_and(|start| now.duration_since(start) >= Duration::from_millis(100)) - && self.last_live_sample_stall_log_at.is_none_or(|last_log| { - now.duration_since(last_log) >= Duration::from_millis(250) - }) { - let Some(stall_started_at) = self.live_sample_stall_started_at else { - return; - }; + self.event_loop_last_progress_window_id = Some(window_id); + self.event_loop_last_progress_monitor_id = + self.windows.get(&window_id).map(|window| window.monitor.id); - tracing::debug!( - cursor = ?cursor, - monitor_id = monitor.id, - stall_duration_ms = now.duration_since(stall_started_at).as_millis(), - "Live sampling cursor unchanged while sampling ticks continue." - ); + self.maybe_log_event_loop_stall(now); + self.mark_progress_with_detail(OverlayEventLoopPhase::WindowEvent, Some(kind)); - self.last_live_sample_stall_log_at = Some(now); - } - }, - Some(_) => { - self.live_sample_stall_started_at = None; - self.last_live_sample_stall_log_at = None; + match event { + WindowEvent::MouseInput { state, button, .. } => { + self.maybe_stop_frozen_selection_drag_for_mouse_input(*state, *button); }, - None => { - self.live_sample_stall_started_at = Some(now); + WindowEvent::Focused(focused) => { + self.note_window_focus_change(window_id, *focused); }, + _ => {}, } - self.last_live_sample_cursor = Some(cursor); - } - - fn maybe_tick_worker_response_limiter(&mut self, resp: WorkerResponse) -> OverlayControl { - let control = match resp { - #[cfg(not(target_os = "macos"))] - WorkerResponse::SampledLiveCursor { monitor, point, request_id, sample } => { - self.handle_sampled_live_cursor_response(monitor, point, request_id, sample); + if let Some(control) = self.handle_scroll_preview_event(window_id, event) { + return control; + } - OverlayControl::Continue + let toolbar_window_id = self + .toolbar_window + .as_ref() + .is_some_and(|toolbar_window| toolbar_window.window.id() == window_id); + let control = match event { + WindowEvent::CloseRequested => self.cancel_overlay("window_close_requested"), + WindowEvent::MouseInput { + state: ElementState::Pressed, + button: MouseButton::Right, + .. + } => self.cancel_overlay("window_right_click"), + WindowEvent::Resized(size) if toolbar_window_id => { + self.handle_toolbar_window_resized(*size) }, - WorkerResponse::RefreshedWindowList { snapshot } => { - #[cfg(target_os = "macos")] - { - self.window_list_refresh_inflight = false; - } + WindowEvent::Resized(size) => self.handle_resized(window_id, *size), + WindowEvent::ScaleFactorChanged { .. } if toolbar_window_id => { + self.handle_toolbar_window_scale_factor_changed(window_id) + }, + WindowEvent::ScaleFactorChanged { .. } => self.handle_scale_factor_changed(window_id), + WindowEvent::CursorEntered { .. } if toolbar_window_id => OverlayControl::Continue, + WindowEvent::CursorLeft { .. } if toolbar_window_id => { + self.toolbar_pointer_local = None; + self.toolbar_left_button_down = false; + self.toolbar_left_button_went_down = false; + self.toolbar_left_button_went_up = false; + self.toolbar_state.dragging = false; + self.toolbar_state.drag_offset = Vec2::ZERO; + self.toolbar_state.drag_anchor = None; #[cfg(target_os = "macos")] - let should_apply_snapshot = !mem::take(&mut self.drop_next_window_list_refresh_snapshot); - #[cfg(not(target_os = "macos"))] - let should_apply_snapshot = true; - - if should_apply_snapshot { - self.handle_refreshed_window_list(snapshot); + { + self.request_redraw_toolbar_window(); } OverlayControl::Continue }, - WorkerResponse::HitTestWindow { monitor, point, request_id, hit } => { - self.handle_hit_test_window_response(monitor, point, request_id, hit); - - OverlayControl::Continue - }, - WorkerResponse::CapturedFreeze { monitor, image, window_image, captured_window_id } => { - self.handle_captured_freeze_response( - monitor, - image, - window_image, - captured_window_id, - ); - - OverlayControl::Continue + WindowEvent::CursorMoved { position, .. } => { + if toolbar_window_id { + self.handle_toolbar_cursor_moved(window_id, *position) + } else { + self.handle_cursor_moved(window_id, *position) + } }, - #[cfg(target_os = "macos")] - WorkerResponse::RecognizedText { request_id, text } => { - self.handle_recognized_text_worker_response(request_id, text) + WindowEvent::MouseWheel { delta, .. } if toolbar_window_id => OverlayControl::Continue, + WindowEvent::MouseWheel { delta, .. } => { + self.handle_scroll_mouse_wheel(window_id, delta) }, - WorkerResponse::Error { source, message } => { - match source { - WorkerErrorSource::FreezeCapture => { - self.pending_freeze_capture = None; - self.inflight_freeze_capture = None; - self.pending_freeze_capture_armed = false; - self.pending_window_freeze_capture = None; - self.inflight_window_freeze_capture = None; - - self.restore_capture_windows_visibility(); - }, - WorkerErrorSource::RefreshWindowList => { - #[cfg(target_os = "macos")] - { - self.window_list_refresh_inflight = false; - self.drop_next_window_list_refresh_snapshot = false; - } - }, - WorkerErrorSource::EncodePng => { - #[cfg(target_os = "macos")] - { - self.png_encode_inflight = false; - } - }, - #[cfg(target_os = "macos")] - WorkerErrorSource::RecognizeText => { - if self.handle_recognized_text_worker_error() { - return OverlayControl::Continue; - } - }, - WorkerErrorSource::CaptureMonitorRegion => { - self.clear_scroll_capture_inflight_request(); - self.scroll_capture_set_error(message); - - return OverlayControl::Continue; - }, + WindowEvent::MouseInput { state, button: MouseButton::Left, .. } => { + if toolbar_window_id { + self.handle_toolbar_mouse_input(*state) + } else { + self.handle_left_mouse_input(window_id, *state) } - - self.state.set_error(message); - self.request_redraw_all(); - - OverlayControl::Continue }, - WorkerResponse::EncodedPng { png_bytes } => { - #[cfg(target_os = "macos")] - { - self.png_encode_inflight = false; + WindowEvent::RedrawRequested if toolbar_window_id => { + self.handle_toolbar_window_redraw_requested() + }, + WindowEvent::ThemeChanged(_) => { + // Keep the HUD palette in sync with system changes when ThemeMode::System is active. + if let Some(monitor) = self.windows.get(&window_id).map(|w| w.monitor) { + self.request_redraw_for_monitor(monitor); + } else { + self.request_redraw_all(); } - self.handle_encoded_png_response(png_bytes) + OverlayControl::Continue }, + WindowEvent::KeyboardInput { event, .. } => self.handle_key_event(event), + WindowEvent::ModifiersChanged(modifiers) => self.handle_modifiers_changed(modifiers), + WindowEvent::RedrawRequested => self.handle_redraw_requested(window_id), + _ => OverlayControl::Continue, }; - #[cfg(target_os = "macos")] - if matches!(control, OverlayControl::Continue) { - self.maybe_request_redraw_for_pending_output(); - self.maybe_apply_pending_self_capture_exception_window_ids_worker_refresh(); - } + self.slow_op_logger.warn_if_slow( + "overlay.window_event", + started_at.elapsed(), + SLOW_OP_WARN_WINDOW_EVENT, + || format!("kind={kind} window_id={window_id:?} toolbar_window={toolbar_window_id}"), + ); control } - #[cfg(not(target_os = "macos"))] - fn handle_sampled_live_cursor_response( - &mut self, - monitor: MonitorRect, - point: GlobalPoint, - request_id: u64, - sample: LiveCursorSample, - ) { - if !matches!(self.state.mode, OverlayMode::Live) { - return; - } - if self.active_cursor_monitor() != Some(monitor) { - return; - } - if self.latest_live_cursor_sample_request_id != Some(request_id) { - return; - } + fn note_window_focus_change(&mut self, window_id: WindowId, focused: bool) { + if focused { + self.focused_window_ids.insert(window_id); - self.applied_live_cursor_sample_request_id = Some(request_id); + self.pending_focus_loss_cleanup = false; - let apply = self.apply_live_cursor_sample_detail(monitor, point, sample); - let sample_latency = self - .latest_live_cursor_sample_requested_at - .take() - .map_or(Duration::ZERO, |requested_at| requested_at.elapsed()); + return; + } - self.log_live_sample_apply_timing( - "worker_response", - monitor, - point, - request_id, - sample_latency, - apply, - ); + self.focused_window_ids.remove(&window_id); - if apply.any_changed() { - self.request_redraw_live_sample_targets(monitor, apply); + if self.focused_window_ids.is_empty() { + self.pending_focus_loss_cleanup = true; } } - fn handle_refreshed_window_list(&mut self, snapshot: Arc) { - self.window_list_snapshot = Some(snapshot); + fn handle_toolbar_mouse_input(&mut self, state: ElementState) -> OverlayControl { + let toolbar_left_button_down = matches!(state, ElementState::Pressed); - if !matches!(self.state.mode, OverlayMode::Live) { - return; + if toolbar_left_button_down == self.toolbar_left_button_down { + return OverlayControl::Continue; + } + if toolbar_left_button_down { + self.toolbar_left_button_went_down = true; + } else { + self.toolbar_left_button_went_up = true; } - let Some(cursor) = self.state.cursor else { - return; - }; - let Some(monitor) = self.active_cursor_monitor() else { - return; - }; - let is_dragging_window = self.left_mouse_button_down - && self.left_mouse_button_down_monitor == Some(monitor) - && matches!(self.state.mode, OverlayMode::Live); - - if is_dragging_window { - if self.state.hovered_window_rect.is_some() { - self.state.hovered_window_rect = None; + self.toolbar_left_button_down = toolbar_left_button_down; - self.request_redraw_live_sample_targets( - monitor, - LiveSampleApplyResult { - overlay_changed: true, - hud_changed: true, - loupe_changed: false, - }, - ); - } + if !toolbar_left_button_down { + self.stop_frozen_selection_drag(); - return; - } - if self.apply_live_hover_cache_state(monitor, cursor) { - self.request_redraw_live_sample_targets( - monitor, - LiveSampleApplyResult { - overlay_changed: true, - hud_changed: true, - loupe_changed: false, - }, - ); + self.toolbar_state.dragging = false; + self.toolbar_state.drag_offset = Vec2::ZERO; + self.toolbar_state.drag_anchor = None; + } else { + self.toolbar_state.drag_offset = Vec2::ZERO; + self.toolbar_state.dragging = false; + self.toolbar_state.drag_anchor = None; } - } - fn handle_hit_test_window_response( - &mut self, - monitor: MonitorRect, - point: GlobalPoint, - request_id: u64, - hit: Option, - ) { - if !matches!(self.state.mode, OverlayMode::Live) { - return; - } - if self.pending_click_hit_test_request_id == Some(request_id) { - self.pending_click_hit_test_request_id = None; - self.state.hovered_window_rect = None; - - let capture_rect = hit.map(|window_hit| window_hit.rect); - let window_target = hit.and_then(|window_hit| { - window_hit.window_id.map(|window_id| WindowFreezeCaptureTarget { - monitor, - window_id, - rect: window_hit.rect, - }) - }); - - self.begin_frozen_capture_with_rect(monitor, capture_rect, window_target, Some(point)); + #[cfg(target_os = "macos")] + { + self.request_redraw_toolbar_window(); } + + OverlayControl::Continue } - fn request_click_capture_hit_test(&mut self, monitor: MonitorRect, cursor: GlobalPoint) { - self.request_live_window_list_refresh_if_needed(); + fn reset_toolbar_pointer_state(&mut self) { + self.toolbar_left_button_down = false; + self.toolbar_left_button_went_down = false; + self.toolbar_left_button_went_up = false; + self.toolbar_pointer_local = None; + self.toolbar_state.drag_anchor = None; + } - if self.window_list_snapshot.is_none() { - let request_id = self.hit_test_request_id.wrapping_add(1); - let Some(worker) = self.worker.as_ref() else { - self.begin_frozen_capture_with_rect(monitor, None, None, Some(cursor)); + fn handle_modifiers_changed(&mut self, modifiers: &winit::event::Modifiers) -> OverlayControl { + self.keyboard_modifiers = modifiers.state(); - return; - }; + OverlayControl::Continue + } - self.hit_test_request_id = request_id; + #[cfg(not(target_os = "macos"))] + fn sample_mouse_location(&mut self) -> GlobalPoint { + let Some(cursor_device) = self.cursor_device.as_ref() else { + return GlobalPoint::new(0, 0); + }; + let mouse = cursor_device.get_mouse(); - match worker.request_hit_test_window(monitor, cursor, request_id) { - Ok(()) => { - self.pending_click_hit_test_request_id = Some(request_id); + GlobalPoint::new(mouse.coords.0, mouse.coords.1) + } - return; - }, - Err(WorkerRequestSendError::Full) => { - self.hit_test_send_full_count = self.hit_test_send_full_count.saturating_add(1); - - tracing::debug!( - request_id, - monitor_id = monitor.id, - point = ?cursor, - full_count = self.hit_test_send_full_count, - "Hit test request dropped: worker queue full." - ); - }, - Err(WorkerRequestSendError::Disconnected) => { - self.hit_test_send_disconnected_count = - self.hit_test_send_disconnected_count.saturating_add(1); - - tracing::debug!( - request_id, - monitor_id = monitor.id, - point = ?cursor, - disconnected_count = self.hit_test_send_disconnected_count, - "Hit test request dropped: worker queue disconnected." - ); - }, - } - } + #[cfg(target_os = "macos")] + fn sample_mouse_location(&mut self) -> GlobalPoint { + let started_at = Instant::now(); + let point = macos_mouse_location().unwrap_or(GlobalPoint::new(0, 0)); + let elapsed = started_at.elapsed(); - let capture_hit = self.hovered_window_hit_from_window_list_snapshot(monitor, cursor); - let capture_rect = capture_hit.map(|window_hit| window_hit.rect); - let window_target = capture_hit.and_then(|window_hit| { - window_hit.window_id.map(|window_id| WindowFreezeCaptureTarget { - monitor, - window_id, - rect: window_hit.rect, - }) - }); + self.slow_op_logger.warn_if_slow( + "overlay.macos_cursor_location", + elapsed, + SLOW_OP_WARN_CURSOR_LOCATION, + || format!("sample point=({}, {})", point.x, point.y), + ); - self.begin_frozen_capture_with_rect(monitor, capture_rect, window_target, Some(cursor)); + point } - fn pending_freeze_capture_matches(&self, monitor: MonitorRect) -> bool { - self.pending_freeze_capture == Some(monitor) - && matches!(self.state.mode, OverlayMode::Frozen) - && self.state.monitor == Some(monitor) + fn last_fresh_event_cursor(&self) -> Option<(MonitorRect, GlobalPoint)> { + self.last_fresh_event_cursor_with_ttl(CURSOR_EVENT_TICK_TTL) } - #[cfg(target_os = "macos")] - fn should_dispatch_pending_freeze_capture(&self, monitor: MonitorRect) -> bool { - self.pending_freeze_capture_matches(monitor) - } + fn last_fresh_event_cursor_with_ttl( + &self, + ttl: Duration, + ) -> Option<(MonitorRect, GlobalPoint)> { + let event_cursor_at = self.last_event_cursor_at?; + let event_cursor = self.last_event_cursor?; - #[cfg(not(target_os = "macos"))] - fn should_dispatch_pending_freeze_capture(&self, monitor: MonitorRect) -> bool { - self.pending_freeze_capture_matches(monitor) && self.state.frozen_image.is_none() - } + if event_cursor_at.elapsed() > ttl { + return None; + } - fn frozen_final_capture_ready(&self) -> bool { - matches!(self.state.mode, OverlayMode::Frozen) - && self.authoritative_frozen_capture_ready - && self.state.frozen_image.is_some() - && self.pending_freeze_capture.is_none() - && self.inflight_freeze_capture.is_none() + Some(event_cursor) } - fn should_force_pending_hud_and_loupe_moves(&self) -> bool { - matches!(self.state.mode, OverlayMode::Frozen) - && self.state.monitor.is_some() - && !self.frozen_final_capture_ready() - } + fn set_alt_held(&mut self, alt: bool) { + if self.state.alt_held == alt { + return; + } - #[cfg(target_os = "macos")] - fn try_latest_live_freeze_preview(&mut self, monitor: MonitorRect) -> Option { - if self.state.live_bg_monitor == Some(monitor) - && let Some(image) = self.state.live_bg_image.take() - { - return Some(image); - } - - self.live_sample_stream - .as_ref() - .and_then(|stream| stream.peek_latest_rgba_snapshot(monitor)) - .map(|snapshot| snapshot.image.as_ref().clone()) - } + self.state.alt_held = alt; - #[cfg(target_os = "macos")] - fn commit_frozen_preview( - &mut self, - monitor: MonitorRect, - image: RgbaImage, - cursor: Option, - ) { - self.state.finish_freeze(monitor, image); + if !alt { + self.handle_alt_release(); - if let Some(cursor) = cursor { - self.update_cursor_state(monitor, cursor); - self.update_hud_window_position(monitor, cursor); + return; } - } - fn seed_frozen_toolbar_default_position( - &mut self, - monitor: MonitorRect, - capture_rect: RectPoints, - ) { - let default_pos = - self.frozen_toolbar_default_position_for_capture_rect(monitor, capture_rect); + let Some((monitor, cursor)) = self.alt_activation_cursor_context() else { + return; + }; - self.toolbar_state.default_slot_position = Some(default_pos); - self.toolbar_state.floating_position = Some(default_pos); + self.set_alt_loupe_window_visible(Some(monitor), true); - let _ = self.update_toolbar_outer_position(monitor, default_pos); + if self.use_fake_hud_blur() { + self.maybe_request_live_bg(monitor); + } - tracing::debug!( - monitor_id = monitor.id, - frozen_generation = self.state.frozen_generation, - toolbar_size_points = - ?WindowRenderer::frozen_toolbar_size(&self.toolbar_state), - default_pos = ?default_pos, - "Frozen toolbar default position preseeded." - ); + match self.state.mode { + OverlayMode::Live => self.request_live_alt_samples(monitor, cursor), + OverlayMode::Frozen => self.request_frozen_alt_samples(cursor), + } } - fn frozen_toolbar_default_position_for_capture_rect( - &self, - monitor: MonitorRect, - capture_rect_points: RectPoints, - ) -> Pos2 { - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let capture_rect = Rect::from_min_size( - Pos2::new(capture_rect_points.x as f32, capture_rect_points.y as f32), - Vec2::new(capture_rect_points.width as f32, capture_rect_points.height as f32), - ); - let toolbar_size = WindowRenderer::frozen_toolbar_size(&self.toolbar_state); + fn apply_loupe_activation_input(&mut self, pressed: bool, repeat: bool) -> bool { + let previous_alt_held = self.state.alt_held; - WindowRenderer::frozen_toolbar_default_pos( - screen_rect, - capture_rect, - toolbar_size, - self.config.toolbar_placement, - ) + match self.config.alt_activation { + AltActivationMode::Hold => self.set_alt_held(pressed), + AltActivationMode::Toggle => { + if pressed && !repeat { + self.set_alt_held(!self.state.alt_held); + } + }, + } + + previous_alt_held != self.state.alt_held } - fn initial_session_runtime(config: &OverlayConfig) -> InitialSessionRuntime { - let (live_bg_request_interval, window_list_refresh_interval, now) = Self::initial_timing(); - let (loupe_sample_side_px, state) = Self::overlay_state_with_config(config); + fn apply_loupe_activation_key_event(&mut self, pressed: bool, repeat: bool) -> bool { + self.loupe_activation_key_down = pressed; - InitialSessionRuntime { - live_bg_request_interval, - window_list_refresh_interval, - now, - loupe_sample_side_px, - state, + if !pressed && !self.state.alt_held { + return false; + } + if pressed && !self.loupe_activation_shortcut_available() { + return false; } + + self.apply_loupe_activation_input(pressed, repeat) } - fn refresh_frozen_helper_windows_for_transition( - &mut self, - monitor: MonitorRect, - cursor: Option, - ) { - if let Some(cursor) = cursor { - self.update_hud_window_position(monitor, cursor); - } + fn clear_loupe_activation_on_focus_loss(&mut self) { + let should_reset = self.loupe_activation_key_down + || (matches!(self.config.alt_activation, AltActivationMode::Hold) + && self.state.alt_held); - if self.should_force_pending_hud_and_loupe_moves() { - self.force_apply_pending_hud_and_loupe_moves(); + if !should_reset { + return; } - self.schedule_egui_repaint_after(self.repaint_interval_for_monitor(Some(monitor))); - self.request_redraw_for_monitor(monitor); - self.request_redraw_hud_window(); + self.loupe_activation_key_down = false; - if self.state.alt_held || self.loupe_window_visible { - self.request_redraw_loupe_window(); + if self.apply_loupe_activation_input(false, false) { + let _ = self.request_redraw_for_alt_state_change(); } } - fn begin_frozen_capture_with_rect( - &mut self, - monitor: MonitorRect, - rect: Option, - window_target: Option, - cursor: Option, - ) { - self.frozen_capture_source = if rect.is_none() { - FrozenCaptureSource::FullscreenFallback - } else if window_target.is_some() { - FrozenCaptureSource::Window - } else { - FrozenCaptureSource::DragRegion - }; + fn maybe_clear_loupe_activation_after_focus_loss(&mut self) { + if !self.pending_focus_loss_cleanup || !self.focused_window_ids.is_empty() { + return; + } - let capture_rect = rect.unwrap_or(RectPoints::new(0, 0, monitor.width, monitor.height)); - let frozen_rgb = self.state.rgb; - let frozen_loupe = self.state.loupe.as_ref().map(|loupe| crate::state::LoupeSample { - center: loupe.center, - patch: loupe.patch.clone(), - }); + self.pending_focus_loss_cleanup = false; - self.state.clear_error(); - self.state.begin_freeze(monitor); + self.clear_loupe_activation_on_focus_loss(); + } - self.state.frozen_capture_rect = Some(capture_rect); - self.state.drag_rect = None; - self.state.hovered_window_rect = None; - self.frozen_selection_drag = FrozenSelectionDragState::default(); + fn request_redraw_for_alt_state_change(&mut self) -> OverlayControl { + if matches!(self.state.mode, OverlayMode::Live) { + self.request_redraw_hud_window(); - tracing::debug!( - monitor_id = monitor.id, - origin = ?monitor.origin, - width_points = monitor.width, - height_points = monitor.height, - monitor_scale_factor = monitor.scale_factor(), - cursor = ?cursor, - capture_rect = ?capture_rect, - "Freeze begin." - ); + if !self.live_loupe_uses_hud_window() + && (self.state.alt_held || self.loupe_window_visible) + { + self.request_redraw_loupe_window(); + } - self.toolbar_state.floating_position = None; - self.toolbar_state.default_slot_position = None; - self.toolbar_state.dragging = false; - self.toolbar_state.needs_redraw = true; - self.toolbar_state.pill_height_points = None; - self.toolbar_state.layout_last_screen_size_points = None; - self.toolbar_state.layout_stable_frames = 0; + return OverlayControl::Continue; + } - self.sync_frozen_toolbar_state(); - // Spawn the toolbar immediately at the default position (capture aware). This avoids any - // dependency on egui viewport stabilization or additional input events (mouse move) to - // finish the initial layout. - self.seed_frozen_toolbar_default_position(monitor, capture_rect); - self.request_redraw_toolbar_window(); + if let Some(monitor) = self.active_cursor_monitor() { + self.request_redraw_for_monitor(monitor); + } else { + self.request_redraw_all(); + } - self.state.rgb = frozen_rgb; - self.state.loupe = frozen_loupe; - self.pending_freeze_capture = Some(monitor); - self.pending_freeze_capture_armed = false; - self.inflight_freeze_capture = None; - self.authoritative_frozen_capture_ready = false; - self.pending_window_freeze_capture = window_target; - self.inflight_window_freeze_capture = None; - self.frozen_window_image = None; - self.capture_windows_hidden = false; - self.pending_click_hit_test_request_id = None; - self.left_mouse_button_down = false; - self.left_mouse_button_down_monitor = None; - self.left_mouse_button_down_global = None; + OverlayControl::Continue + } - self.refresh_frozen_helper_windows_for_transition(monitor, cursor); + fn alt_activation_cursor_context(&mut self) -> Option<(MonitorRect, GlobalPoint)> { + if let Some((monitor, cursor)) = self.last_fresh_event_cursor() { + self.seed_alt_activation_cursor_context(monitor, cursor); - #[cfg(target_os = "macos")] - { - if let Some(image) = self.try_latest_live_freeze_preview(monitor) { - self.state.live_bg_monitor = None; - self.state.live_bg_image = None; + return Some((monitor, cursor)); + } - self.commit_frozen_preview(monitor, image, cursor); - self.force_apply_pending_hud_and_loupe_moves(); - } else { - self.state.live_bg_monitor = None; - self.state.live_bg_image = None; - self.capture_windows_hidden = true; + let cursor = self.sample_mouse_location(); + let Some(monitor) = self.monitor_at(cursor) else { + if self.state.cursor.is_none() { + self.state.cursor = Some(cursor); } - } - #[cfg(not(target_os = "macos"))] - { - if self.use_fake_hud_blur() - && window_target.is_none() - && self.state.live_bg_monitor == Some(monitor) - && let Some(image) = self.state.live_bg_image.take() - { - self.state.live_bg_monitor = None; - self.state.finish_freeze(monitor, image); + return self.active_cursor_monitor().zip(self.state.cursor); + }; - self.pending_freeze_capture = None; - self.pending_freeze_capture_armed = false; - self.authoritative_frozen_capture_ready = true; + self.seed_alt_activation_cursor_context(monitor, cursor); - if let Some(cursor) = cursor { - self.update_cursor_state(monitor, cursor); - self.update_hud_window_position(monitor, cursor); - } + Some((monitor, cursor)) + } - if self.should_force_pending_hud_and_loupe_moves() { - self.force_apply_pending_hud_and_loupe_moves(); - } - } else { - self.state.live_bg_monitor = None; - self.state.live_bg_image = None; - self.capture_windows_hidden = true; + fn seed_alt_activation_cursor_context(&mut self, monitor: MonitorRect, cursor: GlobalPoint) { + let old_monitor = self.active_cursor_monitor(); + let old_cursor = self.state.cursor; - self.hide_capture_windows(); - } + match self.state.mode { + OverlayMode::Live => { + self.update_cursor_for_live_move(old_monitor, old_cursor, monitor, cursor) + }, + OverlayMode::Frozen => { + self.update_cursor_state(monitor, cursor); + self.update_hud_window_position(monitor, cursor); + }, } } - fn update_live_drag_rect(&mut self, monitor: MonitorRect, global: GlobalPoint) { - if !matches!(self.state.mode, OverlayMode::Live) { - self.state.drag_rect = None; + fn handle_alt_release(&mut self) { + self.state.loupe = None; + self.loupe_outer_pos = None; + self.pending_loupe_outer_pos = None; - return; - } - if !self.left_mouse_button_down || self.left_mouse_button_down_monitor != Some(monitor) { - self.state.drag_rect = None; + self.set_alt_loupe_window_visible(None, false); + + if matches!(self.state.mode, OverlayMode::Live) { + self.request_redraw_hud_window(); return; } - let Some(start_global) = self.left_mouse_button_down_global else { - self.state.drag_rect = None; + if let Some(monitor) = self.active_cursor_monitor() { + self.request_redraw_for_monitor(monitor); + } + } - return; - }; - let Some(rect) = monitor.local_rect_from_points(start_global, global) else { - self.state.drag_rect = None; + fn set_alt_loupe_window_visible(&mut self, monitor: Option, visible: bool) { + if self.live_loupe_uses_hud_window() { + self.loupe_window_visible = false; - return; - }; + self.reset_loupe_window_warmup_redraws(); - if rect.is_empty() { - self.state.drag_rect = None; + if let Some(loupe_window) = self.loupe_window.as_ref() { + loupe_window.window.set_visible(false); + } return; } + if visible { + let Some(monitor) = monitor else { + return; + }; + let visible = self.update_loupe_window_position(monitor); + let was_visible = self.loupe_window_visible; - self.state.drag_rect = Some(MonitorRectPoints { monitor_id: monitor.id, rect }); - } + self.loupe_window_visible = visible; - fn frozen_selection_drag_target(&self) -> Option<(MonitorRect, RectPoints)> { - if !matches!(self.state.mode, OverlayMode::Frozen) - || self.frozen_capture_source != FrozenCaptureSource::DragRegion - || self.scroll_capture.active - || self.state.frozen_image.is_none() - { - return None; - } + if visible { + self.force_apply_pending_loupe_window_move(); + } + if visible { + if !was_visible { + self.maybe_start_loupe_window_warmup_redraw(); + } + } else { + self.reset_loupe_window_warmup_redraws(); + } - let monitor = self.state.monitor?; - let capture_rect = self.state.frozen_capture_rect?; + if let Some(loupe_window) = self.loupe_window.as_ref() { + loupe_window.window.set_visible(visible); + loupe_window.window.request_redraw(); + } - if capture_rect.is_empty() { - return None; + return; } - Some((monitor, capture_rect)) - } - - fn frozen_auto_center_available(&self) -> bool { - self.frozen_selection_drag_target().is_some() - } + self.loupe_window_visible = false; - fn begin_frozen_selection_drag(&mut self, global: GlobalPoint) -> bool { - let Some((monitor, capture_rect)) = self.frozen_selection_drag_target() else { - return false; - }; - let Some((cursor_x, cursor_y)) = monitor.local_u32(global) else { - return false; - }; + self.reset_loupe_window_warmup_redraws(); - if !capture_rect.contains((cursor_x, cursor_y)) { - return false; + if let Some(loupe_window) = self.loupe_window.as_ref() { + loupe_window.window.set_visible(false); + loupe_window.window.request_redraw(); } - - self.frozen_selection_drag = FrozenSelectionDragState { - active: true, - pointer_offset_x: cursor_x.saturating_sub(capture_rect.x), - pointer_offset_y: cursor_y.saturating_sub(capture_rect.y), - }; - - true } - fn stop_frozen_selection_drag(&mut self) { - self.frozen_selection_drag = FrozenSelectionDragState::default(); - } + fn request_live_alt_samples(&mut self, monitor: MonitorRect, cursor: GlobalPoint) { + let sample_updated = self.request_live_cursor_sample(monitor, cursor, true); + let apply = self.live_sample_request_redraw_intent(false, sample_updated, true); - fn update_frozen_selection_drag_rect(&mut self, global: GlobalPoint) -> bool { - if !self.frozen_selection_drag.active { - return false; + if apply.any_changed() { + self.request_redraw_live_sample_targets(monitor, apply); } + } - let Some((monitor, capture_rect)) = self.frozen_selection_drag_target() else { - self.stop_frozen_selection_drag(); - - return false; - }; - let (cursor_x, cursor_y) = Self::clamped_local_point_in_monitor(monitor, global); - let desired_x = - i64::from(cursor_x) - i64::from(self.frozen_selection_drag.pointer_offset_x); - let desired_y = - i64::from(cursor_y) - i64::from(self.frozen_selection_drag.pointer_offset_y); - let next_rect = Self::clamp_frozen_capture_rect_to_monitor( - monitor, - capture_rect.width, - capture_rect.height, - desired_x, - desired_y, - ); + fn request_frozen_alt_samples(&mut self, cursor: GlobalPoint) { + if let (Some(frozen_monitor), Some(_)) = + (self.state.monitor, self.state.frozen_image.as_ref()) + { + self.state.loupe = image_helpers::frozen_loupe_patch( + &self.state.frozen_image, + Some(frozen_monitor), + cursor, + self.loupe_patch_width_px, + self.loupe_patch_height_px, + ) + .map(|patch| crate::state::LoupeSample { center: cursor, patch }); - self.apply_frozen_capture_rect_update(monitor, next_rect) + self.request_redraw_for_monitor(frozen_monitor); + } } - fn clamped_local_point_in_monitor(monitor: MonitorRect, global: GlobalPoint) -> (u32, u32) { - let max_x = i64::from(monitor.width.saturating_sub(1)); - let max_y = i64::from(monitor.height.saturating_sub(1)); - let local_x = (i64::from(global.x) - i64::from(monitor.origin.x)).clamp(0, max_x) as u32; - let local_y = (i64::from(global.y) - i64::from(monitor.origin.y)).clamp(0, max_y) as u32; + fn handle_resized(&mut self, window_id: WindowId, size: PhysicalSize) -> OverlayControl { + let window_scale_factor = self + .windows + .get(&window_id) + .map(|w| w.window.scale_factor()) + .or_else(|| self.hud_window.as_ref().map(|w| w.window.scale_factor())) + .or_else(|| self.loupe_window.as_ref().map(|w| w.window.scale_factor())); - (local_x, local_y) - } + tracing::trace!(?window_id, ?size, ?window_scale_factor, "WindowEvent::Resized"); - fn clamp_frozen_capture_rect_to_monitor( - monitor: MonitorRect, - width: u32, - height: u32, - desired_x: i64, - desired_y: i64, - ) -> RectPoints { - let max_x = i64::from(monitor.width.saturating_sub(width)); - let max_y = i64::from(monitor.height.saturating_sub(height)); - let x = desired_x.clamp(0, max_x) as u32; - let y = desired_y.clamp(0, max_y) as u32; + if let Some(hud_window) = self.hud_window.as_mut() + && hud_window.window.id() == window_id + { + let window = Arc::clone(&hud_window.window); - RectPoints::new(x, y, width, height) - } + match hud_window.renderer.resize(size) { + Ok(()) => { + self.configure_hud_window_common(window.as_ref(), None); - fn apply_frozen_capture_rect_update( - &mut self, - monitor: MonitorRect, - next_rect: RectPoints, - ) -> bool { - if self.state.frozen_capture_rect == Some(next_rect) { - return false; + return OverlayControl::Continue; + }, + Err(err) => return self.exit(OverlayExit::Error(format!("{err:#}"))), + } } + if let Some(loupe_window) = self.loupe_window.as_mut() + && loupe_window.window.id() == window_id + { + let window = Arc::clone(&loupe_window.window); - self.state.frozen_capture_rect = Some(next_rect); + match loupe_window.renderer.resize(size) { + Ok(()) => { + self.configure_hud_window_common( + window.as_ref(), + Some(LOUPE_TILE_CORNER_RADIUS_POINTS), + ); - let toolbar_pos = self.frozen_toolbar_default_position_for_capture_rect(monitor, next_rect); + return OverlayControl::Continue; + }, + Err(err) => return self.exit(OverlayExit::Error(format!("{err:#}"))), + } + } - self.toolbar_state.default_slot_position = Some(toolbar_pos); - self.toolbar_state.floating_position = Some(toolbar_pos); + let Some(overlay_window) = self.windows.get_mut(&window_id) else { + return OverlayControl::Continue; + }; - let _ = self.update_toolbar_outer_position(monitor, toolbar_pos); + match overlay_window.renderer.resize(size) { + Ok(()) => OverlayControl::Continue, + Err(err) => self.exit(OverlayExit::Error(format!("{err:#}"))), + } + } - self.request_redraw_for_monitor(monitor); - self.request_redraw_toolbar_window(); - self.request_redraw_scroll_preview_window(); + fn handle_scale_factor_changed(&mut self, window_id: WindowId) -> OverlayControl { + let window_scale_factor = self + .windows + .get(&window_id) + .map(|w| w.window.scale_factor()) + .or_else(|| self.hud_window.as_ref().map(|w| w.window.scale_factor())) + .or_else(|| self.loupe_window.as_ref().map(|w| w.window.scale_factor())); - true - } + tracing::trace!(?window_id, ?window_scale_factor, "WindowEvent::ScaleFactorChanged"); - fn auto_center_frozen_capture_rect(&mut self) -> bool { - let Some((monitor, capture_rect)) = self.frozen_selection_drag_target() else { - return false; - }; - let Some(capture_image) = self.cropped_frozen_capture_image() else { - return false; - }; - let Some(content_bounds) = Self::detect_auto_center_content_bounds(&capture_image) else { - return false; - }; - let delta_x_points = Self::auto_center_shift_points( - content_bounds.x, - content_bounds.width, - capture_image.width(), - capture_rect.width, - ); - let delta_y_points = Self::auto_center_shift_points( - content_bounds.y, - content_bounds.height, - capture_image.height(), - capture_rect.height, - ); - let next_rect = Self::clamp_frozen_capture_rect_to_monitor( - monitor, - capture_rect.width, - capture_rect.height, - i64::from(capture_rect.x) + delta_x_points, - i64::from(capture_rect.y) + delta_y_points, - ); - - self.apply_frozen_capture_rect_update(monitor, next_rect) - } - - fn auto_center_shift_points( - content_origin_px: u32, - content_size_px: u32, - crop_size_px: u32, - capture_size_points: u32, - ) -> i64 { - if crop_size_px == 0 || capture_size_points == 0 { - return 0; - } - - let content_center_px = content_origin_px as f32 + (content_size_px as f32 * 0.5); - let crop_center_px = crop_size_px as f32 * 0.5; - let delta_px = content_center_px - crop_center_px; - - ((delta_px * capture_size_points as f32) / crop_size_px as f32).round() as i64 - } - - fn detect_auto_center_content_bounds(image: &RgbaImage) -> Option { - let width = image.width(); - let height = image.height(); - - if width < 2 || height < 2 { - return None; - } - - let edge_strip = Self::auto_center_edge_strip_extent(width.min(height)); - let top_mean = Self::region_rgb_mean(image, 0, width, 0, edge_strip)?; - let bottom_mean = - Self::region_rgb_mean(image, 0, width, height.saturating_sub(edge_strip), height)?; - let left_mean = Self::region_rgb_mean(image, 0, edge_strip, 0, height)?; - let right_mean = - Self::region_rgb_mean(image, width.saturating_sub(edge_strip), width, 0, height)?; - let threshold = { - let edge_noise = [ - Self::region_rgb_mean_distance(image, 0, width, 0, edge_strip, top_mean), - Self::region_rgb_mean_distance( - image, - 0, - width, - height.saturating_sub(edge_strip), - height, - bottom_mean, - ), - Self::region_rgb_mean_distance(image, 0, edge_strip, 0, height, left_mean), - Self::region_rgb_mean_distance( - image, - width.saturating_sub(edge_strip), - width, - 0, - height, - right_mean, - ), - ] - .into_iter() - .fold(0.0, f32::max); - - (edge_noise * 3.0).round().clamp(24.0, 96.0) as u32 - }; - let min_salient_per_row = (width / 64).max(1) as usize; - let min_salient_per_column = (height / 64).max(1) as usize; - let mut row_counts = vec![0_usize; height as usize]; - let mut column_counts = vec![0_usize; width as usize]; - - for (x, y, pixel) in image.enumerate_pixels() { - let salient_distance = [ - Self::rgb_distance_to_mean(pixel, top_mean), - Self::rgb_distance_to_mean(pixel, bottom_mean), - Self::rgb_distance_to_mean(pixel, left_mean), - Self::rgb_distance_to_mean(pixel, right_mean), - ] - .into_iter() - .min() - .unwrap_or(0); - - if salient_distance < threshold { - continue; - } - - row_counts[y as usize] += 1; - column_counts[x as usize] += 1; - } - - let top = row_counts.iter().position(|count| *count >= min_salient_per_row)?; - let bottom = row_counts.iter().rposition(|count| *count >= min_salient_per_row)?; - let left = column_counts.iter().position(|count| *count >= min_salient_per_column)?; - let right = column_counts.iter().rposition(|count| *count >= min_salient_per_column)?; - - if left > right || top > bottom { - return None; - } - - let bounds = RectPoints::new( - left as u32, - top as u32, - (right - left + 1) as u32, - (bottom - top + 1) as u32, - ); - let fills_crop_width = bounds.width.saturating_mul(100) >= width.saturating_mul(92); - let fills_crop_height = bounds.height.saturating_mul(100) >= height.saturating_mul(92); - - if fills_crop_width && fills_crop_height { - return None; - } - - Some(bounds) - } - - fn auto_center_edge_strip_extent(length: u32) -> u32 { - ((length as f32) * 0.08).round().clamp(1.0, 24.0) as u32 - } - - fn region_rgb_mean(image: &RgbaImage, x0: u32, x1: u32, y0: u32, y1: u32) -> Option<[f32; 3]> { - if x0 >= x1 || y0 >= y1 { - return None; - } - - let mut r_total = 0_u64; - let mut g_total = 0_u64; - let mut b_total = 0_u64; - let mut sample_count = 0_u64; - - for y in y0..y1 { - for x in x0..x1 { - let pixel = image.get_pixel(x, y); - - r_total += u64::from(pixel[0]); - g_total += u64::from(pixel[1]); - b_total += u64::from(pixel[2]); - sample_count += 1; - } - } - - if sample_count == 0 { - return None; - } - - Some([ - r_total as f32 / sample_count as f32, - g_total as f32 / sample_count as f32, - b_total as f32 / sample_count as f32, - ]) - } - - fn region_rgb_mean_distance( - image: &RgbaImage, - x0: u32, - x1: u32, - y0: u32, - y1: u32, - mean: [f32; 3], - ) -> f32 { - if x0 >= x1 || y0 >= y1 { - return 0.0; - } - - let mut total_distance = 0_u64; - let mut sample_count = 0_u64; - - for y in y0..y1 { - for x in x0..x1 { - total_distance += - u64::from(Self::rgb_distance_to_mean(image.get_pixel(x, y), mean)); - sample_count += 1; - } - } - - if sample_count == 0 { 0.0 } else { total_distance as f32 / sample_count as f32 } - } - - fn rgb_distance_to_mean(pixel: &image::Rgba, mean: [f32; 3]) -> u32 { - (pixel[0] as f32 - mean[0]).abs().round() as u32 - + (pixel[1] as f32 - mean[1]).abs().round() as u32 - + (pixel[2] as f32 - mean[2]).abs().round() as u32 - } - - fn cropped_frozen_capture_image(&self) -> Option { - if self.frozen_capture_source != FrozenCaptureSource::FullscreenFallback - && let Some(window_image) = self.frozen_window_image.as_ref() - { - match self.config.window_capture_alpha_mode { - WindowCaptureAlphaMode::Background => {}, - WindowCaptureAlphaMode::MatteLight => { - return Some(Self::flatten_window_image_with_matte( - window_image, - WINDOW_CAPTURE_MATTE_LIGHT_RGBA, - )); - }, - WindowCaptureAlphaMode::MatteDark => { - return Some(Self::flatten_window_image_with_matte( - window_image, - WINDOW_CAPTURE_MATTE_DARK_RGBA, - )); - }, - } - } - - let frozen_image = self.state.frozen_image.as_ref()?; - let Some(monitor) = self.state.monitor else { - return Some(frozen_image.clone()); - }; - let capture_rect = self - .state - .frozen_capture_rect - .unwrap_or_else(|| RectPoints::new(0, 0, monitor.width, monitor.height)); - let capture_rect = monitor.local_rect_to_pixels(capture_rect); - let x = capture_rect.x.min(frozen_image.width()); - let y = capture_rect.y.min(frozen_image.height()); - let max_width = frozen_image.width().saturating_sub(x); - let max_height = frozen_image.height().saturating_sub(y); - let width = capture_rect.width.min(max_width); - let height = capture_rect.height.min(max_height); - - if width == 0 || height == 0 { - None - } else { - Some(imageops::crop_imm(frozen_image, x, y, width, height).to_image()) - } - } - - #[cfg(target_os = "macos")] - fn cropped_monitor_frozen_region_image( - &self, - monitor: MonitorRect, - capture_rect_pixels: RectPoints, - ) -> Option { - let frozen_image = self.state.frozen_image.as_ref()?; - let x = capture_rect_pixels.x.min(frozen_image.width()); - let y = capture_rect_pixels.y.min(frozen_image.height()); - let max_width = frozen_image.width().saturating_sub(x); - let max_height = frozen_image.height().saturating_sub(y); - let width = capture_rect_pixels.width.min(max_width); - let height = capture_rect_pixels.height.min(max_height); - - if width == 0 || height == 0 { - tracing::debug!( - monitor_id = monitor.id, - capture_rect_pixels = ?capture_rect_pixels, - frozen_image_size = ?(frozen_image.width(), frozen_image.height()), - "Scroll capture base-frame crop resolved to an empty region." - ); - - None - } else { - Some(imageops::crop_imm(frozen_image, x, y, width, height).to_image()) - } - } - - fn flatten_window_image_with_matte(image: &RgbaImage, matte: image::Rgba) -> RgbaImage { - let mut out = RgbaImage::from_pixel(image.width(), image.height(), matte); - - imageops::overlay(&mut out, image, 0, 0); - - out - } - - fn compose_window_preview_layer( - window_image: &RgbaImage, - alpha_mode: WindowCaptureAlphaMode, - ) -> RgbaImage { - match alpha_mode { - WindowCaptureAlphaMode::Background => window_image.clone(), - WindowCaptureAlphaMode::MatteLight => { - Self::flatten_window_image_with_matte(window_image, WINDOW_CAPTURE_MATTE_LIGHT_RGBA) - }, - WindowCaptureAlphaMode::MatteDark => { - Self::flatten_window_image_with_matte(window_image, WINDOW_CAPTURE_MATTE_DARK_RGBA) - }, - } - } - - fn composite_window_capture_preview( - mut monitor_image: RgbaImage, - window_image: &RgbaImage, - monitor: MonitorRect, - capture_rect_points: RectPoints, - alpha_mode: WindowCaptureAlphaMode, - ) -> RgbaImage { - let capture_rect_px = monitor.local_rect_to_pixels(capture_rect_points); - - if capture_rect_px.width == 0 || capture_rect_px.height == 0 { - return monitor_image; - } - - let window_overlay = if window_image.width() == capture_rect_px.width - && window_image.height() == capture_rect_px.height - { - window_image.clone() - } else { - imageops::resize( - window_image, - capture_rect_px.width, - capture_rect_px.height, - FilterType::Triangle, - ) - }; - let preview_layer = Self::compose_window_preview_layer(&window_overlay, alpha_mode); - - imageops::overlay( - &mut monitor_image, - &preview_layer, - i64::from(capture_rect_px.x), - i64::from(capture_rect_px.y), - ); - - monitor_image - } - - fn handle_captured_freeze_response( - &mut self, - monitor: MonitorRect, - image: RgbaImage, - window_image: Option, - captured_window_id: Option, - ) { - if matches!(self.state.mode, OverlayMode::Frozen) && self.state.monitor == Some(monitor) { - self.inflight_freeze_capture = None; - self.authoritative_frozen_capture_ready = true; - - let window_capture_target = self.inflight_window_freeze_capture.take(); - let mut frozen_preview_image = image; - - self.pending_window_freeze_capture = None; - self.frozen_window_image = None; - - if let (Some(target), Some(window_capture_image), Some(window_id)) = - (window_capture_target, window_image, captured_window_id) - && target.monitor == monitor - && target.window_id == window_id - { - match self.config.window_capture_alpha_mode { - WindowCaptureAlphaMode::Background => {}, - WindowCaptureAlphaMode::MatteLight | WindowCaptureAlphaMode::MatteDark => { - self.frozen_window_image = Some(window_capture_image); - - if let Some(window_capture_image) = self.frozen_window_image.as_ref() { - frozen_preview_image = Self::composite_window_capture_preview( - frozen_preview_image, - window_capture_image, - monitor, - target.rect, - self.config.window_capture_alpha_mode, - ); - } - }, - } - } - - self.state.finish_freeze(monitor, frozen_preview_image); - self.restore_capture_windows_visibility(); - - self.toolbar_state.needs_redraw = true; - - #[cfg(target_os = "macos")] - if self.toolbar_state.visible { - self.toolbar_window_warmup_redraws_remaining = - self.toolbar_window_warmup_redraws_remaining.max(TOOLBAR_WINDOW_WARMUP_REDRAWS); - } - - if let Some(cursor) = self.state.cursor { - self.state.rgb = - image_helpers::frozen_rgb(&self.state.frozen_image, Some(monitor), cursor); - self.state.loupe = image_helpers::frozen_loupe_patch( - &self.state.frozen_image, - Some(monitor), - cursor, - self.loupe_patch_width_px, - self.loupe_patch_height_px, - ) - .map(|patch| crate::state::LoupeSample { center: cursor, patch }); - - self.update_hud_window_position(monitor, cursor); - } - - self.maybe_start_loupe_window_warmup_redraw(); - self.request_redraw_hud_window(); - - if self.state.alt_held || self.loupe_window_visible { - self.request_redraw_loupe_window(); - } - - self.request_redraw_toolbar_window(); - self.request_redraw_for_monitor(monitor); - #[cfg(not(target_os = "macos"))] - self.raise_hud_windows(); - - return; - } - if self.inflight_freeze_capture == Some(monitor) { - self.inflight_freeze_capture = None; - } - if self.inflight_window_freeze_capture.is_some_and(|inflight| inflight.monitor == monitor) { - self.inflight_window_freeze_capture = None; - self.pending_window_freeze_capture = None; - } - if matches!(self.state.mode, OverlayMode::Live) - && self.use_fake_hud_blur() - && self.active_cursor_monitor() == Some(monitor) - { - self.state.live_bg_monitor = Some(monitor); - self.state.live_bg_image = Some(image); - self.state.live_bg_generation = self.state.live_bg_generation.wrapping_add(1); - - self.request_redraw_for_monitor(monitor); - } - } - - fn handle_encoded_png_response(&mut self, png_bytes: Vec) -> OverlayControl { - let Some(action) = self.pending_png_action.take() else { - return OverlayControl::Continue; - }; - - match action { - PngAction::Copy => match output::write_png_bytes_to_clipboard(&png_bytes) { - Ok(()) => self.exit(OverlayExit::PngBytes(png_bytes)), - Err(err) => { - self.state.set_error(format!("{err:#}")); - self.request_redraw_all(); - - OverlayControl::Continue - }, - }, - PngAction::Save => { - match output::save_png_bytes_to_configured_dir(&png_bytes, &self.config) { - Ok(path) => self.exit(OverlayExit::Saved(path)), - Err(err) => { - self.state.set_error(format!("{err:#}")); - self.request_redraw_all(); - - OverlayControl::Continue - }, - } - }, - } - } - - #[cfg(target_os = "macos")] - fn handle_recognized_text_response(&mut self, text: String) -> OverlayControl { - if text.trim().is_empty() { - self.state.set_error(String::from("No text recognized.")); - self.request_redraw_all(); - - return OverlayControl::Continue; - } - - match output::write_text_to_clipboard(&text) { - Ok(()) => self.exit(OverlayExit::TextCopied(text.chars().count())), - Err(err) => { - self.state.set_error(format!("{err:#}")); - self.request_redraw_all(); - - OverlayControl::Continue - }, - } - } - - #[cfg(target_os = "macos")] - fn next_ocr_request_id(&mut self) -> u64 { - let request_id = self.next_ocr_request_id; - - self.next_ocr_request_id = self.next_ocr_request_id.wrapping_add(1); - - request_id - } - - #[cfg(target_os = "macos")] - fn cancel_ocr_output_intent(&mut self) { - self.active_ocr_request_id = None; - self.pending_recognize_text = None; - } - - #[cfg(target_os = "macos")] - fn maybe_request_redraw_for_pending_output(&mut self) { - if !self.ocr_inflight - && (self.pending_recognize_text.is_some() || self.pending_encode_png.is_some()) - { - self.request_redraw_all(); - } - } - - #[cfg(target_os = "macos")] - fn handle_recognized_text_worker_response( - &mut self, - request_id: u64, - text: String, - ) -> OverlayControl { - self.ocr_inflight = false; - - if self.active_ocr_request_id != Some(request_id) { - return OverlayControl::Continue; - } - - self.active_ocr_request_id = None; - - self.handle_recognized_text_response(text) - } - - #[cfg(target_os = "macos")] - fn handle_recognized_text_worker_error(&mut self) -> bool { - self.ocr_inflight = false; - - if self.active_ocr_request_id.is_none() || self.pending_recognize_text.is_some() { - self.maybe_request_redraw_for_pending_output(); - - return true; - } - - self.active_ocr_request_id = None; - - false - } - - fn maybe_stop_frozen_selection_drag_for_mouse_input( - &mut self, - state: ElementState, - button: MouseButton, - ) { - if state == ElementState::Released && button == MouseButton::Left { - self.stop_frozen_selection_drag(); - } - } - - /// Handles a winit window event for one of the overlay-owned windows. - pub fn handle_window_event( - &mut self, - window_id: WindowId, - event: &WindowEvent, - ) -> OverlayControl { - let started_at = Instant::now(); - let kind = Self::window_event_kind(event); - let now = Instant::now(); - - self.event_loop_last_progress_window_id = Some(window_id); - self.event_loop_last_progress_monitor_id = - self.windows.get(&window_id).map(|window| window.monitor.id); - - self.maybe_log_event_loop_stall(now); - self.mark_progress_with_detail(OverlayEventLoopPhase::WindowEvent, Some(kind)); - - match event { - WindowEvent::MouseInput { state, button, .. } => { - self.maybe_stop_frozen_selection_drag_for_mouse_input(*state, *button); - }, - WindowEvent::Focused(focused) => { - self.note_window_focus_change(window_id, *focused); - }, - _ => {}, - } - - if let Some(control) = self.handle_scroll_preview_event(window_id, event) { - return control; - } - - let toolbar_window_id = self - .toolbar_window - .as_ref() - .is_some_and(|toolbar_window| toolbar_window.window.id() == window_id); - let control = match event { - WindowEvent::CloseRequested => self.cancel_overlay("window_close_requested"), - WindowEvent::MouseInput { - state: ElementState::Pressed, - button: MouseButton::Right, - .. - } => self.cancel_overlay("window_right_click"), - WindowEvent::Resized(size) if toolbar_window_id => { - self.handle_toolbar_window_resized(*size) - }, - WindowEvent::Resized(size) => self.handle_resized(window_id, *size), - WindowEvent::ScaleFactorChanged { .. } if toolbar_window_id => { - self.handle_toolbar_window_scale_factor_changed(window_id) - }, - WindowEvent::ScaleFactorChanged { .. } => self.handle_scale_factor_changed(window_id), - WindowEvent::CursorEntered { .. } if toolbar_window_id => OverlayControl::Continue, - WindowEvent::CursorLeft { .. } if toolbar_window_id => { - self.toolbar_pointer_local = None; - self.toolbar_left_button_down = false; - self.toolbar_left_button_went_down = false; - self.toolbar_left_button_went_up = false; - self.toolbar_state.dragging = false; - self.toolbar_state.drag_offset = Vec2::ZERO; - self.toolbar_state.drag_anchor = None; - - #[cfg(target_os = "macos")] - { - self.request_redraw_toolbar_window(); - } - - OverlayControl::Continue - }, - WindowEvent::CursorMoved { position, .. } => { - if toolbar_window_id { - self.handle_toolbar_cursor_moved(window_id, *position) - } else { - self.handle_cursor_moved(window_id, *position) - } - }, - WindowEvent::MouseWheel { delta, .. } if toolbar_window_id => OverlayControl::Continue, - WindowEvent::MouseWheel { delta, .. } => { - self.handle_scroll_mouse_wheel(window_id, delta) - }, - WindowEvent::MouseInput { state, button: MouseButton::Left, .. } => { - if toolbar_window_id { - self.handle_toolbar_mouse_input(*state) - } else { - self.handle_left_mouse_input(window_id, *state) - } - }, - WindowEvent::RedrawRequested if toolbar_window_id => { - self.handle_toolbar_window_redraw_requested() - }, - WindowEvent::ThemeChanged(_) => { - // Keep the HUD palette in sync with system changes when ThemeMode::System is active. - if let Some(monitor) = self.windows.get(&window_id).map(|w| w.monitor) { - self.request_redraw_for_monitor(monitor); - } else { - self.request_redraw_all(); - } - - OverlayControl::Continue - }, - WindowEvent::KeyboardInput { event, .. } => self.handle_key_event(event), - WindowEvent::ModifiersChanged(modifiers) => self.handle_modifiers_changed(modifiers), - WindowEvent::RedrawRequested => self.handle_redraw_requested(window_id), - _ => OverlayControl::Continue, - }; - - self.slow_op_logger.warn_if_slow( - "overlay.window_event", - started_at.elapsed(), - SLOW_OP_WARN_WINDOW_EVENT, - || format!("kind={kind} window_id={window_id:?} toolbar_window={toolbar_window_id}"), - ); - - control - } - - fn handle_scroll_preview_event( - &mut self, - window_id: WindowId, - event: &WindowEvent, - ) -> Option { - if self - .scroll_preview_window - .as_ref() - .is_none_or(|preview_window| preview_window.window.id() != window_id) - { - return None; - } - - Some(match event { - WindowEvent::RedrawRequested => self.handle_scroll_preview_redraw_requested(), - WindowEvent::MouseInput { - state: ElementState::Pressed, - button: MouseButton::Right, - .. - } => self.cancel_overlay("scroll_preview_right_click"), - WindowEvent::KeyboardInput { event, .. } => self.handle_key_event(event), - WindowEvent::ModifiersChanged(modifiers) => self.handle_modifiers_changed(modifiers), - _ => self.handle_scroll_preview_window_event(event), - }) - } - - fn note_window_focus_change(&mut self, window_id: WindowId, focused: bool) { - if focused { - self.focused_window_ids.insert(window_id); - - self.pending_focus_loss_cleanup = false; - - return; - } - - self.focused_window_ids.remove(&window_id); - - if self.focused_window_ids.is_empty() { - self.pending_focus_loss_cleanup = true; - } - } - - fn handle_toolbar_mouse_input(&mut self, state: ElementState) -> OverlayControl { - let toolbar_left_button_down = matches!(state, ElementState::Pressed); - - if toolbar_left_button_down == self.toolbar_left_button_down { - return OverlayControl::Continue; - } - if toolbar_left_button_down { - self.toolbar_left_button_went_down = true; - } else { - self.toolbar_left_button_went_up = true; - } - - self.toolbar_left_button_down = toolbar_left_button_down; - - if !toolbar_left_button_down { - self.stop_frozen_selection_drag(); - - self.toolbar_state.dragging = false; - self.toolbar_state.drag_offset = Vec2::ZERO; - self.toolbar_state.drag_anchor = None; - } else { - self.toolbar_state.drag_offset = Vec2::ZERO; - self.toolbar_state.dragging = false; - self.toolbar_state.drag_anchor = None; - } - - #[cfg(target_os = "macos")] - { - self.request_redraw_toolbar_window(); - } - - OverlayControl::Continue - } - - fn reset_toolbar_pointer_state(&mut self) { - self.toolbar_left_button_down = false; - self.toolbar_left_button_went_down = false; - self.toolbar_left_button_went_up = false; - self.toolbar_pointer_local = None; - self.toolbar_state.drag_anchor = None; - } - - fn handle_toolbar_cursor_moved( - &mut self, - window_id: WindowId, - position: PhysicalPosition, - ) -> OverlayControl { - let Some(toolbar_window) = self.toolbar_window.as_ref() else { - return OverlayControl::Continue; - }; - - if toolbar_window.window.id() != window_id - || !matches!(self.state.mode, OverlayMode::Frozen) - || !self.toolbar_state.visible - { - return OverlayControl::Continue; - } - - let scale = toolbar_window.window.scale_factor().max(1.0); - let cursor_local = Pos2::new((position.x / scale) as f32, (position.y / scale) as f32); - - self.toolbar_pointer_local = Some(cursor_local); - - if self.frozen_selection_drag.active { - if let Some(global_cursor) = - self.toolbar_cursor_global_position(toolbar_window, cursor_local) - { - self.update_frozen_selection_drag_rect(global_cursor); - } - - return OverlayControl::Continue; - } - - let monitor = match self.state.monitor.or_else(|| self.active_cursor_monitor()) { - Some(monitor) => monitor, - None => return OverlayControl::Continue, - }; - let global_cursor = self.toolbar_cursor_global_position(toolbar_window, cursor_local); - let drag_monitor = - global_cursor.and_then(|cursor| self.monitor_at(cursor)).unwrap_or(monitor); - let mut mouse_drag = self.toolbar_left_button_down && self.toolbar_state.dragging; - - if self.toolbar_left_button_down && self.toolbar_state.drag_anchor.is_none() { - self.toolbar_state.drag_anchor = Some(cursor_local); - } - if !mouse_drag && let Some(drag_anchor) = self.toolbar_state.drag_anchor { - let dx = cursor_local.x - drag_anchor.x; - let dy = cursor_local.y - drag_anchor.y; - let threshold_sq = TOOLBAR_DRAG_START_THRESHOLD_PX * TOOLBAR_DRAG_START_THRESHOLD_PX; - - if dx * dx + dy * dy >= threshold_sq { - let toolbar_outer_pos = self.toolbar_outer_pos.or_else(|| { - self.toolbar_state.floating_position.map(|floating_position| { - GlobalPoint::new( - monitor.origin.x.saturating_add(floating_position.x.round() as i32), - monitor.origin.y.saturating_add(floating_position.y.round() as i32), - ) - }) - }); - - if let (Some(global_cursor), Some(toolbar_outer_pos)) = - (global_cursor, toolbar_outer_pos) - { - self.toolbar_state.drag_offset = Vec2::new( - global_cursor.x as f32 - toolbar_outer_pos.x as f32, - global_cursor.y as f32 - toolbar_outer_pos.y as f32, - ); - self.toolbar_state.dragging = true; - self.toolbar_state.drag_anchor = None; - mouse_drag = true; - } - } - } - if mouse_drag && global_cursor.is_none() { - mouse_drag = false; - } - if mouse_drag && let Some(global_cursor) = global_cursor { - let desired_global = Pos2::new( - global_cursor.x as f32 - self.toolbar_state.drag_offset.x, - global_cursor.y as f32 - self.toolbar_state.drag_offset.y, - ); - let desired_local = Pos2::new( - desired_global.x - drag_monitor.origin.x as f32, - desired_global.y - drag_monitor.origin.y as f32, - ); - let _ = self.update_toolbar_outer_position(drag_monitor, desired_local); - } - - self.request_redraw_toolbar_window(); - - OverlayControl::Continue - } - - fn toolbar_cursor_global_position( - &self, - toolbar_window: &HudOverlayWindow, - cursor_local: Pos2, - ) -> Option { - let toolbar_scale = toolbar_window.window.scale_factor().max(1.0); - let outer_position = toolbar_window.window.outer_position().ok()?; - let global_cursor = Pos2::new( - (outer_position.x as f64 / toolbar_scale) as f32 + cursor_local.x, - (outer_position.y as f64 / toolbar_scale) as f32 + cursor_local.y, - ); - - Some(GlobalPoint::new(global_cursor.x.round() as i32, global_cursor.y.round() as i32)) - } - - fn handle_toolbar_window_resized(&mut self, size: PhysicalSize) -> OverlayControl { - let Some(toolbar_window) = self.toolbar_window.as_mut() else { - return OverlayControl::Continue; - }; - - match toolbar_window.renderer.resize(size) { - Ok(()) => OverlayControl::Continue, - Err(err) => self.exit(OverlayExit::Error(format!("{err:#}"))), - } - } - - fn handle_toolbar_window_scale_factor_changed( - &mut self, - window_id: WindowId, - ) -> OverlayControl { - let Some(toolbar_window) = self - .toolbar_window - .as_mut() - .filter(|toolbar_window| toolbar_window.window.id() == window_id) - else { - return OverlayControl::Continue; - }; - let size = toolbar_window.window.inner_size(); - - match toolbar_window.renderer.resize(size) { - Ok(()) => { - let window = Arc::clone(&toolbar_window.window); - - self.configure_hud_window_common( - window.as_ref(), - Some(f64::from(HUD_PILL_CORNER_RADIUS_POINTS)), - ); - - OverlayControl::Continue - }, - Err(err) => self.exit(OverlayExit::Error(format!("{err:#}"))), - } - } - - fn should_hide_toolbar_window(&self, monitor: MonitorRect) -> bool { - !matches!(self.state.mode, OverlayMode::Frozen) - || !self.toolbar_state.visible - || self.state.frozen_image.is_none() - || self.state.monitor != Some(monitor) - } - - fn set_toolbar_window_hidden(&mut self) { - if let Some(toolbar_window) = self.toolbar_window.as_ref() { - toolbar_window.window.set_visible(false); - } - - self.toolbar_window_visible = false; - self.toolbar_window_warmup_redraws_remaining = 0; - self.last_present_at = Instant::now(); - } - - fn draw_toolbar_window_frame( - &mut self, - monitor: MonitorRect, - toolbar_input: Option, - ) -> Result<()> { - self.sync_frozen_toolbar_state(); - - if self.maybe_recenter_frozen_toolbar_default_slot(monitor) { - self.request_redraw_for_monitor(monitor); - } - - #[cfg(not(target_os = "macos"))] - { - let _ = (&monitor, &toolbar_input); - let Some(toolbar_window) = self.toolbar_window.as_ref() else { - return Ok(()); - }; - - toolbar_window.window.set_visible(false); - - self.last_present_at = Instant::now(); - - Ok(()) - } - #[cfg(target_os = "macos")] - { - let should_focus_frozen_keyboard = !self.toolbar_window_visible - && matches!(self.state.mode, OverlayMode::Frozen) - && !self.scroll_capture.active; - let Some(gpu) = self.gpu.as_ref() else { - return Ok(()); - }; - let Some(toolbar_window) = self.toolbar_window.as_ref() else { - return Ok(()); - }; - - toolbar_window.window.set_visible(true); - - if !self.toolbar_window_visible { - self.toolbar_window_visible = true; - self.toolbar_window_warmup_redraws_remaining = TOOLBAR_WINDOW_WARMUP_REDRAWS; - } - if should_focus_frozen_keyboard { - self.focus_frozen_keyboard_window(); - } - - let previous_floating_position = self.toolbar_state.floating_position; - - self.toolbar_state.floating_position = Some(Pos2::ZERO); - - let Some(toolbar_window) = self.toolbar_window.as_mut() else { - return Ok(()); - }; - let draw_result = toolbar_window.renderer.draw( - gpu, - &self.state, - monitor, - false, - Some(Pos2::ZERO), - false, - HudAnchor::Cursor, - self.config.toolbar_placement, - self.config.show_alt_hint_keycap, - false, - self.config.hud_opaque, - self.config.hud_opacity, - self.config.hud_fog_amount, - self.config.hud_milk_amount, - self.config.hud_tint_hue, - self.config.theme_mode, - self.config.selection_flow_enabled, - self.config.selection_flow_stroke_width_px, - false, - false, - self.frozen_capture_source == FrozenCaptureSource::FullscreenFallback, - None, - Some(&mut self.toolbar_state), - toolbar_input, - ); - - self.toolbar_state.floating_position = previous_floating_position; - - draw_result?; - - let desired_inner_size = toolbar_window.renderer.hud_pill.map(|hud_pill| { - ( - hud_pill.rect.width().ceil().max(1.0) as u32, - hud_pill.rect.height().ceil().max(1.0) as u32, - ) - }); - let toolbar_window = Arc::clone(&toolbar_window.window); - - if let Some(desired) = desired_inner_size - && self.toolbar_inner_size_points != Some(desired) - { - self.toolbar_inner_size_points = Some(desired); - - let _ = toolbar_window.request_inner_size(LogicalSize::new( - f64::from(desired.0), - f64::from(desired.1), - )); - } - - Ok(()) - } - } - - fn handle_toolbar_window_redraw_requested(&mut self) -> OverlayControl { - self.event_loop_last_progress_window_id = - self.toolbar_window.as_ref().map(|toolbar_window| toolbar_window.window.id()); - self.event_loop_last_progress_monitor_id = self.state.monitor.map(|monitor| monitor.id); - - self.maybe_log_event_loop_stall(Instant::now()); - self.mark_progress(OverlayEventLoopPhase::ToolbarRedraw); - - let Some(monitor) = self.state.monitor else { - return OverlayControl::Continue; - }; - let toolbar_input = self.toolbar_pointer_state(monitor, self.toolbar_pointer_local); - let should_hide_toolbar_window = self.should_hide_toolbar_window(monitor); - - if should_hide_toolbar_window { - self.set_toolbar_window_hidden(); - - return OverlayControl::Continue; - } - - if let Err(err) = self.draw_toolbar_window_frame(monitor, toolbar_input) { - return self.exit(OverlayExit::Error(format!("{err:#}"))); - } - - self.update_scroll_toolbar_default_position(monitor); - - if let Some(toolbar_pos) = self.toolbar_state.floating_position { - let _ = self.update_toolbar_outer_position(monitor, toolbar_pos); - } - if let Some(action) = self.toolbar_state.pending_action.take() { - let control = self.handle_toolbar_action(action); - - if !matches!(control, OverlayControl::Continue) { - return control; - } - } - - self.last_present_at = Instant::now(); - - if self.toolbar_state.needs_redraw { - self.toolbar_state.needs_redraw = false; - - self.request_redraw_toolbar_window(); - } - - OverlayControl::Continue - } - - fn handle_modifiers_changed(&mut self, modifiers: &winit::event::Modifiers) -> OverlayControl { - self.keyboard_modifiers = modifiers.state(); - - OverlayControl::Continue - } - - #[cfg(not(target_os = "macos"))] - fn sample_mouse_location(&mut self) -> GlobalPoint { - let Some(cursor_device) = self.cursor_device.as_ref() else { - return GlobalPoint::new(0, 0); - }; - let mouse = cursor_device.get_mouse(); - - GlobalPoint::new(mouse.coords.0, mouse.coords.1) - } - - #[cfg(target_os = "macos")] - fn sample_mouse_location(&mut self) -> GlobalPoint { - let started_at = Instant::now(); - let point = macos_mouse_location().unwrap_or(GlobalPoint::new(0, 0)); - let elapsed = started_at.elapsed(); - - self.slow_op_logger.warn_if_slow( - "overlay.macos_cursor_location", - elapsed, - SLOW_OP_WARN_CURSOR_LOCATION, - || format!("sample point=({}, {})", point.x, point.y), - ); - - point - } - - fn last_fresh_event_cursor(&self) -> Option<(MonitorRect, GlobalPoint)> { - self.last_fresh_event_cursor_with_ttl(CURSOR_EVENT_TICK_TTL) - } - - fn last_fresh_event_cursor_with_ttl( - &self, - ttl: Duration, - ) -> Option<(MonitorRect, GlobalPoint)> { - let event_cursor_at = self.last_event_cursor_at?; - let event_cursor = self.last_event_cursor?; - - if event_cursor_at.elapsed() > ttl { - return None; - } - - Some(event_cursor) - } - - fn set_alt_held(&mut self, alt: bool) { - if self.state.alt_held == alt { - return; - } - - self.state.alt_held = alt; - - if !alt { - self.handle_alt_release(); - - return; - } - - let Some((monitor, cursor)) = self.alt_activation_cursor_context() else { - return; - }; - - self.set_alt_loupe_window_visible(Some(monitor), true); - - if self.use_fake_hud_blur() { - self.maybe_request_live_bg(monitor); - } - - match self.state.mode { - OverlayMode::Live => self.request_live_alt_samples(monitor, cursor), - OverlayMode::Frozen => self.request_frozen_alt_samples(cursor), - } - } - - fn apply_loupe_activation_input(&mut self, pressed: bool, repeat: bool) -> bool { - let previous_alt_held = self.state.alt_held; - - match self.config.alt_activation { - AltActivationMode::Hold => self.set_alt_held(pressed), - AltActivationMode::Toggle => { - if pressed && !repeat { - self.set_alt_held(!self.state.alt_held); - } - }, - } - - previous_alt_held != self.state.alt_held - } - - fn apply_loupe_activation_key_event(&mut self, pressed: bool, repeat: bool) -> bool { - self.loupe_activation_key_down = pressed; - - if !pressed && !self.state.alt_held { - return false; - } - if pressed && !self.loupe_activation_shortcut_available() { - return false; - } - - self.apply_loupe_activation_input(pressed, repeat) - } - - fn clear_loupe_activation_on_focus_loss(&mut self) { - let should_reset = self.loupe_activation_key_down - || (matches!(self.config.alt_activation, AltActivationMode::Hold) - && self.state.alt_held); - - if !should_reset { - return; - } - - self.loupe_activation_key_down = false; - - if self.apply_loupe_activation_input(false, false) { - let _ = self.request_redraw_for_alt_state_change(); - } - } - - fn maybe_clear_loupe_activation_after_focus_loss(&mut self) { - if !self.pending_focus_loss_cleanup || !self.focused_window_ids.is_empty() { - return; - } - - self.pending_focus_loss_cleanup = false; - - self.clear_loupe_activation_on_focus_loss(); - } - - fn request_redraw_for_alt_state_change(&mut self) -> OverlayControl { - if matches!(self.state.mode, OverlayMode::Live) { - self.request_redraw_hud_window(); - - if !self.live_loupe_uses_hud_window() - && (self.state.alt_held || self.loupe_window_visible) - { - self.request_redraw_loupe_window(); - } - - return OverlayControl::Continue; - } - - if let Some(monitor) = self.active_cursor_monitor() { - self.request_redraw_for_monitor(monitor); - } else { - self.request_redraw_all(); - } - - OverlayControl::Continue - } - - fn alt_activation_cursor_context(&mut self) -> Option<(MonitorRect, GlobalPoint)> { - if let Some((monitor, cursor)) = self.last_fresh_event_cursor() { - self.seed_alt_activation_cursor_context(monitor, cursor); - - return Some((monitor, cursor)); - } - - let cursor = self.sample_mouse_location(); - let Some(monitor) = self.monitor_at(cursor) else { - if self.state.cursor.is_none() { - self.state.cursor = Some(cursor); - } - - return self.active_cursor_monitor().zip(self.state.cursor); - }; - - self.seed_alt_activation_cursor_context(monitor, cursor); - - Some((monitor, cursor)) - } - - fn seed_alt_activation_cursor_context(&mut self, monitor: MonitorRect, cursor: GlobalPoint) { - let old_monitor = self.active_cursor_monitor(); - let old_cursor = self.state.cursor; - - match self.state.mode { - OverlayMode::Live => { - self.update_cursor_for_live_move(old_monitor, old_cursor, monitor, cursor) - }, - OverlayMode::Frozen => { - self.update_cursor_state(monitor, cursor); - self.update_hud_window_position(monitor, cursor); - }, - } - } - - fn handle_alt_release(&mut self) { - self.state.loupe = None; - self.loupe_outer_pos = None; - self.pending_loupe_outer_pos = None; - - self.set_alt_loupe_window_visible(None, false); - - if matches!(self.state.mode, OverlayMode::Live) { - self.request_redraw_hud_window(); - - return; - } - - if let Some(monitor) = self.active_cursor_monitor() { - self.request_redraw_for_monitor(monitor); - } - } - - fn set_alt_loupe_window_visible(&mut self, monitor: Option, visible: bool) { - if self.live_loupe_uses_hud_window() { - self.loupe_window_visible = false; - - self.reset_loupe_window_warmup_redraws(); - - if let Some(loupe_window) = self.loupe_window.as_ref() { - loupe_window.window.set_visible(false); - } - - return; - } - if visible { - let Some(monitor) = monitor else { - return; - }; - let visible = self.update_loupe_window_position(monitor); - let was_visible = self.loupe_window_visible; - - self.loupe_window_visible = visible; - - if visible { - self.force_apply_pending_loupe_window_move(); - } - if visible { - if !was_visible { - self.maybe_start_loupe_window_warmup_redraw(); - } - } else { - self.reset_loupe_window_warmup_redraws(); - } - - if let Some(loupe_window) = self.loupe_window.as_ref() { - loupe_window.window.set_visible(visible); - loupe_window.window.request_redraw(); - } - - return; - } - - self.loupe_window_visible = false; - - self.reset_loupe_window_warmup_redraws(); - - if let Some(loupe_window) = self.loupe_window.as_ref() { - loupe_window.window.set_visible(false); - loupe_window.window.request_redraw(); - } - } - - fn request_live_alt_samples(&mut self, monitor: MonitorRect, cursor: GlobalPoint) { - let sample_updated = self.request_live_cursor_sample(monitor, cursor, true); - let apply = self.live_sample_request_redraw_intent(false, sample_updated, true); - - if apply.any_changed() { - self.request_redraw_live_sample_targets(monitor, apply); - } - } - - fn request_frozen_alt_samples(&mut self, cursor: GlobalPoint) { - if let (Some(frozen_monitor), Some(_)) = - (self.state.monitor, self.state.frozen_image.as_ref()) - { - self.state.loupe = image_helpers::frozen_loupe_patch( - &self.state.frozen_image, - Some(frozen_monitor), - cursor, - self.loupe_patch_width_px, - self.loupe_patch_height_px, - ) - .map(|patch| crate::state::LoupeSample { center: cursor, patch }); - - self.request_redraw_for_monitor(frozen_monitor); - } - } - - fn handle_resized(&mut self, window_id: WindowId, size: PhysicalSize) -> OverlayControl { - let window_scale_factor = self - .windows - .get(&window_id) - .map(|w| w.window.scale_factor()) - .or_else(|| self.hud_window.as_ref().map(|w| w.window.scale_factor())) - .or_else(|| self.loupe_window.as_ref().map(|w| w.window.scale_factor())); - - tracing::trace!(?window_id, ?size, ?window_scale_factor, "WindowEvent::Resized"); - - if let Some(hud_window) = self.hud_window.as_mut() - && hud_window.window.id() == window_id - { - let window = Arc::clone(&hud_window.window); - - match hud_window.renderer.resize(size) { - Ok(()) => { - self.configure_hud_window_common(window.as_ref(), None); - - return OverlayControl::Continue; - }, - Err(err) => return self.exit(OverlayExit::Error(format!("{err:#}"))), - } - } - if let Some(loupe_window) = self.loupe_window.as_mut() - && loupe_window.window.id() == window_id - { - let window = Arc::clone(&loupe_window.window); - - match loupe_window.renderer.resize(size) { - Ok(()) => { - self.configure_hud_window_common( - window.as_ref(), - Some(LOUPE_TILE_CORNER_RADIUS_POINTS), - ); - - return OverlayControl::Continue; - }, - Err(err) => return self.exit(OverlayExit::Error(format!("{err:#}"))), - } - } - - let Some(overlay_window) = self.windows.get_mut(&window_id) else { - return OverlayControl::Continue; - }; - - match overlay_window.renderer.resize(size) { - Ok(()) => OverlayControl::Continue, - Err(err) => self.exit(OverlayExit::Error(format!("{err:#}"))), - } - } - - fn handle_scale_factor_changed(&mut self, window_id: WindowId) -> OverlayControl { - let window_scale_factor = self - .windows - .get(&window_id) - .map(|w| w.window.scale_factor()) - .or_else(|| self.hud_window.as_ref().map(|w| w.window.scale_factor())) - .or_else(|| self.loupe_window.as_ref().map(|w| w.window.scale_factor())); - - tracing::trace!(?window_id, ?window_scale_factor, "WindowEvent::ScaleFactorChanged"); - - if let Some(hud_window) = self.hud_window.as_mut() - && hud_window.window.id() == window_id - { - let size = hud_window.window.inner_size(); - let window = Arc::clone(&hud_window.window); - - match hud_window.renderer.resize(size) { - Ok(()) => { - self.configure_hud_window_common(window.as_ref(), None); - - return OverlayControl::Continue; - }, - Err(err) => return self.exit(OverlayExit::Error(format!("{err:#}"))), - } - } - if let Some(loupe_window) = self.loupe_window.as_mut() - && loupe_window.window.id() == window_id - { - let size = loupe_window.window.inner_size(); - let window = Arc::clone(&loupe_window.window); - - match loupe_window.renderer.resize(size) { - Ok(()) => { - self.configure_hud_window_common( - window.as_ref(), - Some(LOUPE_TILE_CORNER_RADIUS_POINTS), - ); - - return OverlayControl::Continue; - }, - Err(err) => return self.exit(OverlayExit::Error(format!("{err:#}"))), - } - } - - let Some(overlay_window) = self.windows.get_mut(&window_id) else { - return OverlayControl::Continue; - }; - let size = overlay_window.window.inner_size(); - - match overlay_window.renderer.resize(size) { - Ok(()) => OverlayControl::Continue, - Err(err) => self.exit(OverlayExit::Error(format!("{err:#}"))), - } - } - - fn handle_cursor_moved( - &mut self, - window_id: WindowId, - position: PhysicalPosition, - ) -> OverlayControl { - let old_monitor = self.active_cursor_monitor(); - let now = Instant::now(); - let Some(overlay_window) = self.windows.get(&window_id) else { - return self.handle_cursor_moved_without_overlay_window(window_id, old_monitor); - }; - let window_monitor = overlay_window.monitor; - let scale_factor = overlay_window.window.scale_factor(); - let window_size = overlay_window.window.inner_size(); - // Clamp to overlay window bounds and map to monitor coordinates. - let max_local_x = ((window_size.width as f64) / scale_factor).max(1.0) as i32 - 1; - let max_local_y = ((window_size.height as f64) / scale_factor).max(1.0) as i32 - 1; - let local_x = (position.x / scale_factor).round() as i32; - let local_y = (position.y / scale_factor).round() as i32; - let event_global = GlobalPoint::new( - window_monitor.origin.x + local_x.clamp(0, max_local_x), - window_monitor.origin.y + local_y.clamp(0, max_local_y), - ); - let monitor = window_monitor; - let global = event_global; - let source = DeviceCursorPointSource::EventRecentFallback; - let device_cursor = event_global; - - self.last_event_cursor = Some((monitor, event_global)); - self.last_event_cursor_at = Some(now); - - let old_cursor = self.state.cursor; - let trace = CursorMoveTrace { - window_id, - position, - old_cursor, - device_cursor, - event_global, - monitor, - global, - source, - }; - - self.trace_cursor_moved_with_mapping(trace); - self.update_cursor_for_live_move(old_monitor, old_cursor, monitor, global); - - let previous_drag_rect = self.state.drag_rect; - - self.update_live_drag_rect(monitor, global); - self.update_frozen_selection_drag_rect(global); - self.request_cursor_move_samples(monitor, global); - - if let Some(old_monitor) = old_monitor - && old_monitor != monitor - { - self.request_redraw_for_monitor(old_monitor); - } - - if Self::live_overlay_redraw_needed_for_cursor_update( - old_monitor, - monitor, - previous_drag_rect, - self.state.drag_rect, - ) { - self.request_redraw_for_monitor(monitor); - } - - OverlayControl::Continue - } - - fn handle_cursor_moved_without_overlay_window( - &mut self, - window_id: WindowId, - old_monitor: Option, - ) -> OverlayControl { - if self.should_ignore_live_auxiliary_cursor_event(window_id) { - return OverlayControl::Continue; - } - - let now = Instant::now(); - let raw = self.sample_mouse_location(); - let Some((monitor, global, source)) = self.resolve_device_cursor_point(raw) else { - return OverlayControl::Continue; - }; - let old_cursor = self.state.cursor; - - self.last_event_cursor = Some((monitor, global)); - self.last_event_cursor_at = Some(now); - - if tracing::enabled!(tracing::Level::TRACE) { - tracing::trace!( - window_id = ?window_id, - window_known = false, - old_cursor = ?old_cursor, - device_cursor = ?global, - event_cursor = ?global, - source = source.as_str(), - "CursorMoved (no overlay window mapping)." - ); - } - - self.update_cursor_for_live_move(old_monitor, old_cursor, monitor, global); - - let previous_drag_rect = self.state.drag_rect; - - self.update_live_drag_rect(monitor, global); - self.update_frozen_selection_drag_rect(global); - self.request_cursor_move_samples(monitor, global); - - if let Some(old_monitor) = old_monitor - && old_monitor != monitor - { - self.request_redraw_for_monitor(old_monitor); - } - - if Self::live_overlay_redraw_needed_for_cursor_update( - old_monitor, - monitor, - previous_drag_rect, - self.state.drag_rect, - ) { - self.request_redraw_for_monitor(monitor); - } - - OverlayControl::Continue - } - - fn should_ignore_live_auxiliary_cursor_event(&self, window_id: WindowId) -> bool { - Self::should_ignore_live_auxiliary_cursor_event_for_role( - self.state.mode, - self.is_auxiliary_capture_window(window_id), - ) - } - - fn is_auxiliary_capture_window(&self, window_id: WindowId) -> bool { - self.hud_window.as_ref().is_some_and(|window| window.window.id() == window_id) - || self.loupe_window.as_ref().is_some_and(|window| window.window.id() == window_id) - || self.toolbar_window.as_ref().is_some_and(|window| window.window.id() == window_id) - || self - .scroll_preview_window - .as_ref() - .is_some_and(|window| window.window.id() == window_id) - } - - fn should_ignore_live_auxiliary_cursor_event_for_role( - mode: OverlayMode, - is_auxiliary_window: bool, - ) -> bool { - matches!(mode, OverlayMode::Live) && is_auxiliary_window - } - - fn current_device_cursor(&mut self) -> GlobalPoint { - self.sample_mouse_location() - } - - fn trace_cursor_moved_with_mapping(&self, trace: CursorMoveTrace) { - if !tracing::enabled!(tracing::Level::TRACE) { - return; - } - - let delta_x = - trace.global.x.abs_diff(trace.old_cursor.map_or(trace.global.x, |point| point.x)); - let delta_y = - trace.global.y.abs_diff(trace.old_cursor.map_or(trace.global.y, |point| point.y)); - - tracing::trace!( - window_id = ?trace.window_id, - window_known = true, - window_position = ?trace.position, - old_cursor = ?trace.old_cursor, - device_cursor = ?trace.device_cursor, - event_cursor = ?trace.event_global, - source = trace.source.as_str(), - monitor_id = trace.monitor.id, - cursor_delta_x = delta_x, - cursor_delta_y = delta_y, - "CursorMoved coordinate source: {}.", - trace.source.as_str() - ); - } - - fn update_cursor_for_live_move( - &mut self, - old_monitor: Option, - old_cursor: Option, - monitor: MonitorRect, - global: GlobalPoint, - ) { - self.update_cursor_state(monitor, global); - self.update_hud_window_position(monitor, global); - - if Self::live_hud_redraw_needed_for_cursor_update(old_cursor, global, old_monitor, monitor) - { - self.request_redraw_hud_window(); - } - if self.should_try_pending_follow_window_move_on_live_cursor_update() { - self.maybe_apply_pending_hud_and_loupe_moves(); - } - if matches!(self.state.mode, OverlayMode::Live) && self.use_fake_hud_blur() { - if self.state.live_bg_monitor != Some(monitor) { - self.state.live_bg_monitor = None; - self.state.live_bg_image = None; - } - - self.maybe_request_live_bg(monitor); - } - } - - fn request_cursor_move_samples(&mut self, monitor: MonitorRect, global: GlobalPoint) { - if !matches!(self.state.mode, OverlayMode::Live) { - return; - } - if self.pending_click_hit_test_request_id.is_some() { - return; - } - - let is_dragging_window = matches!(self.state.mode, OverlayMode::Live) - && self.left_mouse_button_down - && self.left_mouse_button_down_monitor == Some(monitor); - let had_snapshot_update = if is_dragging_window || self.state.alt_held { - false - } else { - self.apply_live_hover_cache_state(monitor, global) - }; - let sample_requested = - self.request_live_cursor_sample(monitor, global, self.state.alt_held); - - if !is_dragging_window && !self.state.alt_held { - let _ = self.request_live_window_list_refresh_if_needed(); - } - - let apply = self.live_sample_request_redraw_intent( - had_snapshot_update, - sample_requested, - self.state.alt_held || self.loupe_window_visible, - ); - - if apply.any_changed() { - self.request_redraw_live_sample_targets(monitor, apply); - } - } - - fn handle_left_mouse_input( - &mut self, - window_id: WindowId, - state: ElementState, - ) -> OverlayControl { - let monitor = self - .windows - .get(&window_id) - .map(|w| w.monitor) - .or_else(|| self.active_cursor_monitor()) - .or(self.state.monitor); - let Some(monitor) = monitor else { - return OverlayControl::Continue; - }; - - if matches!(self.state.mode, OverlayMode::Frozen) { - self.reset_toolbar_pointer_state(); - - match state { - ElementState::Pressed => { - let cursor = self.current_device_cursor(); - let _ = self.begin_frozen_selection_drag(cursor); - }, - ElementState::Released => self.stop_frozen_selection_drag(), - } - - self.request_redraw_for_monitor(monitor); - - return OverlayControl::Continue; - } - if !matches!(self.state.mode, OverlayMode::Live) { - return OverlayControl::Continue; - } - - match state { - ElementState::Pressed => { - if self.left_mouse_button_down { - return OverlayControl::Continue; - } - - let raw_cursor = self.current_device_cursor(); - let Some((press_monitor, press_global, _)) = - self.resolve_live_cursor_point(raw_cursor) - else { - self.left_mouse_button_down = true; - self.left_mouse_button_down_monitor = Some(monitor); - self.left_mouse_button_down_global = Some(raw_cursor); - self.state.drag_rect = None; - self.state.hovered_window_rect = None; - - self.reset_toolbar_pointer_state(); - self.request_redraw_for_monitor(monitor); - - return OverlayControl::Continue; - }; - - self.left_mouse_button_down = true; - self.left_mouse_button_down_monitor = Some(press_monitor); - self.left_mouse_button_down_global = Some(press_global); - self.state.drag_rect = None; - self.state.hovered_window_rect = None; - - self.reset_toolbar_pointer_state(); - self.update_cursor_state(press_monitor, press_global); - self.update_hud_window_position(press_monitor, press_global); - self.request_redraw_for_monitor(press_monitor); - - OverlayControl::Continue - }, - ElementState::Released => { - let Some(start_monitor) = self.left_mouse_button_down_monitor else { - return OverlayControl::Continue; - }; - let Some(start_global) = self.left_mouse_button_down_global else { - self.left_mouse_button_down = false; - self.left_mouse_button_down_monitor = None; - - return OverlayControl::Continue; - }; - let raw_cursor = self.current_device_cursor(); - let (release_monitor, release_global) = - if let Some((release_monitor, release_global, _)) = - self.resolve_live_cursor_point(raw_cursor) - { - (release_monitor, release_global) - } else { - (start_monitor, start_global) - }; - - self.left_mouse_button_down = false; - self.left_mouse_button_down_monitor = None; - self.left_mouse_button_down_global = None; - - let drag_rect = if start_monitor == release_monitor { - self.state.drag_rect.take() - } else { - None - }; - - if let Some(rect) = drag_rect - && start_monitor == release_monitor - && rect.monitor_id == release_monitor.id - && rect.rect.width as f32 >= LIVE_DRAG_START_THRESHOLD_PX - && rect.rect.height as f32 >= LIVE_DRAG_START_THRESHOLD_PX - { - self.begin_frozen_capture_with_rect( - release_monitor, - Some(rect.rect), - None, - Some(release_global), - ); - - return OverlayControl::Continue; - } - - self.state.drag_rect = None; - - self.request_click_capture_hit_test(release_monitor, release_global); - - OverlayControl::Continue - }, - } - } - - fn handle_scroll_mouse_wheel( - &mut self, - window_id: WindowId, - delta: &MouseScrollDelta, - ) -> OverlayControl { - if !self.scroll_capture.active || self.scroll_capture.paused { - return OverlayControl::Continue; - } - - let Some(overlay_monitor) = self.windows.get(&window_id).map(|window| window.monitor) - else { - return OverlayControl::Continue; - }; - let Some(scroll_monitor) = self.scroll_capture.monitor else { - return OverlayControl::Continue; - }; - let Some(capture_rect) = self.scroll_capture.capture_rect_pixels else { - return OverlayControl::Continue; - }; - - if overlay_monitor != scroll_monitor { - return OverlayControl::Continue; - } - - let cursor = self.current_device_cursor(); - let cursor_pixels = scroll_monitor.local_u32_pixels(cursor); - let Some(cursor_pixels) = cursor_pixels else { - return OverlayControl::Continue; - }; - - if !capture_rect.contains(cursor_pixels) { - return OverlayControl::Continue; - } - - self.record_scroll_capture_input_direction_from_overlay_wheel_at(delta, Instant::now()); - - #[cfg(target_os = "macos")] - { - let target_point = cursor; - let now = Instant::now(); - - self.arm_scroll_overlay_mouse_passthrough_window(now, "overlay_mouse_wheel"); - - let forwarded = self.forward_macos_scroll_wheel_event( - scroll_monitor, - cursor, - Some(cursor_pixels), - capture_rect, - target_point, - delta, - ); - - if !forwarded { - self.disarm_scroll_overlay_mouse_passthrough(now, "wheel_forward_failed"); - } - } - - OverlayControl::Continue - } - - #[cfg(target_os = "macos")] - fn forward_macos_scroll_wheel_event( - &mut self, - scroll_monitor: MonitorRect, - cursor: GlobalPoint, - cursor_pixels: Option<(u32, u32)>, - capture_rect: RectPoints, - target_point: GlobalPoint, - delta: &MouseScrollDelta, - ) -> bool { - let normalized = Self::normalize_macos_scroll_wheel_delta( - delta, - &mut self.scroll_capture.pixel_delta_residual, - ); - - if normalized.posted_x == 0 && normalized.posted_y == 0 { - return false; - } - - if let Err(err) = macos_post_scroll_wheel_event(normalized, target_point) { - tracing::warn!( - op = "scroll_capture.wheel_forward_failed", - monitor_id = scroll_monitor.id, - cursor = ?cursor, - cursor_pixels = ?cursor_pixels, - capture_rect = ?capture_rect, - target_point = ?target_point, - raw_delta = ?delta, - normalized_delta_x = normalized.normalized_x, - normalized_delta_y = normalized.normalized_y, - posted_delta_x = normalized.posted_x, - posted_delta_y = normalized.posted_y, - pixel_residual_x = normalized.residual.x, - pixel_residual_y = normalized.residual.y, - error = %format!("{err:#}"), - "Failed to forward scroll wheel event." - ); - - self.state.set_error(format!("{err:#}")); - self.request_redraw_all(); - - return false; - } - - tracing::info!( - op = "scroll_capture.wheel_forwarded", - monitor_id = scroll_monitor.id, - cursor = ?cursor, - cursor_pixels = ?cursor_pixels, - capture_rect = ?capture_rect, - target_point = ?target_point, - raw_delta = ?delta, - normalized_delta_x = normalized.normalized_x, - normalized_delta_y = normalized.normalized_y, - posted_delta_x = normalized.posted_x, - posted_delta_y = normalized.posted_y, - pixel_residual_x = normalized.residual.x, - pixel_residual_y = normalized.residual.y, - source_state_id = macos_hid_event_source_state_id(), - "Forwarded scroll wheel event." - ); - - true - } - - #[cfg(target_os = "macos")] - fn normalize_macos_scroll_wheel_delta( - delta: &MouseScrollDelta, - residual: &mut MacOSScrollPixelResidual, - ) -> MacOSScrollWheelEvent { - match delta { - MouseScrollDelta::LineDelta(x, y) => MacOSScrollWheelEvent { - units: KCG_SCROLL_EVENT_UNIT_LINE, - normalized_x: f64::from(*x), - normalized_y: f64::from(*y), - posted_x: x.round() as i32, - posted_y: y.round() as i32, - residual: *residual, - }, - MouseScrollDelta::PixelDelta(delta) => { - let normalized_x = Self::normalize_macos_scroll_pixel_component(delta.x); - let normalized_y = Self::normalize_macos_scroll_pixel_component(delta.y); - let accumulated_x = residual.x + normalized_x; - let accumulated_y = residual.y + normalized_y; - let posted_x = accumulated_x.trunc() as i32; - let posted_y = accumulated_y.trunc() as i32; - - *residual = MacOSScrollPixelResidual { - x: accumulated_x - f64::from(posted_x), - y: accumulated_y - f64::from(posted_y), - }; - - MacOSScrollWheelEvent { - units: KCG_SCROLL_EVENT_UNIT_PIXEL, - normalized_x, - normalized_y, - posted_x, - posted_y, - residual: *residual, - } - }, - } - } - - #[cfg(target_os = "macos")] - fn normalize_macos_scroll_pixel_component(value: f64) -> f64 { - if !value.is_finite() { - return 0.0; - } - - let normalized = if value.abs() > MACOS_SCROLL_PIXEL_WRAP_THRESHOLD { - if value.is_sign_positive() { - value - MACOS_SCROLL_PIXEL_WRAP_MODULUS - } else { - value + MACOS_SCROLL_PIXEL_WRAP_MODULUS - } - } else { - value - }; - - normalized.clamp(-MACOS_SCROLL_PIXEL_DELTA_CLAMP, MACOS_SCROLL_PIXEL_DELTA_CLAMP) - } - - fn scroll_capture_direction_from_wheel_delta( - delta: &MouseScrollDelta, - ) -> Option { - let vertical_delta = match delta { - MouseScrollDelta::LineDelta(_, y) => f64::from(*y), - MouseScrollDelta::PixelDelta(delta) => { - #[cfg(target_os = "macos")] - { - Self::normalize_macos_scroll_pixel_component(delta.y) - } - #[cfg(not(target_os = "macos"))] - { - delta.y - } - }, - }; - - Self::scroll_capture_direction_from_delta_y(vertical_delta) - } - - fn scroll_capture_direction_from_delta_y(vertical_delta: f64) -> Option { - if vertical_delta < 0.0 { - Some(ScrollDirection::Down) - } else if vertical_delta > 0.0 { - Some(ScrollDirection::Up) - } else { - None - } - } - - fn scroll_capture_direction_from_external_input_delta_y( - delta_y: f64, - ) -> Option { - if delta_y == 0.0 { - return None; - } - - Self::scroll_capture_direction_from_delta_y(delta_y) - } - - fn scroll_capture_motion_rows_from_wheel_delta(delta: &MouseScrollDelta) -> f64 { - match delta { - MouseScrollDelta::LineDelta(_, y) => f64::from(*y).abs(), - MouseScrollDelta::PixelDelta(delta) => { - #[cfg(target_os = "macos")] - { - Self::normalize_macos_scroll_pixel_component(delta.y).abs() - } - #[cfg(not(target_os = "macos"))] - { - delta.y.abs() - } - }, - } - } - - fn accumulate_scroll_capture_downward_motion_rows(&mut self, motion_rows: f64) { - if !motion_rows.is_finite() || motion_rows <= 0.0 { - return; - } - - self.scroll_capture.downward_motion_rows_pending = - (self.scroll_capture.downward_motion_rows_pending + motion_rows.abs()) - .clamp(0.0, SCROLL_CAPTURE_INPUT_MOTION_PRIOR_ROWS_MAX); - } - - fn clear_scroll_capture_downward_motion_rows(&mut self) { - self.scroll_capture.downward_motion_rows_pending = 0.0; - } - - fn consume_scroll_capture_downward_motion_rows(&mut self, consumed_rows: u32) { - if consumed_rows == 0 { - return; - } - - let remaining = self.scroll_capture.downward_motion_rows_pending - f64::from(consumed_rows); - - self.scroll_capture.downward_motion_rows_pending = remaining.max(0.0); - } - - fn consume_scroll_capture_downward_motion_rows_for_outcome( - &mut self, - outcome: &ScrollObserveOutcome, - ) { - if let ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows } = - outcome - { - self.consume_scroll_capture_downward_motion_rows(*growth_rows); - } - } - - fn record_scroll_capture_input_direction_at( - &mut self, - direction: ScrollDirection, - gesture_active: bool, - at: Instant, - ) { - self.scroll_capture.input_direction = Some(direction); - self.scroll_capture.input_direction_at = Some(at); - self.scroll_capture.input_gesture_active = gesture_active; - - #[cfg(target_os = "macos")] - self.clear_incompatible_live_stream_stale_grace(); - } - - fn record_scroll_capture_input_direction_from_overlay_wheel_at( - &mut self, - delta: &MouseScrollDelta, - at: Instant, - ) { - if let Some(direction) = Self::scroll_capture_direction_from_wheel_delta(delta) { - self.record_scroll_capture_input_direction_at(direction, false, at); - - if matches!(direction, ScrollDirection::Down) { - self.accumulate_scroll_capture_downward_motion_rows( - Self::scroll_capture_motion_rows_from_wheel_delta(delta), - ); - } else { - self.clear_scroll_capture_downward_motion_rows(); - } - } - } - - fn finish_scroll_capture_input_direction_at(&mut self, at: Instant) { - if self.scroll_capture.input_direction.is_some() { - self.scroll_capture.input_direction_at = Some(at); - } else { - self.scroll_capture.input_direction_at = None; - } - - self.scroll_capture.input_gesture_active = false; - - #[cfg(target_os = "macos")] - self.clear_incompatible_live_stream_stale_grace(); - } - - fn apply_scroll_capture_input_delta_y( - &mut self, - delta_y: f64, - gesture_active: bool, - gesture_ended: bool, - at: Instant, - ) { - if let Some(direction) = Self::scroll_capture_direction_from_external_input_delta_y(delta_y) - { - if self.should_absorb_upward_external_input_into_active_downward_gesture( - direction, - gesture_active, - ) { - self.record_scroll_capture_input_direction_at( - ScrollDirection::Down, - gesture_active, - at, - ); - self.accumulate_scroll_capture_downward_motion_rows(delta_y.abs()); - } else { - self.record_scroll_capture_input_direction_at(direction, gesture_active, at); - - if matches!(direction, ScrollDirection::Down) { - self.accumulate_scroll_capture_downward_motion_rows(delta_y.abs()); - } else { - self.clear_scroll_capture_downward_motion_rows(); - } - } - } - - if gesture_ended { - self.finish_scroll_capture_input_direction_at(at); - } - } - - fn should_absorb_upward_external_input_into_active_downward_gesture( - &self, - direction: ScrollDirection, - gesture_active: bool, - ) -> bool { - gesture_active - && matches!(direction, ScrollDirection::Up) - && self.scroll_capture.input_direction == Some(ScrollDirection::Down) - && self.scroll_capture.downward_motion_rows_pending > 0.0 - } - - fn apply_external_scroll_input_delta_y( - &mut self, - global_x: f64, - global_y: f64, - delta_y: f64, - gesture_active: bool, - gesture_ended: bool, - at: Instant, - ) { - if !self.scroll_capture.active || self.scroll_capture.paused { - return; - } - - let Some(scroll_monitor) = self.scroll_capture.monitor else { - return; - }; - let Some(capture_rect) = self.scroll_capture.capture_rect_pixels else { - return; - }; - let cursor = GlobalPoint::new(global_x.round() as i32, global_y.round() as i32); - let Some(cursor_pixels) = scroll_monitor.local_u32_pixels(cursor) else { - return; - }; - - #[cfg(not(target_os = "macos"))] - if !capture_rect.contains(cursor_pixels) { - return; - } - - #[cfg(target_os = "macos")] - let _cursor_inside_capture_rect = capture_rect.contains(cursor_pixels); - - #[cfg(target_os = "macos")] - if delta_y != 0.0 - && !gesture_ended - && !self.scroll_capture.overlay_mouse_passthrough_persistent - { - self.arm_scroll_overlay_mouse_passthrough_window( - Instant::now(), - "external_scroll_input", - ); - } - - self.apply_scroll_capture_input_delta_y(delta_y, gesture_active, gesture_ended, at); - } - - fn scroll_capture_trace_snapshot_at( - &self, - observation_at: Instant, - ) -> ScrollCaptureTraceSessionSnapshot { - ScrollCaptureTraceSessionSnapshot::capture( - self.scroll_capture.session.as_ref(), - self.scroll_capture_preview_dimensions(), - self.scroll_capture.input_direction, - self.scroll_capture.input_gesture_active, - self.scroll_capture.downward_motion_rows_pending, - self.scroll_capture_input_age_ms_at(observation_at), - ) - } - - #[cfg(test)] - fn scroll_capture_input_allows_observation(&self) -> bool { - self.scroll_capture_observation_block_reason().is_none() - } - - #[cfg(test)] - fn scroll_capture_input_allows_growth(&self) -> bool { - self.scroll_capture_input_allows_observation() - } - - #[cfg(test)] - fn scroll_capture_observation_block_reason(&self) -> Option<&'static str> { - self.scroll_capture_observation_block_reason_at(Instant::now()) - } - - fn scroll_capture_observation_block_reason_at( - &self, - observation_at: Instant, - ) -> Option<&'static str> { - if self.scroll_capture.input_direction.is_none() { - return Some("missing_direction"); - } - if self.scroll_capture.input_gesture_active { - return None; - } - - let Some(input_direction_at) = self.scroll_capture.input_direction_at else { - return Some("missing_input_timestamp"); - }; - - if observation_at.saturating_duration_since(input_direction_at) - > SCROLL_CAPTURE_INPUT_FRESHNESS - { - return Some("stale_input"); - } - - None - } - - #[cfg(target_os = "macos")] - fn scroll_capture_input_age_ms(&self) -> Option { - self.scroll_capture_input_age_ms_at(Instant::now()) - } - - fn scroll_capture_input_age_ms_at(&self, observation_at: Instant) -> Option { - self.scroll_capture.input_direction_at.map(|input_direction_at| { - u64::try_from(observation_at.saturating_duration_since(input_direction_at).as_millis()) - .unwrap_or(u64::MAX) - }) - } - - #[cfg(target_os = "macos")] - fn scroll_capture_should_force_stream_refresh_at(&self, now: Instant) -> bool { - if !self.scroll_capture_has_fresh_downward_backlog_at(now) { - return false; - } - if self.scroll_capture.input_gesture_active { - return false; - } - - let Some(input_direction_at) = self.scroll_capture.input_direction_at else { - return false; - }; - - now.saturating_duration_since(input_direction_at) <= SCROLL_CAPTURE_INPUT_FRESHNESS - } - - fn scroll_capture_has_fresh_downward_backlog_at(&self, now: Instant) -> bool { - if self.scroll_capture.input_direction != Some(ScrollDirection::Down) - || self.scroll_capture.downward_motion_rows_pending <= 0.0 - { - return false; - } - - let Some(input_direction_at) = self.scroll_capture.input_direction_at else { - return false; - }; - - now.saturating_duration_since(input_direction_at) <= SCROLL_CAPTURE_INPUT_FRESHNESS - } - - #[cfg(target_os = "macos")] - fn scroll_capture_should_schedule_stale_stream_refresh_at(&self, now: Instant) -> bool { - if !self.scroll_capture.input_gesture_active { - return true; - } - - self.scroll_capture.last_stream_event_at.is_none_or(|last_stream_event_at| { - now.saturating_duration_since(last_stream_event_at) - >= SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW - }) - } - - fn scroll_capture_should_allow_post_stall_burst_search_at( - &self, - frame_seq: u64, - now: Instant, - ) -> bool { - self.scroll_capture.pending_post_stall_burst_after_seq.is_some_and(|after_seq| { - frame_seq > after_seq && self.scroll_capture_has_fresh_downward_backlog_at(now) - }) - } - - #[cfg(target_os = "macos")] - fn scroll_capture_should_arm_post_stall_burst_for_time_gap_at( - &self, - frame_captured_at: Instant, - ) -> bool { - let Some(previous_captured_at) = self.scroll_capture.last_consumed_stream_frame_captured_at - else { - return false; - }; - - self.scroll_capture_has_fresh_downward_backlog_at(frame_captured_at) - && frame_captured_at.saturating_duration_since(previous_captured_at) - >= SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW - } - - fn toolbar_pointer_state( - &mut self, - monitor: MonitorRect, - toolbar_cursor_local_override: Option, - ) -> Option { - if !matches!(self.state.mode, OverlayMode::Frozen) { - return None; - } - if !self.toolbar_state.visible { - return None; - } - if self.state.monitor != Some(monitor) { - return None; - } - if toolbar_cursor_local_override.is_none() && self.active_cursor_monitor() != Some(monitor) - { - return None; - } - - let left_button_went_down = self.toolbar_left_button_went_down; - let left_button_went_up = self.toolbar_left_button_went_up; - - self.toolbar_left_button_went_down = false; - self.toolbar_left_button_went_up = false; - - let cursor_local = toolbar_cursor_local_override - .or_else(|| self.state.cursor.and_then(|cursor| global_to_local(cursor, monitor)))?; - let left_button_down = self.toolbar_left_button_down; - - Some(FrozenToolbarPointerState { - cursor_local, - left_button_down, - left_button_went_down, - left_button_went_up, - }) - } - - fn handle_key_event(&mut self, event: &KeyEvent) -> OverlayControl { - if matches!(event.logical_key, Key::Named(NamedKey::Tab)) { - let pressed = event.state == ElementState::Pressed; - - if self.apply_loupe_activation_key_event(pressed, event.repeat) { - return self.request_redraw_for_alt_state_change(); - } - - return OverlayControl::Continue; - } - if event.state != ElementState::Pressed { - return OverlayControl::Continue; - } - if event.repeat { - return OverlayControl::Continue; - } - if self.scroll_capture.active { - return self.handle_scroll_capture_key_event(event); - } - - match &event.logical_key { - Key::Named(NamedKey::Escape) => self.cancel_overlay("escape_key"), - Key::Character(key_text) - if (key_text == "h" || key_text == "H") - && self.plain_character_shortcut_available() => - { - self.toolbar_state.visible = !self.toolbar_state.visible; - - self.request_redraw_all(); - - OverlayControl::Continue - }, - Key::Character(key_text) - if key_text.as_str().eq_ignore_ascii_case("c") - && self.plain_character_shortcut_available() => - { - self.auto_center_frozen_capture_rect(); - - OverlayControl::Continue - }, - Key::Character(key_text) - if key_text.as_str().eq_ignore_ascii_case("s") - && self.is_save_shortcut_pressed() => - { - self.begin_png_action(PngAction::Save); - - OverlayControl::Continue - }, - Key::Character(key_text) - if key_text.as_str().eq_ignore_ascii_case("s") - && self.plain_character_shortcut_available() => - { - let available = self.scroll_capture_is_available(); - let selection_ready = self.scroll_capture_selection_is_ready(); - - tracing::info!( - op = "scroll_capture.frozen_s_pressed", - available, - scroll_capture_active = self.scroll_capture.active, - selection_ready, - frozen_capture_source = ?self.frozen_capture_source, - state_mode = ?self.state.mode, - "Received `s` while frozen." - ); - - if selection_ready { - return self.start_scroll_capture(); - } - - OverlayControl::Continue - }, - Key::Named(NamedKey::Space) => { - self.begin_png_action(PngAction::Copy); - - OverlayControl::Continue - }, - _ => OverlayControl::Continue, - } - } - - fn is_save_shortcut_pressed(&self) -> bool { - #[cfg(target_os = "macos")] - { - self.keyboard_modifiers.super_key() - } - #[cfg(not(target_os = "macos"))] - { - self.keyboard_modifiers.control_key() - } - } - - fn loupe_activation_shortcut_available(&self) -> bool { - !self.keyboard_modifiers.shift_key() - && !self.keyboard_modifiers.alt_key() - && !self.keyboard_modifiers.control_key() - && !self.keyboard_modifiers.super_key() - } - - fn plain_character_shortcut_available(&self) -> bool { - !self.loupe_activation_key_down - && !self.keyboard_modifiers.alt_key() - && !self.keyboard_modifiers.control_key() - && !self.keyboard_modifiers.super_key() - } - - fn handle_scroll_capture_key_event(&mut self, event: &KeyEvent) -> OverlayControl { - match &event.logical_key { - Key::Named(NamedKey::Escape) => self.cancel_overlay("scroll_capture_escape_key"), - Key::Named(NamedKey::Space) => { - self.begin_png_action(PngAction::Copy); - - OverlayControl::Continue - }, - Key::Character(key_text) - if key_text.as_str().eq_ignore_ascii_case("s") - && self.is_save_shortcut_pressed() => - { - self.begin_png_action(PngAction::Save); - - OverlayControl::Continue - }, - Key::Character(key_text) if key_text.as_str().eq_ignore_ascii_case("u") => { - self.undo_scroll_capture_append(); - - OverlayControl::Continue - }, - Key::Character(key_text) if key_text.as_str().eq_ignore_ascii_case("p") => { - self.toggle_scroll_capture_paused(); - - OverlayControl::Continue - }, - _ => OverlayControl::Continue, - } - } - - fn current_export_image(&self) -> Option { - if self.scroll_capture.active { - return self - .scroll_capture - .session - .as_ref() - .map(|session| session.export_image().clone()); - } - - self.cropped_frozen_capture_image().or_else(|| self.state.frozen_image.clone()) - } - - fn current_scroll_preview_render_image(&self) -> Option { - if self.scroll_capture.active { - return self.current_export_image(); - } - - self.scroll_capture.preview_display_image.clone().or_else(|| self.current_export_image()) - } - - fn scroll_capture_selection_is_ready(&self) -> bool { - matches!(self.state.mode, OverlayMode::Frozen) - && self.state.monitor.is_some() - && self.state.frozen_capture_rect.is_some() - && self.frozen_capture_source == FrozenCaptureSource::DragRegion - && self.frozen_final_capture_ready() - } - - fn scroll_capture_is_available(&mut self) -> bool { - if !self.scroll_capture_selection_is_ready() { - return false; - } - - #[cfg(target_os = "macos")] - { - true - } - #[cfg(not(target_os = "macos"))] - { - false - } - } - - fn toolbar_scroll_capture_slot_available(&self) -> bool { - if self.scroll_capture.active { - return true; - } - - #[cfg(target_os = "macos")] - { - matches!(self.state.mode, OverlayMode::Frozen) - && self.state.monitor.is_some() - && self.state.frozen_capture_rect.is_some() - && self.frozen_capture_source == FrozenCaptureSource::DragRegion - } - - #[cfg(not(target_os = "macos"))] - { - false - } - } - - #[cfg(target_os = "macos")] - fn try_prepare_scroll_capture_start( - &mut self, - ) -> Option<(MonitorRect, RectPoints, RectPoints, RgbaImage)> { - if !self.scroll_capture_selection_is_ready() { - tracing::info!( - op = "scroll_capture.start_rejected", - reason = "selection_not_ready", - frozen_capture_source = ?self.frozen_capture_source, - state_mode = ?self.state.mode, - "Skipped starting scroll capture because the current frozen selection was not eligible." - ); - - self.state - .set_error(String::from("Scroll capture requires a dragged region selection.")); - self.request_redraw_all(); - - return None; - } - - let Some(monitor) = self.state.monitor else { - tracing::info!( - op = "scroll_capture.start_rejected", - reason = "missing_monitor", - "Skipped starting scroll capture because the frozen monitor was unavailable." - ); - - return None; - }; - let Some(capture_rect_points) = self.state.frozen_capture_rect else { - tracing::info!( - op = "scroll_capture.start_rejected", - reason = "missing_capture_rect", - monitor_id = monitor.id, - "Skipped starting scroll capture because the frozen capture rect was unavailable." - ); - - return None; - }; - let capture_rect_pixels = monitor.local_rect_to_pixels(capture_rect_points); - let Some(base_frame) = - self.cropped_monitor_frozen_region_image(monitor, capture_rect_pixels) - else { - tracing::info!( - op = "scroll_capture.start_rejected", - reason = "base_frame_unavailable", - monitor_id = monitor.id, - capture_rect_points = ?capture_rect_points, - capture_rect_pixels = ?capture_rect_pixels, - "Skipped starting scroll capture because the selected frozen region could not be read." - ); - - self.state - .set_error(String::from("Scroll capture could not read the selected region.")); - self.request_redraw_all(); - - return None; - }; - - Some((monitor, capture_rect_points, capture_rect_pixels, base_frame)) - } - - #[cfg(target_os = "macos")] - fn build_scroll_capture_state( - &self, - monitor: MonitorRect, - capture_rect_points: RectPoints, - capture_rect_pixels: RectPoints, - base_frame: RgbaImage, - ) -> Result { - let use_worker_sampling = self.should_use_scroll_capture_worker_sampling(); - let trace_recorder = ScrollCaptureTraceRecorder::from_env( - monitor, - capture_rect_pixels, - SCROLL_CAPTURE_PREVIEW_WIDTH_PX, - &base_frame, - ); - let preview_latest_frame = Some(base_frame.clone()); - let session = ScrollSession::new(base_frame, SCROLL_CAPTURE_PREVIEW_WIDTH_PX)?; - let preview_committed_image = Some(session.preview_image().clone()); - let preview_display_image = preview_committed_image.clone(); - - Ok(ScrollCaptureState { - active: true, - paused: false, - monitor: Some(monitor), - #[cfg(target_os = "macos")] - capture_rect_points: Some(capture_rect_points), - capture_rect_pixels: Some(capture_rect_pixels), - input_direction: None, - input_direction_at: None, - input_gesture_active: false, - downward_motion_rows_pending: 0.0, - #[cfg(target_os = "macos")] - overlay_mouse_passthrough_active: false, - #[cfg(target_os = "macos")] - overlay_mouse_passthrough_persistent: false, - #[cfg(target_os = "macos")] - overlay_mouse_passthrough_until: None, - #[cfg(target_os = "macos")] - external_scroll_input_drain_reader: self - .scroll_capture - .external_scroll_input_drain_reader - .clone(), - last_external_scroll_input_seq: 0, - #[cfg(target_os = "macos")] - pixel_delta_residual: MacOSScrollPixelResidual::default(), - #[cfg(target_os = "macos")] - live_stream: (!use_worker_sampling).then(|| { - MacLiveFrameStream::with_scroll_capture_region_and_waker( - self.config.self_capture_exception_window_ids.clone(), - capture_rect_points, - capture_rect_pixels, - self.scroll_frame_waker.clone(), - ) - }), - #[cfg(target_os = "macos")] - live_stream_backlog: VecDeque::new(), - last_stream_frame_seq: 0, - #[cfg(target_os = "macos")] - last_stream_frame_fingerprint: None, - #[cfg(target_os = "macos")] - consecutive_identical_stream_frames: 0, - #[cfg(target_os = "macos")] - last_consumed_stream_frame_captured_at: None, - #[cfg(target_os = "macos")] - last_stream_event_at: None, - #[cfg(target_os = "macos")] - last_stream_poll_at: None, - #[cfg(target_os = "macos")] - last_duplicate_stream_refresh_at: None, - pending_post_stall_burst_after_seq: None, - #[cfg(target_os = "macos")] - live_stream_stale_grace: None, - next_sample_at: Some(Instant::now() + SCROLL_CAPTURE_SAMPLE_INTERVAL), - next_request_id: 0, - inflight_request_id: None, - #[cfg(target_os = "macos")] - inflight_request_observation: None, - #[cfg(all(test, target_os = "macos"))] - force_worker_sampling_in_tests: false, - session: Some(session), - preview_committed_image, - preview_latest_frame, - preview_display_image, - retained_overlay_preview_image: None, - retained_overlay_preview_motion_rows_hint: None, - last_overlay_preview_motion_rows_hint: None, - last_overlay_preview_provisional_motion_rows_hint: None, - last_overlay_preview_existing_candidate_height: None, - last_overlay_preview_existing_candidate_motion_rows_hint: None, - last_overlay_preview_ledger_candidate_height: None, - last_overlay_preview_ledger_candidate_motion_rows_hint: None, - last_overlay_preview_retained_candidate_height: None, - last_overlay_preview_retained_candidate_motion_rows_hint: None, - last_overlay_preview_retained_hint_matches_motion_rows: false, - last_overlay_preview_fresh_latest_frame_can_drive: false, - last_overlay_preview_strong_unresolved_registration: false, - last_overlay_preview_latest_frame_present: false, - last_overlay_preview_used_provisional: false, - trace_recorder, - }) - } - - fn sync_frozen_toolbar_state(&mut self) { - self.toolbar_state.auto_center_available = self.frozen_auto_center_available(); - self.toolbar_state.scroll_capture_active = self.scroll_capture.active; - // Keep drag-region toolbar geometry stable across the authoritative frozen-capture handoff: - // show the Scroll slot immediately, but keep it disabled until final_capture_ready flips. - self.toolbar_state.scroll_capture_available = self.toolbar_scroll_capture_slot_available(); - self.toolbar_state.final_capture_ready = self.frozen_final_capture_ready(); - } - - fn start_scroll_capture(&mut self) -> OverlayControl { - if self.scroll_capture.active { - tracing::info!( - op = "scroll_capture.start_rejected", - reason = "already_active", - "Skipped starting scroll capture because a session is already active." - ); - - return OverlayControl::Continue; - } - - #[cfg(not(target_os = "macos"))] - { - tracing::info!( - op = "scroll_capture.start_rejected", - reason = "unsupported_platform", - "Skipped starting scroll capture because the current platform is unsupported." - ); - - OverlayControl::Continue - } - #[cfg(target_os = "macos")] - { - let Some((monitor, capture_rect_points, capture_rect_pixels, base_frame)) = - self.try_prepare_scroll_capture_start() - else { - return OverlayControl::Continue; - }; - - if let Some(guard) = self.scroll_capture_start_guard.clone() { - match guard() { - Ok(true) => {}, - Ok(false) => return OverlayControl::Continue, - Err(err) => { - self.state.set_error(format!("{err:#}")); - self.request_redraw_all(); - - return OverlayControl::Continue; - }, - } - } - if let Some(hook) = self.scroll_capture_starting_hook.clone() - && let Err(err) = hook() - { - self.state.set_error(format!("{err:#}")); - self.request_redraw_all(); - - return OverlayControl::Continue; - } - - let base_frame_dimensions = base_frame.dimensions(); - - self.scroll_capture = match self.build_scroll_capture_state( - monitor, - capture_rect_points, - capture_rect_pixels, - base_frame, - ) { - Ok(scroll_capture) => scroll_capture, - Err(err) => { - self.state.set_error(format!("{err:#}")); - self.request_redraw_all(); - - return OverlayControl::Continue; - }, - }; - - if let Some(hook) = self.scroll_capture_started_hook.clone() { - hook(); - } - if let Some(trace_recorder) = self.scroll_capture.trace_recorder.as_ref() { - tracing::info!( - op = "scroll_capture.trace_recording_enabled", - manifest_path = %trace_recorder.manifest_path().display(), - "Enabled scroll-capture live trace recording for this session." - ); - } - - tracing::info!( - op = "scroll_capture.start", - frozen_capture_source = ?self.frozen_capture_source, - monitor_id = monitor.id, - monitor_origin = ?monitor.origin, - monitor_size_points = ?(monitor.width, monitor.height), - monitor_scale_factor = monitor.scale_factor(), - capture_rect_points = ?capture_rect_points, - capture_rect_pixels = ?capture_rect_pixels, - base_frame_px = ?base_frame_dimensions, - "Entered scroll-capture mode." - ); - - self.sync_frozen_toolbar_state(); - self.refresh_scroll_preview_committed_image(); - self.refresh_scroll_preview_display_image(); - self.sync_scroll_preview_segments(); - self.position_scroll_preview_window(monitor); - self.update_scroll_toolbar_default_position(monitor); - self.set_scroll_overlay_mouse_passthrough_persistent(true, "scroll_capture_started"); - self.focus_scroll_keyboard_window(); - - if let Some(preview) = self.scroll_preview_window.as_ref() { - preview.window.set_visible(true); - preview.window.request_redraw(); - } - if let (Some(monitor), Some(live_stream)) = - (self.scroll_capture.monitor, self.scroll_capture.live_stream.as_ref()) - { - live_stream.prime_monitor_nonblocking(monitor); - } - - self.request_redraw_for_monitor(monitor); - - OverlayControl::Continue - } - } - - fn toggle_scroll_capture_paused(&mut self) { - if !self.scroll_capture.active { - return; - } - - self.scroll_capture.paused = !self.scroll_capture.paused; - - #[cfg(target_os = "macos")] - if self.scroll_capture.paused { - self.set_scroll_overlay_mouse_passthrough_persistent(false, "paused"); - } - if !self.scroll_capture.paused { - #[cfg(target_os = "macos")] - { - self.set_scroll_overlay_mouse_passthrough_persistent(true, "resumed"); - - if let (Some(monitor), Some(live_stream)) = - (self.scroll_capture.monitor, self.scroll_capture.live_stream.as_ref()) - { - live_stream.prime_monitor_nonblocking(monitor); - } - } - #[cfg(not(target_os = "macos"))] - { - self.scroll_capture.next_sample_at = - Some(Instant::now() + SCROLL_CAPTURE_SAMPLE_INTERVAL); - } - } - - self.request_redraw_scroll_preview_window(); - } - - fn prepare_active_scroll_capture_output(&mut self) { - if !self.scroll_capture.active { - return; - } - - self.maybe_tick_scroll_capture(); - self.refresh_scroll_preview_committed_image(); - self.refresh_scroll_preview_display_image(); - self.sync_scroll_preview_segments(); - } - - fn undo_scroll_capture_append(&mut self) { - if !self.scroll_capture.active { - return; - } - - let Some(session) = self.scroll_capture.session.as_mut() else { - return; - }; - - if !session.undo_last_append() { - return; - } - - self.refresh_scroll_preview_committed_image(); - self.clear_scroll_capture_inflight_request(); - - #[cfg(target_os = "macos")] - { - if let (Some(monitor), Some(live_stream)) = - (self.scroll_capture.monitor, self.scroll_capture.live_stream.as_ref()) - { - live_stream.prime_monitor_nonblocking(monitor); - } - } - #[cfg(not(target_os = "macos"))] - { - self.scroll_capture.next_sample_at = - Some(Instant::now() + SCROLL_CAPTURE_SAMPLE_INTERVAL); - } - - self.refresh_scroll_preview_display_image(); - self.sync_scroll_preview_segments(); - } - - fn begin_png_action(&mut self, action: PngAction) { - if !matches!(self.state.mode, OverlayMode::Frozen) { - return; - } - if !self.frozen_final_capture_ready() { - self.state.set_error("Preparing capture..."); - self.request_redraw_all(); - - return; - } - - self.prepare_active_scroll_capture_output(); - - let image = if self.scroll_capture.active { - self.current_scroll_preview_render_image() - } else { - self.current_export_image() - }; - let Some(export_image) = image else { - return; - }; - - #[cfg(target_os = "macos")] - self.cancel_ocr_output_intent(); - - self.pending_png_action = Some(action); - - match action { - PngAction::Copy => self.state.set_error("Copying..."), - PngAction::Save => self.state.set_error("Saving..."), - } - - self.pending_encode_png = Some(export_image); - - self.request_redraw_all(); - } - - #[cfg(target_os = "macos")] - fn begin_ocr_action(&mut self) { - if !matches!(self.state.mode, OverlayMode::Frozen) { - return; - } - if !self.frozen_final_capture_ready() { - self.state.set_error("Preparing capture..."); - self.request_redraw_all(); - - return; - } - - self.prepare_active_scroll_capture_output(); - - let Some(export_image) = self.current_export_image() else { - return; - }; - let request_id = self.next_ocr_request_id(); - - self.pending_png_action = None; - self.pending_encode_png = None; - self.active_ocr_request_id = Some(request_id); - - self.state.set_error("Recognizing text..."); - - self.pending_recognize_text = - Some(PendingRecognizeTextRequest { request_id, image: export_image }); - - self.request_redraw_all(); - } - - fn handle_redraw_requested(&mut self, window_id: WindowId) -> OverlayControl { - let now = Instant::now(); - - self.event_loop_last_progress_window_id = Some(window_id); - self.event_loop_last_progress_monitor_id = - self.windows.get(&window_id).map(|window| window.monitor.id); - - self.maybe_log_event_loop_stall(now); - self.mark_progress(OverlayEventLoopPhase::RedrawDispatch); - - let control = self.drain_worker_responses(); - - if !matches!(control, OverlayControl::Continue) { - return control; - } - if self.hud_window.as_ref().is_some_and(|hud_window| hud_window.window.id() == window_id) { - return self.handle_hud_redraw_requested(); - } - if self - .loupe_window - .as_ref() - .is_some_and(|loupe_window| loupe_window.window.id() == window_id) - { - return self.handle_loupe_redraw_requested(); - } - if self - .scroll_preview_window - .as_ref() - .is_some_and(|preview_window| preview_window.window.id() == window_id) - { - return self.handle_scroll_preview_redraw_requested(); - } - - self.handle_overlay_window_redraw(window_id) - } - - fn stabilized_live_hud_inner_size( - mode: OverlayMode, - previous: Option<(u32, u32)>, - desired: (u32, u32), - ) -> (u32, u32) { - if !matches!(mode, OverlayMode::Live) { - return desired; - } - - let Some(previous) = previous else { - return desired; - }; - - (previous.0.max(desired.0), desired.1) - } - - fn hud_window_content_rect( - _mode: OverlayMode, - _live_loupe_in_hud: bool, - hud_pill: HudPillGeometry, - _loupe_tile: Option, - ) -> Rect { - hud_pill.rect - } - - fn maybe_skip_hud_redraw(&mut self) -> Option { - if self.scroll_capture.active { - if let Some(hud_window) = self.hud_window.as_ref() - && self.hud_window_visible - { - hud_window.window.set_visible(false); - } - - self.hud_window_visible = false; - self.last_present_at = Instant::now(); - - return Some(OverlayControl::Continue); - } - if self.capture_windows_hidden { - #[cfg(not(target_os = "macos"))] - { - if let Some(hud_window) = self.hud_window.as_ref() - && self.hud_window_visible - { - hud_window.window.set_visible(false); - } - - self.hud_window_visible = false; - self.last_present_at = Instant::now(); - - #[cfg(not(target_os = "macos"))] - return Some(OverlayControl::Continue); - } - } - - None - } - - fn draw_hud_window_frame(&mut self, live_loupe_in_hud: bool) -> Result { - let Some(gpu) = self.gpu.as_ref() else { - return Err(eyre::eyre!("Missing GPU context")); - }; - let monitor = - self.monitor_for_mode().or_else(|| self.windows.values().next().map(|w| w.monitor)); - let mut summary = HudRedrawSummary::default(); - - if let (Some(monitor), Some(hud_window)) = (monitor, self.hud_window.as_mut()) { - summary.redraw_window_id = Some(hud_window.window.id()); - summary.redraw_monitor_id = Some(monitor.id); - - if !self.hud_window_visible { - hud_window.window.set_visible(true); - - self.hud_window_visible = true; - } - - let draw_started_at = Instant::now(); - - hud_window.renderer.draw( - gpu, - &self.state, - monitor, - true, - Some(Pos2::new(-14.0, -14.0)), - !live_loupe_in_hud, - HudAnchor::Cursor, - self.config.toolbar_placement, - self.config.show_alt_hint_keycap, - self.config.show_hud_blur, - self.config.hud_opaque, - self.config.hud_opacity, - self.config.hud_fog_amount, - self.config.hud_milk_amount, - self.config.hud_tint_hue, - self.config.theme_mode, - self.config.selection_flow_enabled, - self.config.selection_flow_stroke_width_px, - true, - false, - self.frozen_capture_source == FrozenCaptureSource::FullscreenFallback, - None, - None, - None, - )?; - - summary.renderer_draw_elapsed = Some(draw_started_at.elapsed()); - - if let Some(hud_pill) = hud_window.renderer.hud_pill { - let height_points = hud_pill.rect.height(); - let height_changed = self - .toolbar_state - .pill_height_points - .is_none_or(|prev| (prev - height_points).abs() > 0.1); - - self.toolbar_state.pill_height_points = Some(height_points); - - if height_changed - && matches!(self.state.mode, OverlayMode::Frozen) - && self.toolbar_state.visible - && self.state.monitor == Some(monitor) - { - self.toolbar_state.needs_redraw = true; - summary.request_toolbar_redraw = Some(monitor); - } - - let combined_rect = Self::hud_window_content_rect( - self.state.mode, - live_loupe_in_hud, - hud_pill, - hud_window.renderer.loupe_tile, - ); - let desired_w = combined_rect.width().ceil().max(1.0) as u32; - let desired_h = combined_rect.height().ceil().max(1.0) as u32; - let desired = Self::stabilized_live_hud_inner_size( - self.state.mode, - self.hud_inner_size_points, - (desired_w, desired_h), - ); - - if self.hud_inner_size_points != Some(desired) { - self.hud_inner_size_points = Some(desired); - summary.resize_target = Some(desired); - - let request_inner_size_started_at = Instant::now(); - let _ = hud_window.window.request_inner_size(LogicalSize::new( - f64::from(desired.0), - f64::from(desired.1), - )); - - summary.request_inner_size_elapsed = - Some(request_inner_size_started_at.elapsed()); - - if let Some(cursor) = self.state.cursor { - let position_update_started_at = Instant::now(); - - self.update_hud_window_position(monitor, cursor); - - summary.position_update_elapsed = - Some(position_update_started_at.elapsed()); - } - } - } - } - - Ok(summary) - } - - fn should_try_pending_hud_window_move_on_redraw(&self, summary: &HudRedrawSummary) -> bool { - summary.position_update_elapsed.is_some() - || (matches!(self.state.mode, OverlayMode::Live) - && self.pending_hud_outer_pos.is_some()) - } - - fn should_try_pending_follow_window_move_on_live_cursor_update(&self) -> bool { - matches!(self.state.mode, OverlayMode::Live) - && (self.pending_hud_outer_pos.is_some() || self.pending_loupe_outer_pos.is_some()) - } - - fn log_hud_redraw_metrics(&mut self, redraw_elapsed: Duration, summary: &HudRedrawSummary) { - tracing::trace!( - op = "overlay.hud_redraw_phase_timing", - window_id = ?summary.redraw_window_id, - monitor_id = ?summary.redraw_monitor_id, - total_us = redraw_elapsed.as_micros(), - renderer_draw_us = summary.renderer_draw_elapsed.map_or(0, |elapsed| elapsed.as_micros()), - request_inner_size_us = summary - .request_inner_size_elapsed - .map_or(0, |elapsed| elapsed.as_micros()), - position_update_us = summary - .position_update_elapsed - .map_or(0, |elapsed| elapsed.as_micros()), - toolbar_followup = summary.request_toolbar_redraw.is_some(), - resize_target = ?summary.resize_target, - "HUD redraw phase timing." - ); - - if let Some(elapsed) = summary.renderer_draw_elapsed { - self.slow_op_logger.warn_if_redraw_substep_slow( - "overlay.hud_redraw.renderer_draw", - elapsed, - redraw_elapsed, - || { - format!( - "window_id={:?} monitor_id={:?} toolbar_followup={}", - summary.redraw_window_id, - summary.redraw_monitor_id, - summary.request_toolbar_redraw.is_some() - ) - }, - ); - } - if let Some(elapsed) = summary.request_inner_size_elapsed { - self.slow_op_logger.warn_if_redraw_substep_slow( - "overlay.hud_redraw.request_inner_size", - elapsed, - redraw_elapsed, - || { - format!( - "window_id={:?} monitor_id={:?} desired_size={:?}", - summary.redraw_window_id, summary.redraw_monitor_id, summary.resize_target - ) - }, - ); - } - if let Some(elapsed) = summary.position_update_elapsed { - self.slow_op_logger.warn_if_redraw_substep_slow( - "overlay.hud_redraw.position_update", - elapsed, - redraw_elapsed, - || { - format!( - "window_id={:?} monitor_id={:?} pending_outer_pos={:?}", - summary.redraw_window_id, - summary.redraw_monitor_id, - self.pending_hud_outer_pos - ) - }, - ); - } - - self.slow_op_logger.warn_if_slow( - "overlay.hud_redraw.total", - redraw_elapsed, - LIVE_PRESENT_INTERVAL_MIN, - || { - format!( - "window_id={:?} monitor_id={:?} toolbar_followup={}", - summary.redraw_window_id, - summary.redraw_monitor_id, - summary.request_toolbar_redraw.is_some() - ) - }, - ); - } - - fn handle_hud_redraw_requested(&mut self) -> OverlayControl { - let redraw_started_at = Instant::now(); - let live_loupe_in_hud = self.live_loupe_renders_in_hud_window(); - - self.event_loop_last_progress_window_id = - self.hud_window.as_ref().map(|hud_window| hud_window.window.id()); - self.event_loop_last_progress_monitor_id = - self.monitor_for_mode().map(|monitor| monitor.id); - - self.maybe_log_event_loop_stall(Instant::now()); - self.mark_progress(OverlayEventLoopPhase::HudRedraw); - - if let Some(control) = self.maybe_skip_hud_redraw() { - return control; - } - - let summary = match self.draw_hud_window_frame(live_loupe_in_hud) { - Ok(summary) => summary, - Err(err) => return self.exit(OverlayExit::Error(format!("{err:#}"))), - }; - - if summary.position_update_elapsed.is_some() { - self.force_apply_pending_hud_window_move(); - } else if self.should_try_pending_hud_window_move_on_redraw(&summary) { - self.maybe_apply_pending_hud_window_move(Instant::now()); - } - - if let Some(monitor) = summary.request_toolbar_redraw { - self.request_redraw_for_monitor(monitor); - } - - let redraw_elapsed = redraw_started_at.elapsed(); - - self.log_hud_redraw_metrics(redraw_elapsed, &summary); - - self.last_present_at = Instant::now(); - - OverlayControl::Continue - } - - fn hide_loupe_window(&mut self) { - if let Some(loupe_window) = self.loupe_window.as_ref() { - loupe_window.window.set_visible(false); - } - - self.loupe_window_visible = false; - - self.reset_loupe_window_warmup_redraws(); - - self.last_present_at = Instant::now(); - } - - fn should_skip_loupe_redraw(&self) -> bool { - self.scroll_capture.active - || self.capture_windows_hidden - || !self.state.alt_held - || (matches!(self.state.mode, OverlayMode::Live) && self.live_loupe_uses_hud_window()) - } - - fn current_loupe_draw_target(&self) -> Option<(MonitorRect, GlobalPoint)> { - let monitor = - self.monitor_for_mode().or_else(|| self.windows.values().next().map(|w| w.monitor))?; - let cursor = self.state.cursor?; - - Some((monitor, cursor)) - } - - fn draw_loupe_window_frame( - &mut self, - monitor: MonitorRect, - _cursor: GlobalPoint, - ) -> Result { - let redraw_started_at = Instant::now(); - let Some(loupe_window) = self.loupe_window.as_mut() else { - return Ok(false); - }; - let loupe_window_id = loupe_window.window.id(); - - #[cfg(not(target_os = "macos"))] - loupe_window.window.set_visible(true); - - let Some(gpu) = self.gpu.as_ref() else { - return Err(eyre::eyre!("Missing GPU context")); - }; - let tile_draw_started_at = Instant::now(); - - loupe_window.renderer.draw_loupe_tile_window( - gpu, - &self.state, - monitor, - self.config.show_hud_blur, - self.config.hud_opaque, - self.config.hud_opacity, - self.config.hud_fog_amount, - self.config.hud_milk_amount, - self.config.hud_tint_hue, - self.config.theme_mode, - )?; - - let tile_draw_elapsed = tile_draw_started_at.elapsed(); - let mut needs_reposition = false; - let mut request_inner_size_elapsed = None; - let mut resize_target = None; - - if let Some(tile_rect) = loupe_window.renderer.loupe_tile { - let desired_w = tile_rect.max.x.ceil().max(1.0) as u32; - let desired_h = tile_rect.max.y.ceil().max(1.0) as u32; - let desired = (desired_w, desired_h); - - if self.loupe_inner_size_points != Some(desired) { - self.loupe_inner_size_points = Some(desired); - resize_target = Some(desired); - - let request_inner_size_started_at = Instant::now(); - let _ = loupe_window.window.request_inner_size(LogicalSize::new( - f64::from(desired_w), - f64::from(desired_h), - )); - - request_inner_size_elapsed = Some(request_inner_size_started_at.elapsed()); - needs_reposition = true; - } - } - - let redraw_elapsed = redraw_started_at.elapsed(); - - self.slow_op_logger.warn_if_redraw_substep_slow( - "overlay.loupe_redraw.tile_draw", - tile_draw_elapsed, - redraw_elapsed, - || format!("window_id={loupe_window_id:?} monitor_id={}", monitor.id), - ); - - if let Some(elapsed) = request_inner_size_elapsed { - self.slow_op_logger.warn_if_redraw_substep_slow( - "overlay.loupe_redraw.request_inner_size", - elapsed, - redraw_elapsed, - || { - format!( - "window_id={loupe_window_id:?} monitor_id={} desired_size={resize_target:?}", - monitor.id - ) - }, - ); - } - - Ok(needs_reposition) - } - - fn handle_loupe_redraw_requested(&mut self) -> OverlayControl { - let redraw_started_at = Instant::now(); - - self.event_loop_last_progress_window_id = - self.loupe_window.as_ref().map(|loupe_window| loupe_window.window.id()); - self.event_loop_last_progress_monitor_id = - self.monitor_for_mode().map(|monitor| monitor.id); - - self.maybe_log_event_loop_stall(Instant::now()); - self.mark_progress(OverlayEventLoopPhase::LoupeRedraw); - - if self.gpu.is_none() { - return self.exit(OverlayExit::Error(String::from("Missing GPU context"))); - }; - if self.should_skip_loupe_redraw() { - self.hide_loupe_window(); - - return OverlayControl::Continue; - } - - let Some((monitor, cursor)) = self.current_loupe_draw_target() else { - self.last_present_at = Instant::now(); - - return OverlayControl::Continue; - }; - let redraw_window_id = - self.loupe_window.as_ref().map(|loupe_window| loupe_window.window.id()); - let was_visible = self.loupe_window_visible; - let needs_reposition = match self.draw_loupe_window_frame(monitor, cursor) { - Ok(needs_reposition) => needs_reposition, - Err(err) => return self.exit(OverlayExit::Error(format!("{err:#}"))), - }; - let mut reposition_elapsed = None; - - if needs_reposition { - let reposition_started_at = Instant::now(); - let _ = self.update_loupe_window_position(monitor); - - self.force_apply_pending_loupe_window_move(); - - reposition_elapsed = Some(reposition_started_at.elapsed()); - } - - if let Some(loupe_window) = self.loupe_window.as_ref() - && !was_visible - { - loupe_window.window.set_visible(true); - } - - self.loupe_window_visible = true; - - if !was_visible { - self.maybe_start_loupe_window_warmup_redraw(); - } - - let redraw_elapsed = redraw_started_at.elapsed(); - - if let Some(elapsed) = reposition_elapsed { - self.slow_op_logger.warn_if_redraw_substep_slow( - "overlay.loupe_redraw.reposition", - elapsed, - redraw_elapsed, - || { - format!( - "window_id={redraw_window_id:?} monitor_id={} pending_outer_pos={:?}", - monitor.id, self.pending_loupe_outer_pos - ) - }, - ); - } - - tracing::trace!( - op = "overlay.loupe_redraw_phase_timing", - window_id = ?redraw_window_id, - monitor_id = monitor.id, - total_us = redraw_elapsed.as_micros(), - reposition_us = reposition_elapsed.map_or(0, |elapsed| elapsed.as_micros()), - was_visible, - needs_reposition, - "Loupe redraw phase timing." - ); - - self.slow_op_logger.warn_if_slow( - "overlay.loupe_redraw.total", - redraw_elapsed, - LIVE_PRESENT_INTERVAL_MIN, - || { - format!( - "window_id={redraw_window_id:?} monitor_id={} was_visible={} needs_reposition={}", - monitor.id, was_visible, needs_reposition - ) - }, - ); - - self.last_present_at = Instant::now(); - - OverlayControl::Continue - } - - fn handle_scroll_preview_window_event(&mut self, event: &WindowEvent) -> OverlayControl { - let Some(preview_window) = self.scroll_preview_window.as_mut() else { - return OverlayControl::Continue; - }; - - preview_window.handle_window_event(event); - - OverlayControl::Continue - } - - fn handle_scroll_preview_redraw_requested(&mut self) -> OverlayControl { - let Some(preview_window) = self.scroll_preview_window.as_mut() else { - return OverlayControl::Continue; - }; - - if !self.scroll_capture.active { - preview_window.window.set_visible(false); - - return OverlayControl::Continue; - } - - let theme = - hud_helpers::effective_hud_theme(self.config.theme_mode, preview_window.window.theme()); - let view = ScrollPreviewView { paused: self.scroll_capture.paused, theme }; - let Some(gpu) = self.gpu.as_ref() else { - return self.exit(OverlayExit::Error(String::from("Missing GPU context"))); - }; - - match preview_window.draw(gpu, theme, view) { - Ok(()) => OverlayControl::Continue, - Err(err) => self.exit(OverlayExit::Error(format!("{err:#}"))), - } - } - - #[cfg(target_os = "macos")] - fn position_scroll_preview_window(&self, monitor: MonitorRect) { - let Some(preview_window) = self.scroll_preview_window.as_ref() else { - return; - }; - let preview_rect = self.scroll_preview_local_rect(monitor); - let current_size = preview_window.window.inner_size(); - let desired_width = preview_rect.width().round().max(1.0) as u32; - let desired_height = preview_rect.height().round().max(1.0) as u32; - - if current_size.width != desired_width || current_size.height != desired_height { - let _ = preview_window.window.request_inner_size(LogicalSize::new( - f64::from(desired_width), - f64::from(desired_height), - )); - } - - preview_window.window.set_outer_position(LogicalPosition::new( - f64::from(monitor.origin.x) + f64::from(preview_rect.min.x), - f64::from(monitor.origin.y) + f64::from(preview_rect.min.y), - )); - } - - fn scroll_preview_local_rect(&self, monitor: MonitorRect) -> Rect { - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let gap = SCROLL_PREVIEW_WINDOW_MARGIN_POINTS as f32; - let preview_width = SCROLL_PREVIEW_WINDOW_WIDTH_POINTS as f32; - - if let Some(capture_rect) = self.state.frozen_capture_rect { - let capture_rect = Rect::from_min_size( - Pos2::new(capture_rect.x as f32, capture_rect.y as f32), - Vec2::new(capture_rect.width as f32, capture_rect.height as f32), - ) - .intersect(screen_rect); - let preview_size = self - .scroll_preview_display_size_points() - .unwrap_or(Vec2::new(preview_width, capture_rect.height().max(1.0))); - let preview_width = preview_size.x.max(preview_width); - let max_preview_height = (screen_rect.max.y - capture_rect.min.y - gap).max(1.0); - let preview_height = preview_size.y.min(max_preview_height).max(1.0); - let right_x = capture_rect.max.x + gap; - let left_x = capture_rect.min.x - gap - preview_width; - let x = if right_x + preview_width <= screen_rect.max.x { - right_x - } else if left_x >= screen_rect.min.x { - left_x - } else { - (screen_rect.max.x - preview_width - gap).max(screen_rect.min.x + gap) - }; - - return Rect::from_min_size( - Pos2::new(x, capture_rect.min.y), - Vec2::new(preview_width, preview_height), - ); - } - - let preview_size = if let Some(preview_window) = self.scroll_preview_window.as_ref() { - let scale = preview_window.window.scale_factor().max(1.0) as f32; - let size = preview_window.window.inner_size(); - - Vec2::new( - ((size.width as f32) / scale).max(preview_width), - ((size.height as f32) / scale).max(SCROLL_PREVIEW_WINDOW_HEIGHT_POINTS as f32), - ) - } else { - Vec2::new(preview_width, SCROLL_PREVIEW_WINDOW_HEIGHT_POINTS as f32) - }; - let min_x = screen_rect.min.x + gap; - let max_x = (screen_rect.max.x - preview_size.x - gap).max(min_x); - let min_y = screen_rect.min.y + gap; - let max_y = (screen_rect.max.y - preview_size.y - gap).max(min_y); - let y = min_y.min(max_y); - let pos = Pos2::new(max_x, y); - - Rect::from_min_size(pos, preview_size) - } - - #[cfg(target_os = "macos")] - fn set_scroll_overlay_mouse_passthrough(&self, passthrough: bool) { - for overlay_window in self.windows.values() { - let _ = overlay_window.window.set_cursor_hittest(!passthrough); - } - } - - #[cfg(target_os = "macos")] - fn set_scroll_overlay_mouse_passthrough_state( - &mut self, - now: Instant, - passthrough: bool, - reason: &'static str, - ) { - if self.scroll_capture.overlay_mouse_passthrough_active == passthrough { - return; - } - - self.set_scroll_overlay_mouse_passthrough(passthrough); - - self.scroll_capture.overlay_mouse_passthrough_active = passthrough; - - tracing::info!( - op = if passthrough { - "scroll_capture.mouse_passthrough_armed" - } else { - "scroll_capture.mouse_passthrough_disarmed" - }, - reason, - passthrough, - deadline_in_ms = self.scroll_capture.overlay_mouse_passthrough_until.map(|deadline| { - u64::try_from(deadline.saturating_duration_since(now).as_millis()) - .unwrap_or(u64::MAX) - }), - "Updated scroll-capture mouse passthrough state." - ); - } - - #[cfg(target_os = "macos")] - fn set_scroll_overlay_mouse_passthrough_persistent( - &mut self, - passthrough: bool, - reason: &'static str, - ) { - let now = Instant::now(); - - self.scroll_capture.overlay_mouse_passthrough_persistent = passthrough; - self.scroll_capture.overlay_mouse_passthrough_until = None; - - self.set_scroll_overlay_mouse_passthrough_state(now, passthrough, reason); - } - - #[cfg(target_os = "macos")] - fn arm_scroll_overlay_mouse_passthrough_window(&mut self, now: Instant, reason: &'static str) { - if self.scroll_capture.overlay_mouse_passthrough_persistent { - return; - } - - let deadline = now + SCROLL_CAPTURE_MOUSE_PASSTHROUGH_IDLE_GRACE; - let was_active = self.scroll_capture.overlay_mouse_passthrough_active; - - self.scroll_capture.overlay_mouse_passthrough_until = Some(deadline); - - self.set_scroll_overlay_mouse_passthrough_state(now, true, reason); - - if was_active { - tracing::info!( - op = "scroll_capture.mouse_passthrough_extended", - reason, - deadline_in_ms = u64::try_from(deadline.saturating_duration_since(now).as_millis()) - .unwrap_or(u64::MAX), - "Extended scroll-capture mouse passthrough window." - ); - } - } - - #[cfg(target_os = "macos")] - fn disarm_scroll_overlay_mouse_passthrough(&mut self, now: Instant, reason: &'static str) { - self.scroll_capture.overlay_mouse_passthrough_persistent = false; - self.scroll_capture.overlay_mouse_passthrough_until = None; - - self.set_scroll_overlay_mouse_passthrough_state(now, false, reason); - } - - #[cfg(target_os = "macos")] - fn sync_scroll_overlay_mouse_passthrough_window(&mut self, now: Instant) { - if self.scroll_capture.overlay_mouse_passthrough_persistent { - return; - } - if !self.scroll_capture.overlay_mouse_passthrough_active { - return; - } - - let Some(deadline) = self.scroll_capture.overlay_mouse_passthrough_until else { - self.set_scroll_overlay_mouse_passthrough_state(now, false, "missing_deadline"); - - return; - }; - - if deadline <= now { - self.disarm_scroll_overlay_mouse_passthrough(now, "idle_timeout"); - } - } - - #[cfg(target_os = "macos")] - fn focus_frozen_keyboard_window(&self) { - macos_activate_app(); - - let target_window = if let Some(toolbar_window) = self.toolbar_window.as_ref() { - Some(toolbar_window.window.as_ref()) - } else { - self.windows - .values() - .find(|overlay_window| Some(overlay_window.monitor) == self.state.monitor) - .map(|overlay_window| overlay_window.window.as_ref()) - }; - let Some(target_window) = target_window else { - tracing::info!( - op = "scroll_capture.frozen_focus_requested", - target = "missing_window", - state_mode = ?self.state.mode, - toolbar_window_present = self.toolbar_window.is_some(), - monitor_id = ?self.state.monitor.map(|monitor| monitor.id), - "Requested frozen keyboard focus, but no target window was available." - ); - - return; - }; - - tracing::info!( - op = "scroll_capture.frozen_focus_requested", - target = if self.toolbar_window.is_some() { "toolbar_window" } else { "overlay_window" }, - state_mode = ?self.state.mode, - toolbar_window_visible = self.toolbar_window_visible, - monitor_id = ?self.state.monitor.map(|monitor| monitor.id), - "Requested frozen keyboard focus." - ); - - macos_make_window_key(target_window); - } - - #[cfg(target_os = "macos")] - fn focus_live_capture_window(&self) { - macos_activate_app(); - - let target_window = self - .active_cursor_monitor() - .and_then(|monitor| { - self.windows.values().find(|overlay_window| overlay_window.monitor == monitor) - }) - .or_else(|| self.windows.values().next()) - .map(|overlay_window| overlay_window.window.as_ref()); - let Some(target_window) = target_window else { - tracing::info!( - op = "overlay.live_focus_requested", - target = "missing_window", - window_count = self.windows.len(), - "Requested live capture focus, but no overlay window was available." - ); - - return; - }; - - tracing::info!( - op = "overlay.live_focus_requested", - target = "overlay_window", - window_count = self.windows.len(), - cursor_monitor_id = ?self.active_cursor_monitor().map(|monitor| monitor.id), - "Requested live capture focus." - ); - - macos_make_window_key(target_window); - } - - #[cfg(target_os = "macos")] - fn focus_scroll_keyboard_window(&self) { - macos_activate_app(); - - let target_window = if let Some(toolbar_window) = self.toolbar_window.as_ref() { - Some(toolbar_window.window.as_ref()) - } else if let Some(preview_window) = self.scroll_preview_window.as_ref() { - Some(preview_window.window.as_ref()) - } else { - self.windows - .values() - .find(|overlay_window| Some(overlay_window.monitor) == self.scroll_capture.monitor) - .map(|overlay_window| overlay_window.window.as_ref()) - }; - let Some(target_window) = target_window else { - return; - }; - - macos_make_window_key(target_window); - } - - fn update_scroll_toolbar_default_position(&mut self, monitor: MonitorRect) { - if !self.scroll_capture.active || self.toolbar_state.dragging { - return; - } - - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let preview_rect = self.scroll_preview_local_rect(monitor); - let toolbar_size = WindowRenderer::frozen_toolbar_size(&self.toolbar_state); - let toolbar_pos = WindowRenderer::frozen_toolbar_default_pos( - screen_rect, - preview_rect, - toolbar_size, - self.config.toolbar_placement, - ); - - self.toolbar_state.default_slot_position = Some(toolbar_pos); - self.toolbar_state.floating_position = Some(toolbar_pos); - - let _ = self.update_toolbar_outer_position(monitor, toolbar_pos); - } - - fn maybe_recenter_frozen_toolbar_default_slot(&mut self, monitor: MonitorRect) -> bool { - if !matches!(self.state.mode, OverlayMode::Frozen) || self.state.monitor != Some(monitor) { - return false; - } - if self.scroll_capture.active || self.toolbar_state.dragging { - return false; - } - - let Some(capture_rect) = self.state.frozen_capture_rect else { - return false; - }; - let Some(toolbar_pos) = self.toolbar_state.floating_position else { - return false; - }; - let Some(previous_default_pos) = self.toolbar_state.default_slot_position else { - return false; - }; - let current_default_pos = - self.frozen_toolbar_default_position_for_capture_rect(monitor, capture_rect); - - self.toolbar_state.default_slot_position = Some(current_default_pos); - - if frozen_toolbar_matches_default_slot(toolbar_pos, previous_default_pos) { - self.toolbar_state.floating_position = Some(current_default_pos); - - return !frozen_toolbar_matches_default_slot(toolbar_pos, current_default_pos); - } - - false - } - - fn handle_overlay_window_redraw(&mut self, window_id: WindowId) -> OverlayControl { - let Some(overlay_monitor) = self.windows.get(&window_id).map(|overlay| overlay.monitor) - else { - return OverlayControl::Continue; - }; - - self.sync_frozen_toolbar_state(); - - self.event_loop_last_progress_window_id = Some(window_id); - self.event_loop_last_progress_monitor_id = Some(overlay_monitor.id); - - self.maybe_log_event_loop_stall(Instant::now()); - self.mark_progress(OverlayEventLoopPhase::OverlayRedraw); - - // On macOS the frozen toolbar is now rendered in its own native HUD window; keep this - // fullscreen overlay free of toolbar UI so shader-backed blur and monitor-aligned offsets - // do not conflict with native-window positioning. - let draw_toolbar = !cfg!(target_os = "macos") - && matches!(self.state.mode, OverlayMode::Frozen) - && self.toolbar_state.visible - && self.state.monitor == Some(overlay_monitor) - && self.frozen_final_capture_ready(); - let toolbar_input = - if draw_toolbar { self.toolbar_pointer_state(overlay_monitor, None) } else { None }; - - if matches!(self.state.mode, OverlayMode::Frozen) - && self.state.monitor == Some(overlay_monitor) - { - tracing::trace!( - window_id = ?window_id, - monitor_id = overlay_monitor.id, - frozen_generation = self.state.frozen_generation, - final_capture_ready = self.authoritative_frozen_capture_ready, - frozen_image_ready = self.state.frozen_image.is_some(), - pending_freeze_capture = self.pending_freeze_capture.map(|m| m.id), - draw_toolbar, - toolbar_visible = self.toolbar_state.visible, - toolbar_floating_position = ?self.toolbar_state.floating_position, - toolbar_stable_frames = self.toolbar_state.layout_stable_frames, - toolbar_last_screen_size_points = ?self.toolbar_state.layout_last_screen_size_points, - "Overlay redraw (Frozen)." - ); - } - - let overlay_screen_rect = self.overlay_window_screen_rect(window_id, overlay_monitor); - let toolbar_visible_for_badge = if cfg!(target_os = "macos") { - !self.should_hide_toolbar_window(overlay_monitor) - } else { - draw_toolbar - }; - #[cfg(target_os = "macos")] - let toolbar_ready_for_badge = if toolbar_visible_for_badge { - let ready = self.advance_frozen_toolbar_readiness_sample(overlay_screen_rect); - - if !ready { - self.request_redraw_for_monitor(overlay_monitor); - } - - ready - } else { - false - }; - #[cfg(not(target_os = "macos"))] - let toolbar_ready_for_badge = - toolbar_visible_for_badge && self.frozen_toolbar_ready_for_draw(overlay_screen_rect); - let frozen_toolbar_reserved_rect = self.frozen_size_badge_toolbar_reserved_rect( - overlay_monitor, - overlay_screen_rect, - toolbar_ready_for_badge, - ); - let Some(gpu) = self.gpu.as_ref() else { - return self.exit(OverlayExit::Error(String::from("Missing GPU context"))); - }; - let toolbar_state = if draw_toolbar { Some(&mut self.toolbar_state) } else { None }; - - { - let Some(overlay_window) = self.windows.get_mut(&window_id) else { - return OverlayControl::Continue; - }; - - if let Err(err) = overlay_window.renderer.draw( - gpu, - &self.state, - overlay_monitor, - false, - None, - false, - self.config.hud_anchor, - self.config.toolbar_placement, - self.config.show_alt_hint_keycap, - self.config.show_hud_blur, - self.config.hud_opaque, - self.config.hud_opacity, - self.config.hud_fog_amount, - self.config.hud_milk_amount, - self.config.hud_tint_hue, - self.config.theme_mode, - self.config.selection_flow_enabled, - self.config.selection_flow_stroke_width_px, - !self.scroll_capture.active, - self.scroll_capture.active, - self.frozen_capture_source == FrozenCaptureSource::FullscreenFallback, - frozen_toolbar_reserved_rect, - toolbar_state, - toolbar_input, - ) { - return self.exit(OverlayExit::Error(format!("{err:#}"))); - } - } - self.last_present_at = Instant::now(); - - self.handle_capture_and_toolbar_redraw_post(overlay_monitor, draw_toolbar) - } - - fn overlay_window_screen_rect(&self, window_id: WindowId, monitor: MonitorRect) -> Rect { - let fallback_size = Vec2::new(monitor.width as f32, monitor.height as f32); - - self.windows - .get(&window_id) - .map(|overlay_window| { - let scale_factor = overlay_window.window.scale_factor().max(1.0) as f32; - let size = overlay_window.window.inner_size(); - let size_points = if size.width == 0 || size.height == 0 { - fallback_size - } else { - Vec2::new( - (size.width as f32 / scale_factor).max(1.0), - (size.height as f32 / scale_factor).max(1.0), - ) - }; - - Rect::from_min_size(Pos2::ZERO, size_points) - }) - .unwrap_or_else(|| Rect::from_min_size(Pos2::ZERO, fallback_size)) - } - - #[cfg(any(target_os = "macos", test))] - fn advance_frozen_toolbar_readiness_sample(&mut self, screen_rect: Rect) -> bool { - advance_frozen_toolbar_readiness_sample_state(&mut self.toolbar_state, screen_rect) - } - - #[cfg(any(not(target_os = "macos"), test))] - fn frozen_toolbar_ready_for_draw(&self, screen_rect: Rect) -> bool { - let screen_size_points = screen_rect.size(); - let needs_new_sample = frozen_toolbar_needs_new_sample( - self.toolbar_state.layout_last_screen_size_points, - screen_size_points, - ); - - !needs_new_sample && self.toolbar_state.layout_stable_frames >= 1 - } - - fn frozen_size_badge_toolbar_reserved_rect( - &self, - monitor: MonitorRect, - screen_rect: Rect, - toolbar_ready: bool, - ) -> Option { - if !toolbar_ready - || !matches!(self.state.mode, OverlayMode::Frozen) - || self.state.monitor != Some(monitor) - { - return None; - } - - WindowRenderer::frozen_toolbar_reserved_rect( - &self.state, - monitor, - screen_rect, - self.config.toolbar_placement, - &self.toolbar_state, - ) - } - - fn handle_capture_and_toolbar_redraw_post( - &mut self, - overlay_monitor: MonitorRect, - draw_toolbar: bool, - ) -> OverlayControl { - if self.should_dispatch_pending_freeze_capture(overlay_monitor) - && let Some(worker) = &self.worker - { - let pending_window_target = self - .pending_window_freeze_capture - .filter(|target| target.monitor == overlay_monitor); - let freeze_target = pending_window_target - .map_or(FreezeCaptureTarget::Monitor, |target| FreezeCaptureTarget::Window { - window_id: target.window_id, - }); - - #[cfg(target_os = "macos")] - { - if worker.request_freeze_capture(overlay_monitor, freeze_target) { - self.pending_freeze_capture = None; - self.pending_freeze_capture_armed = false; - self.inflight_freeze_capture = Some(overlay_monitor); - self.inflight_window_freeze_capture = pending_window_target; - self.pending_window_freeze_capture = None; - } else { - self.request_redraw_for_monitor(overlay_monitor); - } - } - #[cfg(not(target_os = "macos"))] - { - // Capture must happen on a post-hide redraw so the HUD/loupe are not included. - if self.pending_freeze_capture_armed { - if worker.request_freeze_capture(overlay_monitor, freeze_target) { - self.pending_freeze_capture = None; - self.pending_freeze_capture_armed = false; - self.inflight_freeze_capture = Some(overlay_monitor); - self.inflight_window_freeze_capture = pending_window_target; - self.pending_window_freeze_capture = None; - } else { - self.request_redraw_for_monitor(overlay_monitor); - } - } else { - self.pending_freeze_capture_armed = true; - - #[cfg(not(target_os = "macos"))] - self.hide_capture_windows(); - self.request_redraw_for_monitor(overlay_monitor); - } - } - } - if draw_toolbar && let Some(action) = self.toolbar_state.pending_action.take() { - let control = self.handle_toolbar_action(action); - - if !matches!(control, OverlayControl::Continue) { - return control; - } - } - if draw_toolbar && self.toolbar_state.needs_redraw { - self.toolbar_state.needs_redraw = false; - - self.request_redraw_for_monitor(overlay_monitor); - } - - OverlayControl::Continue - } - - fn handle_toolbar_action(&mut self, action: FrozenToolbarTool) -> OverlayControl { - match action { - FrozenToolbarTool::AutoCenter => { - self.auto_center_frozen_capture_rect(); - - OverlayControl::Continue - }, - FrozenToolbarTool::Copy => { - self.begin_png_action(PngAction::Copy); - - OverlayControl::Continue - }, - FrozenToolbarTool::Save => { - self.begin_png_action(PngAction::Save); - - OverlayControl::Continue - }, - FrozenToolbarTool::Scroll => self.start_scroll_capture(), - #[cfg(target_os = "macos")] - FrozenToolbarTool::Ocr => { - self.begin_ocr_action(); - - OverlayControl::Continue - }, - _ => OverlayControl::Continue, - } - } - - fn cancel_overlay(&mut self, reason: &'static str) -> OverlayControl { - tracing::info!( - op = "overlay.cancel_requested", - reason, - mode = ?self.state.mode, - scroll_capture_active = self.scroll_capture.active, - last_event_phase = %self.event_loop_phase.as_str(), - last_event_window_id = ?self.event_loop_last_progress_window_id, - last_event_monitor_id = ?self.event_loop_last_progress_monitor_id, - last_event_detail = ?self.event_loop_last_progress_detail, - "Overlay cancellation was requested." - ); - - self.exit(OverlayExit::Cancelled) - } - - fn exit(&mut self, exit: OverlayExit) -> OverlayControl { - let (exit_kind, png_bytes_len, saved_path, error_message) = match &exit { - OverlayExit::Cancelled => ("cancelled", None, None, None), - OverlayExit::PngBytes(png_bytes) => ("png_bytes", Some(png_bytes.len()), None, None), - OverlayExit::TextCopied(_) => ("text_copied", None, None, None), - OverlayExit::Saved(path) => ("saved", None, Some(path.display().to_string()), None), - OverlayExit::Error(message) => ("error", None, None, Some(message.as_str())), - }; - #[cfg(target_os = "macos")] - let scroll_capture_has_live_stream = self.scroll_capture.live_stream.is_some(); - #[cfg(not(target_os = "macos"))] - let scroll_capture_has_live_stream = false; - #[cfg(target_os = "macos")] - let live_sample_stream_present = self.live_sample_stream.is_some(); - #[cfg(not(target_os = "macos"))] - let live_sample_stream_present = false; - - tracing::info!( - op = "overlay.exit_begin", - exit_kind, - png_bytes_len, - saved_path, - error_message, - scroll_capture_active = self.scroll_capture.active, - scroll_capture_has_live_stream, - live_sample_stream_present, - last_event_phase = %self.event_loop_phase.as_str(), - last_event_window_id = ?self.event_loop_last_progress_window_id, - last_event_monitor_id = ?self.event_loop_last_progress_monitor_id, - last_event_detail = ?self.event_loop_last_progress_detail, - "Beginning overlay exit cleanup." - ); - - if self.scroll_capture.active { - self.maybe_tick_scroll_capture(); - self.refresh_scroll_preview_committed_image(); - self.refresh_scroll_preview_display_image(); - self.sync_scroll_preview_segments(); - } - - let scroll_capture_final_snapshot = self.scroll_capture_trace_snapshot_at(Instant::now()); - let final_preview_image = self.current_scroll_preview_render_image(); - - if let (Some(trace_recorder), Some(session)) = - (self.scroll_capture.trace_recorder.as_mut(), self.scroll_capture.session.as_ref()) - { - let final_preview_image = - final_preview_image.unwrap_or_else(|| session.preview_image().clone()); - - trace_recorder.finalize_session( - session, - &final_preview_image, - scroll_capture_final_snapshot, - ); - } - - #[cfg(target_os = "macos")] - self.set_scroll_overlay_mouse_passthrough(false); - self.windows.clear(); - - self.hud_window = None; - self.hud_inner_size_points = None; - self.hud_outer_pos = None; - self.pending_hud_outer_pos = None; - self.loupe_window = None; - self.loupe_inner_size_points = None; - self.loupe_outer_pos = None; - self.pending_loupe_outer_pos = None; - self.toolbar_window = None; - self.scroll_preview_window = None; - self.toolbar_inner_size_points = None; - self.toolbar_outer_pos = None; - self.hud_window_visible = false; - self.toolbar_window_visible = false; - self.toolbar_window_warmup_redraws_remaining = 0; - self.loupe_window_visible = false; - self.loupe_window_warmup_redraws_remaining = 0; - self.scroll_capture = ScrollCaptureState::default(); - self.frozen_capture_source = FrozenCaptureSource::None; - self.cursor_monitor = None; - self.gpu = None; - self.worker = None; - #[cfg(target_os = "macos")] - { - self.live_sample_worker = None; - self.live_sample_stream = None; - } - self.event_loop_phase = OverlayEventLoopPhase::Idle; - self.event_loop_progress_seq = 0; - self.event_loop_last_progress_at = Instant::now(); - self.event_loop_last_progress_window_id = None; - self.event_loop_last_progress_monitor_id = None; - self.event_loop_last_progress_detail = None; - self.event_loop_last_stall_warn_at = None; - self.toolbar_left_button_down = false; - self.toolbar_left_button_went_down = false; - self.toolbar_left_button_went_up = false; - self.toolbar_pointer_local = None; - - self.stop_frozen_selection_drag(); - self.clear_pending_output_actions(); - - tracing::info!( - op = "overlay.exit_end", - exit_kind, - png_bytes_len, - saved_path, - error_message, - "Finished overlay exit cleanup." - ); - - OverlayControl::Exit(exit) - } - - fn clear_pending_output_actions(&mut self) { - #[cfg(target_os = "macos")] - { - self.active_ocr_request_id = None; - self.pending_recognize_text = None; - } - self.pending_encode_png = None; - self.pending_png_action = None; - #[cfg(target_os = "macos")] - { - self.ocr_inflight = false; - self.png_encode_inflight = false; - } - - self.focused_window_ids.clear(); - - self.pending_focus_loss_cleanup = false; - self.loupe_activation_key_down = false; - self.keyboard_modifiers = ModifiersState::default(); - } - - fn initialize_cursor_state_for_cursor( - &mut self, - cursor: GlobalPoint, - monitor: Option, - ) { - let Some(monitor) = monitor else { - self.state.cursor = Some(cursor); - self.state.rgb = None; - self.cursor_monitor = None; - - return; - }; - - self.update_cursor_state(monitor, cursor); - self.update_hud_window_position(monitor, cursor); - - if matches!(self.state.mode, OverlayMode::Live) { - if self.use_fake_hud_blur() { - self.maybe_request_live_bg(monitor); - } - - self.request_live_samples_for_cursor(monitor, cursor); - } - } - - fn monitor_for_cursor_in_rects( - monitors: &[MonitorRect], - cursor: GlobalPoint, - ) -> Option { - monitors.iter().copied().find(|monitor| monitor.contains(cursor)) - } - - fn prime_startup_cursor_context(&mut self, cursor: GlobalPoint, monitor: Option) { - let Some(monitor) = monitor else { - self.state.cursor = Some(cursor); - self.state.rgb = None; - self.cursor_monitor = None; - - return; - }; - - self.update_cursor_state(monitor, cursor); - self.update_hud_window_position(monitor, cursor); - } - - #[cfg(target_os = "macos")] - fn startup_live_rgb_plan(startup_monitor: Option) -> StartupLiveRgbPlan { - StartupLiveRgbPlan { focus_window: true, seed_monitor: startup_monitor } - } - - #[cfg(target_os = "macos")] - fn seed_startup_live_cursor_rgb(&mut self, monitor: MonitorRect, cursor: GlobalPoint) { - if !matches!(self.state.mode, OverlayMode::Live) || self.state.rgb.is_some() { - return; - } - - let Some(stream) = self.live_sample_stream.as_ref() else { - return; - }; - let Some((x_px, y_px)) = monitor.local_u32_pixels(cursor) else { - return; - }; - let deadline = Instant::now() + STARTUP_LIVE_SAMPLE_WAIT_TIMEOUT; - - loop { - if let Some(sample) = - stream.latest_cursor_sample(monitor, CursorSampleRequest::rgb(x_px, y_px)) - && let Some(rgb) = sample.rgb - { - self.state.rgb = Some(rgb); - - return; - } - - if Instant::now() >= deadline { - return; - } - - thread::sleep(STARTUP_LIVE_SAMPLE_WAIT_POLL_INTERVAL); - } - } - - fn maybe_request_live_bg(&mut self, monitor: MonitorRect) { - if !matches!(self.state.mode, OverlayMode::Live) || !self.use_fake_hud_blur() { - return; - } - if self.state.live_bg_monitor == Some(monitor) && self.state.live_bg_image.is_some() { - return; - } - - let force = self.state.alt_held && self.state.live_bg_image.is_none(); - - if !force && self.last_live_bg_request_at.elapsed() < self.live_bg_request_interval { - return; - } - - let Some(worker) = &self.worker else { - return; - }; - - if worker.request_freeze_capture(monitor, FreezeCaptureTarget::Monitor) { - self.last_live_bg_request_at = Instant::now(); - } - } - - fn monitor_at(&self, cursor: GlobalPoint) -> Option { - self.windows - .values() - .find(|window| window.monitor.contains(cursor)) - .map(|window| window.monitor) - } - - fn resolve_device_cursor_point( - &self, - raw: GlobalPoint, - ) -> Option<(MonitorRect, GlobalPoint, DeviceCursorPointSource)> { - if let Some(monitor) = self.monitor_at(raw) { - return Some((monitor, raw, DeviceCursorPointSource::DevicePoints)); - } - - for monitor in self.windows.values().map(|window| window.monitor) { - let sf = f64::from(monitor.scale_factor()).max(1.0); - let origin_px_x = (monitor.origin.x as f64 * sf).round() as i64; - let origin_px_y = (monitor.origin.y as f64 * sf).round() as i64; - let size_px_x = (monitor.width as f64 * sf).round() as i64; - let size_px_y = (monitor.height as f64 * sf).round() as i64; - let local_px_x = (raw.x as i64).saturating_sub(origin_px_x); - let local_px_y = (raw.y as i64).saturating_sub(origin_px_y); - - if local_px_x < 0 - || local_px_y < 0 - || local_px_x >= size_px_x - || local_px_y >= size_px_y - { - continue; - } - - let local_points_x = (local_px_x as f64 / sf).round() as i64; - let local_points_y = (local_px_y as f64 / sf).round() as i64; - let local_points_x = match i32::try_from(local_points_x) { - Ok(value) => value, - Err(_) => continue, - }; - let local_points_y = match i32::try_from(local_points_y) { - Ok(value) => value, - Err(_) => continue, - }; - let candidate = GlobalPoint::new( - monitor.origin.x.saturating_add(local_points_x), - monitor.origin.y.saturating_add(local_points_y), - ); - - if monitor.contains(candidate) { - return Some((monitor, candidate, DeviceCursorPointSource::DevicePixelsFallback)); - } - } - - None - } - - fn resolve_live_cursor_point( - &self, - raw_device: GlobalPoint, - ) -> Option<(MonitorRect, GlobalPoint, DeviceCursorPointSource)> { - let Some((device_monitor, device_global, device_source)) = - self.resolve_device_cursor_point(raw_device) - else { - let (monitor, global) = self.last_event_cursor?; - let event_cursor_at = self.last_event_cursor_at?; - - if event_cursor_at.elapsed() > LIVE_EVENT_CURSOR_CACHE_TTL { - return None; - } - - return Some((monitor, global, DeviceCursorPointSource::EventRecentFallback)); - }; - - if let (Some(event_cursor_at), Some((event_monitor, event_global))) = - (self.last_event_cursor_at, self.last_event_cursor) - && self.state.cursor == Some(device_global) - && event_global != device_global - && event_cursor_at.elapsed() <= LIVE_EVENT_CURSOR_CACHE_TTL - { - return Some(( - event_monitor, - event_global, - DeviceCursorPointSource::EventRecentFallback, - )); - } - - Some((device_monitor, device_global, device_source)) - } - - fn active_cursor_monitor(&self) -> Option { - self.cursor_monitor.or_else(|| self.state.cursor.and_then(|cursor| self.monitor_at(cursor))) - } - - fn monitor_for_mode(&self) -> Option { - match self.state.mode { - OverlayMode::Frozen => self.active_cursor_monitor().or(self.state.monitor), - OverlayMode::Live => self.active_cursor_monitor(), - } - } - - fn update_hud_window_position(&mut self, monitor: MonitorRect, cursor: GlobalPoint) { - if self.live_loupe_uses_hud_window() - && matches!(self.state.mode, OverlayMode::Live) - && self.state.alt_held - { - let _ = self.update_loupe_window_position(monitor); - - return; - } - - let Some(hud_window) = self.hud_window.as_ref() else { - return; - }; - let scale = hud_window.window.scale_factor().max(1.0); - let size = hud_window.window.inner_size(); - let hud_w_points = ((size.width as f64) / scale).ceil().max(1.0) as i32; - let hud_h_points = ((size.height as f64) / scale).ceil().max(1.0) as i32; - let monitor_right = monitor.origin.x.saturating_add_unsigned(monitor.width); - let monitor_bottom = monitor.origin.y.saturating_add_unsigned(monitor.height); - // Keep the HUD far enough from the cursor that even if the OS lags window moves during - // rapid drags, the cursor is unlikely to "catch up" and overlap the HUD window. - let offset_x = 48; - let offset_y = 24; - let mut x = cursor.x.saturating_add(offset_x); - let mut y = cursor.y.saturating_add(offset_y); - - if x.saturating_add(hud_w_points) > monitor_right { - x = cursor.x.saturating_sub(offset_x.saturating_add(hud_w_points)); - } - if y.saturating_add(hud_h_points) > monitor_bottom { - y = cursor.y.saturating_sub(offset_y.saturating_add(hud_h_points)); - } - - x = x.clamp( - monitor.origin.x, - monitor_right.saturating_sub(hud_w_points).max(monitor.origin.x), - ); - y = y.clamp( - monitor.origin.y, - monitor_bottom.saturating_sub(hud_h_points).max(monitor.origin.y), - ); - - let desired = GlobalPoint::new(x, y); - - if self.hud_outer_pos == Some(desired) { - if self.state.alt_held { - let _ = self.update_loupe_window_position(monitor); - } - - return; - } - - self.hud_outer_pos = Some(desired); - self.pending_hud_outer_pos = Some(desired); - - if self.state.alt_held { - let _ = self.update_loupe_window_position(monitor); - } - } - - fn update_loupe_window_position(&mut self, monitor: MonitorRect) -> bool { - if !self.state.alt_held { - self.pending_loupe_outer_pos = None; - - return false; - } - - let Some(loupe_window) = self.loupe_window.as_ref() else { - return false; - }; - let loupe_scale = loupe_window.window.scale_factor().max(1.0); - let loupe_size = loupe_window.window.inner_size(); - let loupe_w_points = ((loupe_size.width as f64) / loupe_scale).ceil().max(1.0) as i32; - let loupe_h_points = ((loupe_size.height as f64) / loupe_scale).ceil().max(1.0) as i32; - let monitor_right = monitor.origin.x.saturating_add_unsigned(monitor.width); - let monitor_bottom = monitor.origin.y.saturating_add_unsigned(monitor.height); - let max_x = monitor_right.saturating_sub(loupe_w_points).max(monitor.origin.x); - let max_y = monitor_bottom.saturating_sub(loupe_h_points).max(monitor.origin.y); - let gap = HUD_LOUPE_STRIP_GAP_POINTS; - let (mut x, mut y) = if matches!(self.state.mode, OverlayMode::Live) { - let hud_height_points = self.hud_window.as_ref().map(|hud_window| { - let hud_scale = hud_window.window.scale_factor().max(1.0); - let hud_size = hud_window.window.inner_size(); - - ((hud_size.height as f64) / hud_scale).ceil().max(1.0) as i32 - }); - let Some(desired) = Self::live_loupe_default_position( - monitor, - self.state.cursor, - self.hud_outer_pos, - hud_height_points, - loupe_w_points, - loupe_h_points, - ) else { - return false; - }; - - (desired.x, desired.y) - } else { - let Some(hud_window) = self.hud_window.as_ref() else { - return false; - }; - let Some(hud_outer) = self.hud_outer_pos else { - return false; - }; - let hud_scale = hud_window.window.scale_factor().max(1.0); - let hud_size = hud_window.window.inner_size(); - let hud_h_points = ((hud_size.height as f64) / hud_scale).ceil().max(1.0) as i32; - let below_y = hud_outer.y.saturating_add(hud_h_points + gap); - let above_y = hud_outer.y.saturating_sub(gap.saturating_add(loupe_h_points)); - - ( - hud_outer.x, - if below_y.saturating_add(loupe_h_points) <= monitor_bottom { - below_y - } else { - above_y - }, - ) - }; - - x = x.clamp(monitor.origin.x, max_x); - y = y.clamp(monitor.origin.y, max_y); - - let desired = GlobalPoint::new(x, y); - - if self.loupe_outer_pos == Some(desired) { - self.pending_loupe_outer_pos = Some(desired); - - return true; - } - - self.loupe_outer_pos = Some(desired); - self.pending_loupe_outer_pos = Some(desired); - - true - } - - fn live_loupe_default_position( - monitor: MonitorRect, - cursor: Option, - hud_outer: Option, - hud_height_points: Option, - loupe_w_points: i32, - loupe_h_points: i32, - ) -> Option { - let monitor_right = monitor.origin.x.saturating_add_unsigned(monitor.width); - let monitor_bottom = monitor.origin.y.saturating_add_unsigned(monitor.height); - let max_x = monitor_right.saturating_sub(loupe_w_points).max(monitor.origin.x); - let max_y = monitor_bottom.saturating_sub(loupe_h_points).max(monitor.origin.y); - let gap = HUD_LOUPE_STRIP_GAP_POINTS; - let (mut x, mut y) = - if let (Some(hud_outer), Some(hud_height_points)) = (hud_outer, hud_height_points) { - let below_y = hud_outer.y.saturating_add(hud_height_points + gap); - let above_y = hud_outer.y.saturating_sub(gap.saturating_add(loupe_h_points)); - - ( - hud_outer.x, - if below_y.saturating_add(loupe_h_points) <= monitor_bottom { - below_y - } else { - above_y - }, - ) - } else { - let cursor = cursor?; - let offset_x = 48; - let offset_y = 32; - let mut x = cursor.x.saturating_add(offset_x); - let mut y = cursor.y.saturating_add(offset_y); - - if x.saturating_add(loupe_w_points) > monitor_right { - x = cursor.x.saturating_sub(offset_x.saturating_add(loupe_w_points)); - } - if y.saturating_add(loupe_h_points) > monitor_bottom { - y = cursor.y.saturating_sub(offset_y.saturating_add(loupe_h_points)); - } - - (x, y) - }; - - x = x.clamp(monitor.origin.x, max_x); - y = y.clamp(monitor.origin.y, max_y); - - Some(GlobalPoint::new(x, y)) - } - - fn update_toolbar_outer_position(&mut self, monitor: MonitorRect, local_pos: Pos2) -> bool { - let Some(toolbar_window) = self.toolbar_window.as_ref() else { - return false; - }; - let toolbar_scale = toolbar_window.window.scale_factor().max(1.0); - let toolbar_size = if let Some((width, height)) = self.toolbar_inner_size_points { - Vec2::new(width as f32, height as f32) - } else { - let size = toolbar_window.window.inner_size(); - let toolbar_w = ((size.width as f64) / toolbar_scale).ceil().max(1.0) as f32; - let toolbar_h = ((size.height as f64) / toolbar_scale).ceil().max(1.0) as f32; - - Vec2::new(toolbar_w, toolbar_h) - }; - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let clamped_local_pos = WindowRenderer::clamp_toolbar_position( - screen_rect, - toolbar_size, - local_pos, - TOOLBAR_SCREEN_MARGIN_PX, - TOOLBAR_SCREEN_MARGIN_PX, - ); - let desired = GlobalPoint::new( - monitor.origin.x.saturating_add(clamped_local_pos.x.round() as i32), - monitor.origin.y.saturating_add(clamped_local_pos.y.round() as i32), - ); - - if self.toolbar_outer_pos == Some(desired) { - return false; - } - - self.toolbar_outer_pos = Some(desired); - self.toolbar_state.floating_position = Some(clamped_local_pos); - - let started_at = Instant::now(); - - toolbar_window - .window - .set_outer_position(LogicalPosition::new(desired.x as f64, desired.y as f64)); - self.slow_op_logger.warn_if_slow( - "overlay.toolbar_window_set_outer_position", - started_at.elapsed(), - SLOW_OP_WARN_OUTER_POSITION, - || { - format!( - "window_id={:?} pos=({}, {})", - toolbar_window.window.id(), - desired.x, - desired.y - ) - }, - ); - toolbar_window.window.request_redraw(); - - true - } - - fn update_cursor_state(&mut self, monitor: MonitorRect, cursor: GlobalPoint) { - self.cursor_monitor = Some(monitor); - self.state.cursor = Some(cursor); - - match self.state.mode { - OverlayMode::Live => {}, - OverlayMode::Frozen => { - if self.state.frozen_image.is_none() { - return; - } - - let frozen_monitor = self.state.monitor; - - self.state.rgb = - image_helpers::frozen_rgb(&self.state.frozen_image, frozen_monitor, cursor); - self.state.loupe = if self.state.alt_held { - image_helpers::frozen_loupe_patch( - &self.state.frozen_image, - frozen_monitor, - cursor, - self.loupe_patch_width_px, - self.loupe_patch_height_px, - ) - .map(|patch| crate::state::LoupeSample { center: cursor, patch }) - } else { - None - }; - }, - } - } - - #[cfg(not(target_os = "macos"))] - fn hide_capture_windows(&mut self) { - self.capture_windows_hidden = true; - - if let Some(hud_window) = &self.hud_window { - hud_window.window.set_visible(false); - } - - self.hud_window_visible = false; - - if let Some(loupe_window) = &self.loupe_window { - loupe_window.window.set_visible(false); - } - } - - fn restore_capture_windows_visibility(&mut self) { - if !self.capture_windows_hidden { - return; - } - - self.capture_windows_hidden = false; - #[cfg(not(target_os = "macos"))] - { - if let Some(hud_window) = &self.hud_window { - hud_window.window.set_visible(true); - } - - self.hud_window_visible = true; - } - - #[cfg(not(target_os = "macos"))] - if let Some(loupe_window) = &self.loupe_window { - loupe_window.window.set_visible(self.state.alt_held); - } - } - - #[cfg(not(target_os = "macos"))] - fn raise_hud_windows(&self) { - if let Some(hud_window) = self.hud_window.as_ref() { - hud_window.window.focus_window(); - } - - if self.state.alt_held - && let Some(loupe_window) = self.loupe_window.as_ref() - { - loupe_window.window.focus_window(); - } - } -} - -impl Default for OverlaySession { - fn default() -> Self { - Self::new() - } -} - -#[cfg(target_os = "macos")] -#[derive(Clone, Debug, Eq, PartialEq)] -struct PendingRecognizeTextRequest { - request_id: u64, - image: RgbaImage, -} - -struct InitialSessionRuntime { - live_bg_request_interval: Duration, - window_list_refresh_interval: Duration, - now: Instant, - loupe_sample_side_px: u32, - state: OverlayState, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct FrozenToolbarButtonStyle { - icon_color: Color32, - bg_color: Color32, - border_color: Option, -} - -struct ScrollPreviewStrip { - texture: TextureHandle, - pixel_size: [usize; 2], - rgba: Vec, - size_points: Vec2, -} - -struct LiveLoupeTexture { - texture: TextureHandle, - patch_size_px: [usize; 2], - rgba: Vec, -} - -struct ScrollPreviewWindow { - window: Arc, - surface: Surface<'static>, - surface_config: wgpu::SurfaceConfiguration, - needs_reconfigure: bool, - egui_ctx: egui::Context, - egui_state: egui_winit::State, - renderer: Renderer, - preview_image: Option, -} -impl ScrollPreviewWindow { - fn new(event_loop: &ActiveEventLoop, gpu: &GpuContext) -> Result { - let attrs = winit::window::Window::default_attributes() - .with_title("rsnap-scroll-preview") - .with_visible(false) - .with_resizable(false) - .with_decorations(false) - .with_transparent(true) - .with_inner_size(LogicalSize::new( - SCROLL_PREVIEW_WINDOW_WIDTH_POINTS, - SCROLL_PREVIEW_WINDOW_HEIGHT_POINTS, - )) - .with_window_level(WindowLevel::AlwaysOnTop); - let window = event_loop - .create_window(attrs) - .map_err(|err| format!("Unable to create scroll preview window: {err}"))?; - let window = Arc::new(window); - let surface = gpu - .instance - .create_surface(Arc::clone(&window)) - .map_err(|err| format!("wgpu create_surface failed: {err:#}"))?; - let caps = surface.get_capabilities(&gpu.adapter); - let surface_format = WindowRenderer::pick_surface_format(&caps); - let surface_alpha = WindowRenderer::pick_surface_alpha(&caps); - let surface_config = - WindowRenderer::make_surface_config(window.as_ref(), surface_format, surface_alpha); - let egui_ctx = egui::Context::default(); - let mut fonts = FontDefinitions::default(); - - egui_phosphor::add_to_fonts(&mut fonts, Variant::Regular); - - egui_ctx.set_fonts(fonts); - - let egui_state = egui_winit::State::new( - egui_ctx.clone(), - ViewportId::ROOT, - window.as_ref(), - None, - None, - None, - ); - let renderer = Renderer::new( - &gpu.device, - surface_config.format, - egui_wgpu::RendererOptions { - msaa_samples: 1, - depth_stencil_format: None, - dithering: false, - predictable_texture_filtering: false, - }, - ); - - surface.configure(&gpu.device, &surface_config); - - let _ = window.set_cursor_hittest(false); - - #[cfg(target_os = "macos")] - macos_configure_hud_window(window.as_ref(), false, 0.0, Some(18.0)); - - Ok(Self { - window, - surface, - surface_config, - needs_reconfigure: false, - egui_ctx, - egui_state, - renderer, - preview_image: None, - }) - } - - fn handle_window_event(&mut self, event: &WindowEvent) { - match event { - WindowEvent::Resized(size) => self.resize(*size), - WindowEvent::ScaleFactorChanged { .. } => self.resize(self.window.inner_size()), - WindowEvent::ThemeChanged(_) => self.window.request_redraw(), - _ => {}, - } - - let _ = self.egui_state.on_window_event(&self.window, event); - - self.window.request_redraw(); - } - - fn sync_image(&mut self, image: Option) { - let Some(image) = image else { - self.preview_image = None; - - return; - }; - let preview_image = image_helpers::resize_scroll_preview_segment(&image); - let pixel_size = [preview_image.width() as usize, preview_image.height() as usize]; - let rgba = preview_image.as_raw().clone(); - let color_image = ColorImage::from_rgba_unmultiplied(pixel_size, &rgba); - let ppp = self.window.scale_factor() as f32; - let size_points = - Vec2::new(preview_image.width() as f32 / ppp, preview_image.height() as f32 / ppp); - - match self.preview_image.as_mut() { - Some(strip) if strip.pixel_size == pixel_size => { - strip.texture.set(color_image, TextureOptions::LINEAR); - - strip.pixel_size = pixel_size; - strip.rgba = rgba; - strip.size_points = size_points; - }, - _ => { - let texture = self.egui_ctx.load_texture( - String::from("scroll-preview-image"), - color_image, - TextureOptions::LINEAR, - ); - - self.preview_image = - Some(ScrollPreviewStrip { texture, pixel_size, rgba, size_points }); - }, - } - } - - fn render_preview_ui(&mut self, view: ScrollPreviewView) -> FullOutput { - let raw_input = self.egui_state.take_egui_input(&self.window); - - self.egui_ctx.run_ui(raw_input, |ui| { - CentralPanel::default().frame(Frame::new().fill(Color32::TRANSPARENT)).show_inside( - ui, - |ui| { - let _ = view.paused; - let tile_fill = match view.theme { - HudTheme::Dark => Color32::from_rgba_unmultiplied(20, 22, 27, 228), - HudTheme::Light => Color32::from_rgba_unmultiplied(244, 246, 249, 236), - }; - let tile_stroke = match view.theme { - HudTheme::Dark => Color32::from_rgba_unmultiplied(255, 255, 255, 18), - HudTheme::Light => Color32::from_rgba_unmultiplied(30, 36, 44, 22), - }; - let tile_frame = Frame::new() - .fill(tile_fill) - .stroke(Stroke::new(1.0, tile_stroke)) - .corner_radius(CornerRadius::same(18)) - .inner_margin(Margin::symmetric(14, 14)); - - tile_frame.show(ui, |ui| { - ui.set_min_size(ui.available_size()); - - if let Some(preview_image) = self.preview_image.as_ref() { - let available = ui.available_size(); - let scale = - (available.x / preview_image.size_points.x).clamp(0.05, 1.0); - let draw_size = preview_image.size_points * scale; - - ui.with_layout(Layout::top_down(Align::Center), |ui| { - ui.image((preview_image.texture.id(), draw_size)); - }); - } else { - ui.allocate_space(ui.available_size()); - } - }); - }, - ); - }) - } - - fn render_preview_frame(&mut self, gpu: &GpuContext, full_output: FullOutput) -> Result<()> { - self.egui_state.handle_platform_output(&self.window, full_output.platform_output); - - for (id, delta) in &full_output.textures_delta.set { - self.renderer.update_texture(&gpu.device, &gpu.queue, *id, delta); - } - for id in &full_output.textures_delta.free { - self.renderer.free_texture(id); - } - - let pixels_per_point = self.window.scale_factor() as f32; - let paint_jobs = self.egui_ctx.tessellate(full_output.shapes, pixels_per_point); - let size = self.window.inner_size(); - let screen_descriptor = ScreenDescriptor { - size_in_pixels: [size.width.max(1), size.height.max(1)], - pixels_per_point, - }; - let frame = match self.acquire_frame(gpu)? { - AcquiredSurfaceFrame::Ready(frame) => frame, - AcquiredSurfaceFrame::Skipped(reason) => { - tracing::trace!( - window_id = ?self.window.id(), - reason = reason.as_str(), - "Skipped scroll preview frame acquisition." - ); - - if reason.should_request_redraw() { - self.window.request_redraw(); - } - - return Ok(()); - }, - }; - let view = frame.texture.create_view(&TextureViewDescriptor::default()); - let mut encoder = gpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("rsnap-scroll-preview encoder"), - }); - let _ = self.renderer.update_buffers( - &gpu.device, - &gpu.queue, - &mut encoder, - &paint_jobs, - &screen_descriptor, - ); - - { - let rpass_desc = wgpu::RenderPassDescriptor { - label: Some("rsnap-scroll-preview rpass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &view, - depth_slice: None, - resolve_target: None, - ops: wgpu::Operations { - load: LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }), - store: StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }; - let mut rpass = encoder.begin_render_pass(&rpass_desc).forget_lifetime(); - - self.renderer.render(&mut rpass, &paint_jobs, &screen_descriptor); - } - - gpu.queue.submit(Some(encoder.finish())); - frame.present(); - - Ok(()) - } - - fn draw(&mut self, gpu: &GpuContext, theme: HudTheme, view: ScrollPreviewView) -> Result<()> { - self.sync_surface_to_window(gpu); - - if self.needs_reconfigure { - self.reconfigure_surface(gpu); - } - - match theme { - HudTheme::Dark => self.egui_ctx.set_visuals(Visuals::dark()), - HudTheme::Light => self.egui_ctx.set_visuals(Visuals::light()), - } - - let full_output = self.render_preview_ui(view); - - self.render_preview_frame(gpu, full_output) - } - - fn acquire_frame(&mut self, gpu: &GpuContext) -> Result { - for attempt in 0..2 { - match self.surface.get_current_texture() { - CurrentSurfaceTexture::Success(frame) => { - return Ok(AcquiredSurfaceFrame::Ready(frame)); - }, - CurrentSurfaceTexture::Suboptimal(frame) => { - self.needs_reconfigure = true; - - return Ok(AcquiredSurfaceFrame::Ready(frame)); - }, - CurrentSurfaceTexture::Outdated if attempt == 0 => { - self.reconfigure_surface(gpu); - }, - CurrentSurfaceTexture::Lost if attempt == 0 => { - self.recreate_surface(gpu).wrap_err("recreate scroll preview surface")?; - }, - CurrentSurfaceTexture::Outdated => { - return Err(eyre::eyre!( - "scroll preview get_current_texture stayed outdated after reconfigure" - )); - }, - CurrentSurfaceTexture::Lost => { - return Err(eyre::eyre!( - "scroll preview get_current_texture stayed lost after recreate" - )); - }, - CurrentSurfaceTexture::Timeout => { - return Ok(AcquiredSurfaceFrame::Skipped(SurfaceFrameSkipReason::Timeout)); - }, - CurrentSurfaceTexture::Occluded => { - return Ok(AcquiredSurfaceFrame::Skipped(SurfaceFrameSkipReason::Occluded)); - }, - CurrentSurfaceTexture::Validation => { - return Err(eyre::eyre!("scroll preview get_current_texture hit validation")); - }, - } - } - - unreachable!("surface acquisition attempts are bounded") - } - - fn recreate_surface(&mut self, gpu: &GpuContext) -> Result<()> { - let surface = gpu - .instance - .create_surface(Arc::clone(&self.window)) - .wrap_err("create scroll preview surface")?; - - self.surface = surface; - - self.reconfigure_surface(gpu); - - Ok(()) - } - - fn reconfigure_surface(&mut self, gpu: &GpuContext) { - self.surface.configure(&gpu.device, &self.surface_config); - - self.needs_reconfigure = false; - } - - fn sync_surface_to_window(&mut self, gpu: &GpuContext) { - let actual_size = self.window.inner_size(); - let desired_w = actual_size.width.max(1); - let desired_h = actual_size.height.max(1); - - if self.surface_config.width == desired_w && self.surface_config.height == desired_h { - return; - } - - tracing::debug!( - window_id = ?self.window.id(), - actual_size_px = ?actual_size, - old_surface_px = ?(self.surface_config.width, self.surface_config.height), - new_surface_px = ?(desired_w, desired_h), - window_scale_factor = self.window.scale_factor(), - "Reconfiguring scroll preview surface to match window." - ); - - self.surface_config.width = desired_w; - self.surface_config.height = desired_h; - self.needs_reconfigure = false; - - self.reconfigure_surface(gpu); - } - - fn resize(&mut self, size: PhysicalSize) { - self.surface_config.width = size.width.max(1); - self.surface_config.height = size.height.max(1); - self.needs_reconfigure = true; - } -} - -struct ScrollPreviewView { - paused: bool, - theme: HudTheme, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct SelectionFlowGeometryCacheKey { - rect_min_x_bits: u32, - rect_min_y_bits: u32, - rect_max_x_bits: u32, - rect_max_y_bits: u32, - corner_radius_bits: u32, - seam_offset_bits: u32, - sample_count: usize, -} -impl SelectionFlowGeometryCacheKey { - const fn new(rect: Rect, corner_radius: f32, seam_offset: f32, sample_count: usize) -> Self { - Self { - rect_min_x_bits: rect.min.x.to_bits(), - rect_min_y_bits: rect.min.y.to_bits(), - rect_max_x_bits: rect.max.x.to_bits(), - rect_max_y_bits: rect.max.y.to_bits(), - corner_radius_bits: corner_radius.to_bits(), - seam_offset_bits: seam_offset.to_bits(), - sample_count, - } - } -} - -#[derive(Debug, Default)] -struct SelectionFlowGeometryCache { - key: Option, - samples: Vec<(Pos2, f32)>, - normals: Vec, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct SelectionDashedBorderCacheKey { - rect_min_x_bits: u32, - rect_min_y_bits: u32, - rect_max_x_bits: u32, - rect_max_y_bits: u32, - dash_length_bits: u32, - gap_length_bits: u32, -} -impl SelectionDashedBorderCacheKey { - const fn new(rect: Rect, dash_length: f32, gap_length: f32) -> Self { - Self { - rect_min_x_bits: rect.min.x.to_bits(), - rect_min_y_bits: rect.min.y.to_bits(), - rect_max_x_bits: rect.max.x.to_bits(), - rect_max_y_bits: rect.max.y.to_bits(), - dash_length_bits: dash_length.to_bits(), - gap_length_bits: gap_length.to_bits(), - } - } -} - -#[derive(Debug, Default)] -struct SelectionDashedBorderCache { - key: Option, - segments: Vec<[Pos2; 2]>, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct SelectionDashedBorderMetrics { - stroke_width: f32, - dash_length: f32, - gap_length: f32, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct SelectionSizeBadgePadding { - left: f32, - right: f32, - top: f32, - bottom: f32, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct SelectionSizeBadgeLayout { - text_size: Vec2, - badge_size: Vec2, - padding: SelectionSizeBadgePadding, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct SelectionSizeBadgeTarget { - rect: Rect, - size_points: RectPoints, -} - -struct HudOverlayWindow { - window: Arc, - renderer: WindowRenderer, -} - -#[derive(Debug, Default)] -struct HudRedrawSummary { - request_toolbar_redraw: Option, - renderer_draw_elapsed: Option, - request_inner_size_elapsed: Option, - position_update_elapsed: Option, - resize_target: Option<(u32, u32)>, - redraw_window_id: Option, - redraw_monitor_id: Option, -} - -#[cfg(target_os = "macos")] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct StartupLiveRgbPlan { - focus_window: bool, - seed_monitor: Option, -} - -#[derive(Debug, Default)] -struct WindowRendererPhaseTimings { - prepare_input: Duration, - sync_hud_bg: Duration, - run_egui: Duration, - update_hud_blur_uniform: Duration, - sync_egui_textures: Duration, - tessellate: Duration, - acquire_frame: Duration, - render_frame: Duration, - total: Duration, -} -impl WindowRendererPhaseTimings { - fn trace( - &self, - path: WindowRendererPath, - window_id: WindowId, - monitor_id: u32, - mode: OverlayMode, - toolbar_active: bool, - paint_jobs: usize, - ) { - tracing::trace!( - op = "overlay.window_renderer_phase_timing", - path = path.as_str(), - window_id = ?window_id, - monitor_id, - mode = ?mode, - toolbar_active, - paint_jobs, - total_us = self.total.as_micros(), - prepare_input_us = self.prepare_input.as_micros(), - sync_hud_bg_us = self.sync_hud_bg.as_micros(), - run_egui_us = self.run_egui.as_micros(), - update_hud_blur_uniform_us = self.update_hud_blur_uniform.as_micros(), - sync_egui_textures_us = self.sync_egui_textures.as_micros(), - tessellate_us = self.tessellate.as_micros(), - acquire_frame_us = self.acquire_frame.as_micros(), - render_frame_us = self.render_frame.as_micros(), - "Overlay window renderer phase timing." - ); - } - - fn warn_if_substeps_slow( - &self, - slow_op_logger: &mut SlowOperationLogger, - path: WindowRendererPath, - window_id: WindowId, - monitor_id: u32, - mode: OverlayMode, - paint_jobs: usize, - ) { - let context = || { - format!( - "path={} window_id={window_id:?} monitor_id={monitor_id} mode={mode:?} paint_jobs={paint_jobs}", - path.as_str() - ) - }; - - self.warn_phase_if_slow( - slow_op_logger, - "overlay.window_renderer.prepare_input", - self.prepare_input, - &context, - ); - self.warn_phase_if_slow( - slow_op_logger, - "overlay.window_renderer.sync_hud_bg", - self.sync_hud_bg, - &context, - ); - self.warn_phase_if_slow( - slow_op_logger, - "overlay.window_renderer.run_egui", - self.run_egui, - &context, - ); - self.warn_phase_if_slow( - slow_op_logger, - "overlay.window_renderer.update_hud_blur_uniform", - self.update_hud_blur_uniform, - &context, - ); - self.warn_phase_if_slow( - slow_op_logger, - "overlay.window_renderer.sync_egui_textures", - self.sync_egui_textures, - &context, - ); - self.warn_phase_if_slow( - slow_op_logger, - "overlay.window_renderer.tessellate", - self.tessellate, - &context, - ); - } - - fn warn_phase_if_slow( - &self, - slow_op_logger: &mut SlowOperationLogger, - op: &'static str, - elapsed: Duration, - describe: &F, - ) where - F: Fn() -> String, - { - if elapsed.is_zero() { - return; - } - - slow_op_logger.warn_if_redraw_substep_slow(op, elapsed, self.total, describe); - } -} - -struct OverlayWindow { - monitor: MonitorRect, - window: Arc, - renderer: WindowRenderer, - refresh_rate_millihertz: Option, -} - -struct GpuContext { - instance: wgpu::Instance, - adapter: Adapter, - device: Device, - queue: Queue, -} -impl GpuContext { - fn new() -> Result { - let instance = wgpu::Instance::default(); - let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { - power_preference: PowerPreference::LowPower, - compatible_surface: None, - force_fallback_adapter: false, - })) - .map_err(|err| eyre::eyre!("Failed to request GPU adapter: {err}"))?; - let adapter_limits = adapter.limits(); - let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { - label: Some("rsnap-overlay device"), - required_features: Features::empty(), - // Use the adapter's actual limits. Using `downlevel_defaults()` caps max texture - // size to 2048, which breaks on common HiDPI displays. - required_limits: adapter_limits, - experimental_features: ExperimentalFeatures::default(), - memory_hints: MemoryHints::Performance, - trace: Trace::Off, - })) - .wrap_err("Failed to create wgpu device")?; - - Ok(Self { instance, adapter, device, queue }) - } -} - -struct WindowRenderer { - window: Arc, - surface: Surface<'static>, - surface_config: wgpu::SurfaceConfiguration, - needs_reconfigure: bool, - egui_ctx: egui::Context, - egui_renderer: Renderer, - bg_sampler: Sampler, - mipgen_pipeline: RenderPipeline, - mipgen_surface_pipeline: RenderPipeline, - mipgen_bind_group_layout: BindGroupLayout, - hud_blur_pipeline: RenderPipeline, - hud_blur_bind_group_layout: BindGroupLayout, - hud_blur_uniform: Buffer, - hud_bg: Option, - hud_bg_generation: u64, - hud_pill: Option, - loupe_tile: Option, - live_loupe_texture: Option, - hud_theme: Option, - egui_start_time: Instant, - egui_last_frame_time: Instant, - selection_flow_cache: SelectionFlowGeometryCache, - selection_dashed_border_cache: SelectionDashedBorderCache, - slow_op_logger: SlowOperationLogger, - occluded_redraw_retry_until: Option, -} -impl WindowRenderer { - fn note_successful_frame_presented(&mut self) { - self.occluded_redraw_retry_until = None; - } - - fn mip_level_count(width: u32, height: u32) -> u32 { - let max_dim = width.max(height).max(1); - - (32_u32.saturating_sub(max_dim.leading_zeros())).max(1) - } - - fn create_mipgen_pipeline( - gpu: &GpuContext, - format: wgpu::TextureFormat, - ) -> (RenderPipeline, BindGroupLayout) { - let shader = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("rsnap-mipgen shader"), - source: ShaderSource::Wgsl(Cow::Borrowed(include_str!("mipgen.wgsl"))), - }); - let bind_group_layout = - gpu.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("rsnap-mipgen bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Texture { - multisampled: false, - view_dimension: TextureViewDimension::D2, - sample_type: TextureSampleType::Float { filterable: true }, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Sampler(SamplerBindingType::Filtering), - count: None, - }, - ], - }); - let pipeline_layout = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("rsnap-mipgen pipeline layout"), - bind_group_layouts: &[Some(&bind_group_layout)], - immediate_size: 0, - }); - let pipeline = gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("rsnap-mipgen pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - compilation_options: PipelineCompilationOptions::default(), - buffers: &[], - }, - primitive: wgpu::PrimitiveState { - topology: PrimitiveTopology::TriangleList, - strip_index_format: None, - front_face: FrontFace::Ccw, - cull_mode: None, - polygon_mode: PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: MultisampleState::default(), - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_main"), - compilation_options: PipelineCompilationOptions::default(), - targets: &[Some(wgpu::ColorTargetState { - format, - blend: None, - write_mask: ColorWrites::ALL, - })], - }), - multiview_mask: None, - cache: None, - }); - - (pipeline, bind_group_layout) - } - - fn create_mipgen_surface_pipeline( - gpu: &GpuContext, - format: wgpu::TextureFormat, - bind_group_layout: &BindGroupLayout, - ) -> RenderPipeline { - let shader = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("rsnap-mipgen fullscreen shader"), - source: ShaderSource::Wgsl(Cow::Borrowed(include_str!("mipgen.wgsl"))), - }); - let pipeline_layout = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("rsnap-mipgen fullscreen pipeline layout"), - bind_group_layouts: &[Some(bind_group_layout)], - immediate_size: 0, - }); - - gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("rsnap-mipgen fullscreen pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - compilation_options: PipelineCompilationOptions::default(), - buffers: &[], - }, - primitive: wgpu::PrimitiveState { - topology: PrimitiveTopology::TriangleList, - strip_index_format: None, - front_face: FrontFace::Ccw, - cull_mode: None, - polygon_mode: PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: MultisampleState::default(), - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_main"), - compilation_options: PipelineCompilationOptions::default(), - targets: &[Some(wgpu::ColorTargetState { - format, - blend: None, - write_mask: ColorWrites::ALL, - })], - }), - multiview_mask: None, - cache: None, - }) - } - - fn generate_mipmaps(&self, gpu: &GpuContext, texture: &Texture, mip_level_count: u32) { - if mip_level_count <= 1 { - return; - } - - let mut encoder = gpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("rsnap-mipgen encoder"), - }); - - for level in 1..mip_level_count { - let src_view = texture.create_view(&TextureViewDescriptor { - label: Some("rsnap-mipgen src view"), - format: None, - dimension: None, - usage: None, - aspect: TextureAspect::All, - base_mip_level: level - 1, - mip_level_count: Some(1), - base_array_layer: 0, - array_layer_count: Some(1), - }); - let dst_view = texture.create_view(&TextureViewDescriptor { - label: Some("rsnap-mipgen dst view"), - format: None, - dimension: None, - usage: None, - aspect: TextureAspect::All, - base_mip_level: level, - mip_level_count: Some(1), - base_array_layer: 0, - array_layer_count: Some(1), - }); - let bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("rsnap-mipgen bind group"), - layout: &self.mipgen_bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: BindingResource::TextureView(&src_view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: BindingResource::Sampler(&self.bg_sampler), - }, - ], - }); - let rpass_desc = wgpu::RenderPassDescriptor { - label: Some("rsnap-mipgen pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &dst_view, - depth_slice: None, - resolve_target: None, - ops: wgpu::Operations { - load: LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), - store: StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }; - let mut rpass = encoder.begin_render_pass(&rpass_desc).forget_lifetime(); - - rpass.set_pipeline(&self.mipgen_pipeline); - rpass.set_bind_group(0, &bind_group, &[]); - rpass.draw(0..3, 0..1); - } - - gpu.queue.submit(Some(encoder.finish())); - } - fn pick_surface_format(caps: &SurfaceCapabilities) -> wgpu::TextureFormat { - caps.formats - .iter() - .copied() - .find(|f| { - matches!( - f, - wgpu::TextureFormat::Bgra8UnormSrgb | wgpu::TextureFormat::Rgba8UnormSrgb - ) - }) - .or_else(|| caps.formats.iter().copied().find(wgpu::TextureFormat::is_srgb)) - .unwrap_or(caps.formats[0]) - } - - fn pick_surface_alpha(caps: &SurfaceCapabilities) -> CompositeAlphaMode { - caps.alpha_modes - .iter() - .copied() - .find(|m| matches!(m, wgpu::CompositeAlphaMode::PreMultiplied)) - .or_else(|| { - caps.alpha_modes - .iter() - .copied() - .find(|m| matches!(m, wgpu::CompositeAlphaMode::PostMultiplied)) - }) - .or_else(|| { - caps.alpha_modes - .iter() - .copied() - .find(|m| !matches!(m, wgpu::CompositeAlphaMode::Opaque)) - }) - .unwrap_or(caps.alpha_modes[0]) - } - - fn make_surface_config( - window: &winit::window::Window, - format: wgpu::TextureFormat, - alpha_mode: CompositeAlphaMode, - ) -> wgpu::SurfaceConfiguration { - let size = window.inner_size(); - - wgpu::SurfaceConfiguration { - usage: TextureUsages::RENDER_ATTACHMENT, - format, - width: size.width.max(1), - height: size.height.max(1), - present_mode: PresentMode::Fifo, - alpha_mode, - view_formats: vec![], - desired_maximum_frame_latency: 2, - } - } - - fn create_bg_sampler(gpu: &GpuContext) -> Sampler { - gpu.device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("rsnap-frozen-bg sampler"), - address_mode_u: AddressMode::ClampToEdge, - address_mode_v: AddressMode::ClampToEdge, - address_mode_w: AddressMode::ClampToEdge, - mag_filter: FilterMode::Linear, - min_filter: FilterMode::Linear, - mipmap_filter: MipmapFilterMode::Linear, - ..Default::default() - }) - } - - fn create_hud_blur_pipeline( - gpu: &GpuContext, - surface_format: wgpu::TextureFormat, - ) -> (RenderPipeline, BindGroupLayout) { - let shader = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("rsnap-hud-blur shader"), - source: ShaderSource::Wgsl(Cow::Borrowed(include_str!("hud_blur.wgsl"))), - }); - let bind_group_layout = - gpu.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("rsnap-hud-blur bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Texture { - multisampled: false, - view_dimension: TextureViewDimension::D2, - sample_type: TextureSampleType::Float { filterable: true }, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Sampler(SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Buffer { - ty: BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: BufferSize::new( - mem::size_of::() as u64 - ), - }, - count: None, - }, - ], - }); - let pipeline_layout = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("rsnap-hud-blur pipeline layout"), - bind_group_layouts: &[Some(&bind_group_layout)], - immediate_size: 0, - }); - let pipeline = gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("rsnap-hud-blur pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - compilation_options: PipelineCompilationOptions::default(), - buffers: &[], - }, - primitive: wgpu::PrimitiveState { - topology: PrimitiveTopology::TriangleList, - strip_index_format: None, - front_face: FrontFace::Ccw, - cull_mode: None, - polygon_mode: PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: MultisampleState::default(), - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_main"), - compilation_options: PipelineCompilationOptions::default(), - targets: &[Some(wgpu::ColorTargetState { - format: surface_format, - blend: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING), - write_mask: ColorWrites::ALL, - })], - }), - multiview_mask: None, - cache: None, - }); - - (pipeline, bind_group_layout) - } - - fn apply_pending_reconfigure(&mut self, gpu: &GpuContext) { - if self.needs_reconfigure { - self.reconfigure(gpu); - - self.needs_reconfigure = false; - } - } - - fn prepare_egui_input( - &mut self, - gpu: &GpuContext, - pointer_state: Option, - pixels_per_point_override: Option, - ) -> (PhysicalSize, f32, egui::RawInput) { - // egui animations depend on a monotonic time base. Without this, animation state can appear - // to "snap" only after an input event (e.g. CursorMoved) triggers a new frame. - let now = Instant::now(); - let elapsed = now.duration_since(self.egui_start_time).as_secs_f64().max(0.0); - let predicted_dt = - now.duration_since(self.egui_last_frame_time).as_secs_f32().clamp(0.0, 0.5); - - self.egui_last_frame_time = now; - - // Keep the wgpu surface configuration in sync with the OS-reported window size. - // - // On macOS we can observe transient mismatches where `surface_config` is smaller than the - // actual window size (e.g. right after entering Frozen mode), which causes egui to build - // a smaller `screen_rect` and results in UI elements appearing clipped/offset until a - // later redraw or input event triggers a resize/reconfigure. - let actual_size = self.window.inner_size(); - let desired_w = actual_size.width.max(1); - let desired_h = actual_size.height.max(1); - - if self.surface_config.width != desired_w || self.surface_config.height != desired_h { - tracing::debug!( - window_id = ?self.window.id(), - actual_size_px = ?actual_size, - old_surface_px = ?(self.surface_config.width, self.surface_config.height), - new_surface_px = ?(desired_w, desired_h), - window_scale_factor = self.window.scale_factor(), - pixels_per_point_override, - "Reconfiguring wgpu surface to match window." - ); - - self.surface_config.width = desired_w; - self.surface_config.height = desired_h; - self.needs_reconfigure = false; - - self.reconfigure(gpu); - } - - let size = PhysicalSize::new(self.surface_config.width, self.surface_config.height); - let pixels_per_point = pixels_per_point_override - .filter(|v| *v > 0.0) - .unwrap_or_else(|| self.window.scale_factor() as f32); - let screen_size_points = - Vec2::new(size.width as f32 / pixels_per_point, size.height as f32 / pixels_per_point); - let max_texture_side = gpu.device.limits().max_texture_dimension_2d as usize; - - self.egui_ctx.input_mut(|i| i.max_texture_side = max_texture_side); - - let mut raw_input = egui::RawInput { - screen_rect: Some(Rect::from_min_size(Pos2::ZERO, screen_size_points)), - focused: true, - time: Some(elapsed), - predicted_dt, - ..Default::default() - }; - let mut events = Vec::new(); - - raw_input.max_texture_side = Some(max_texture_side); - - if let Some(pointer) = pointer_state { - events.push(Event::PointerMoved(pointer.cursor_local)); - - if pointer.left_button_went_down { - events.push(Event::PointerButton { - pos: pointer.cursor_local, - button: PointerButton::Primary, - pressed: true, - modifiers: egui::Modifiers::default(), - }); - } - if pointer.left_button_went_up { - events.push(Event::PointerButton { - pos: pointer.cursor_local, - button: PointerButton::Primary, - pressed: false, - modifiers: egui::Modifiers::default(), - }); - } - } - - if !events.is_empty() { - raw_input.events = events; - } - - if let Some(viewport) = raw_input.viewports.get_mut(&ViewportId::ROOT) { - viewport.native_pixels_per_point = Some(pixels_per_point); - viewport.inner_rect = raw_input.screen_rect; - viewport.focused = Some(true); - } - - (size, pixels_per_point, raw_input) - } - - #[allow(clippy::too_many_arguments)] - fn run_egui( - &mut self, - raw_input: egui::RawInput, - state: &OverlayState, - monitor: MonitorRect, - can_draw_hud: bool, - hud_local_cursor_override: Option, - hud_compact: bool, - show_hud_blur: bool, - hud_anchor: HudAnchor, - toolbar_placement: ToolbarPlacement, - show_alt_hint_keycap: bool, - hud_blur_active: bool, - hud_opaque: bool, - hud_opacity: f32, - hud_milk_amount: f32, - hud_tint_hue: f32, - theme: HudTheme, - selection_flow_enabled: bool, - selection_flow_stroke_width_px: f32, - needs_frozen_surface_bg: bool, - show_frozen_capture_affordance: bool, - frozen_capture_is_fullscreen_fallback: bool, - frozen_toolbar_reserved_rect: Option, - selection_flow_geometry_cache: &mut SelectionFlowGeometryCache, - selection_dashed_border_cache: &mut SelectionDashedBorderCache, - mut toolbar_state: Option<&mut FrozenToolbarState>, - toolbar_pointer: Option, - ) -> (FullOutput, Option) { - let hud_data = if can_draw_hud { - state.cursor.and_then(|cursor| { - let local_cursor = - hud_local_cursor_override.or_else(|| global_to_local(cursor, monitor))?; - - Some((cursor, local_cursor)) - }) - } else { - None - }; - let mut hud_pill = None; - let mut _show_selection_affordance = false; - let egui_ctx = self.egui_ctx.clone(); - let full_output = egui_ctx.run_ui(raw_input, |ui| { - let ctx = ui.ctx(); - - Self::render_frozen_toolbar_ui( - ui.ctx(), - state, - monitor, - theme, - toolbar_placement, - hud_blur_active, - hud_opaque, - hud_opacity, - hud_milk_amount, - hud_tint_hue, - toolbar_state.as_deref_mut(), - toolbar_pointer, - &mut hud_pill, - ); - - if let Some((cursor, local_cursor)) = hud_data { - let _ = show_hud_blur; - - self.render_hud( - ctx, - state, - monitor, - cursor, - local_cursor, - hud_compact, - hud_anchor, - show_alt_hint_keycap, - hud_blur_active, - hud_opaque, - hud_opacity, - hud_milk_amount, - hud_tint_hue, - theme, - &mut hud_pill, - ); - } - - if matches!(state.mode, OverlayMode::Live) && !can_draw_hud { - let screen_rect = ctx.input(|i| i.viewport_rect()); - let layer = LayerId::new( - Order::Foreground, - Id::new(format!("live-capture-{}", monitor.id)), - ); - let painter = ctx.layer_painter(layer); - - _show_selection_affordance |= Self::render_live_capture_affordances( - ctx, - &painter, - state, - monitor, - screen_rect, - theme, - selection_flow_enabled, - selection_flow_stroke_width_px, - selection_flow_geometry_cache, - ); - } - if matches!(state.mode, OverlayMode::Frozen) - && (needs_frozen_surface_bg || show_frozen_capture_affordance) - && state.monitor == Some(monitor) - && state.frozen_capture_rect.is_some() - { - let screen_rect = ctx.input(|i| i.viewport_rect()); - - _show_selection_affordance |= Self::render_frozen_capture_affordance( - ctx, - state, - monitor, - screen_rect, - theme, - frozen_toolbar_reserved_rect, - frozen_capture_is_fullscreen_fallback, - selection_flow_enabled, - selection_flow_stroke_width_px, - selection_flow_geometry_cache, - selection_dashed_border_cache, - ); - } - }); - - (full_output, hud_pill) - } - - #[allow(clippy::too_many_arguments)] - fn render_live_capture_affordances( - ctx: &egui::Context, - painter: &Painter, - state: &OverlayState, - monitor: MonitorRect, - screen_rect: Rect, - theme: HudTheme, - selection_flow_enabled: bool, - selection_flow_stroke_width_px: f32, - selection_flow_geometry_cache: &mut SelectionFlowGeometryCache, - ) -> bool { - let mut has_rect = false; - - if !matches!(state.mode, OverlayMode::Live) { - return false; - } - - let primary_not_down = !ctx.input(|i| i.pointer.primary_down()); - - if let Some(hovered_window) = state.hovered_window_rect - && hovered_window.monitor_id == monitor.id - { - let rect = Rect::from_min_size( - Pos2::new(hovered_window.rect.x as f32, hovered_window.rect.y as f32), - Vec2::new(hovered_window.rect.width as f32, hovered_window.rect.height as f32), - ); - let rect = rect.intersect(screen_rect); - - if rect.width() >= LIVE_DRAG_START_THRESHOLD_PX - && rect.height() >= LIVE_DRAG_START_THRESHOLD_PX - { - Self::render_live_drag_selection_scrim(painter, rect, screen_rect, theme); - - if selection_flow_enabled { - Self::render_selection_flow_ring( - painter, - rect, - ctx, - theme, - SelectionFlowStyle::Band, - selection_flow_stroke_width_px, - selection_flow_geometry_cache, - ); - } - - has_rect = true; - } - } - if let Some(rect) = Self::live_drag_focus_rect(state, monitor, screen_rect) { - Self::render_live_drag_selection_scrim(painter, rect, screen_rect, theme); - - has_rect = true; - } - if let Some(target) = - Self::live_capture_size_badge_target(state, monitor, screen_rect, primary_not_down) - { - Self::render_selection_size_badge( - ctx, - painter, - monitor, - screen_rect, - target, - None, - theme, - ); - - has_rect = true; - } - - let has_hovered_window_for_this_monitor = - state.hovered_window_rect.is_some_and(|hovered| hovered.monitor_id == monitor.id); - let has_drag_rect_for_this_monitor = - state.drag_rect.is_some_and(|drag_rect| drag_rect.monitor_id == monitor.id); - let cursor_on_monitor = state.cursor.is_some_and(|cursor| monitor.contains(cursor)); - - if selection_flow_enabled - && !has_hovered_window_for_this_monitor - && !has_drag_rect_for_this_monitor - && cursor_on_monitor - && primary_not_down - { - Self::render_selection_flow_ring( - painter, - screen_rect, - ctx, - theme, - SelectionFlowStyle::Band, - selection_flow_stroke_width_px, - selection_flow_geometry_cache, - ); - - has_rect = true; - } - - has_rect - } - - #[allow(clippy::too_many_arguments)] - fn render_frozen_capture_affordance( - ctx: &egui::Context, - state: &OverlayState, - monitor: MonitorRect, - screen_rect: Rect, - theme: HudTheme, - frozen_toolbar_reserved_rect: Option, - frozen_capture_is_fullscreen_fallback: bool, - selection_flow_enabled: bool, - selection_flow_stroke_width_px: f32, - selection_flow_geometry_cache: &mut SelectionFlowGeometryCache, - selection_dashed_border_cache: &mut SelectionDashedBorderCache, - ) -> bool { - let Some(rect) = Self::frozen_capture_focus_rect(state, screen_rect) else { - return false; - }; - let layer = - LayerId::new(Order::Foreground, Id::new(format!("frozen-pending-{}", monitor.id))); - let painter = ctx.layer_painter(layer); - - if state.frozen_image.is_some() { - let mut has_affordance = Self::render_frozen_selection_scrim( - &painter, - rect, - screen_rect, - theme, - selection_dashed_border_cache, - ); - - if let Some(target) = Self::frozen_capture_size_badge_target(state, screen_rect) { - Self::render_selection_size_badge( - ctx, - &painter, - monitor, - screen_rect, - target, - frozen_toolbar_reserved_rect, - theme, - ); - - has_affordance = true; - } - - return has_affordance; - } - if !selection_flow_enabled { - let mut has_affordance = Self::render_frozen_selection_scrim( - &painter, - rect, - screen_rect, - theme, - selection_dashed_border_cache, - ); - - if let Some(target) = Self::frozen_capture_size_badge_target(state, screen_rect) { - Self::render_selection_size_badge( - ctx, - &painter, - monitor, - screen_rect, - target, - frozen_toolbar_reserved_rect, - theme, - ); - - has_affordance = true; - } - - return has_affordance; - } - - Self::render_selection_flow_ring( - &painter, - rect, - ctx, - theme, - if frozen_capture_is_fullscreen_fallback { - SelectionFlowStyle::Band - } else { - SelectionFlowStyle::FullBorder - }, - selection_flow_stroke_width_px, - selection_flow_geometry_cache, - ); - - if let Some(target) = Self::frozen_capture_size_badge_target(state, screen_rect) { - Self::render_selection_size_badge( - ctx, - &painter, - monitor, - screen_rect, - target, - frozen_toolbar_reserved_rect, - theme, - ); - } - - true - } - - fn frozen_capture_focus_rect(state: &OverlayState, screen_rect: Rect) -> Option { - let capture_rect = state.frozen_capture_rect?; - - Some(Self::selection_focus_rect(capture_rect, screen_rect)) - } - - fn live_drag_focus_rect( - state: &OverlayState, - monitor: MonitorRect, - screen_rect: Rect, - ) -> Option { - let drag_rect = state.drag_rect?; - - if drag_rect.monitor_id != monitor.id { - return None; - } - - let rect = Self::selection_focus_rect(drag_rect.rect, screen_rect); - - if rect.width() < LIVE_DRAG_START_THRESHOLD_PX - || rect.height() < LIVE_DRAG_START_THRESHOLD_PX - { - return None; - } - - Some(rect) - } - - fn selection_focus_rect(rect: RectPoints, screen_rect: Rect) -> Rect { - Rect::from_min_size( - Pos2::new(rect.x as f32, rect.y as f32), - Vec2::new(rect.width as f32, rect.height as f32), - ) - .intersect(screen_rect) - } - - fn selection_size_badge_target_from_rect( - rect_points: RectPoints, - screen_rect: Rect, - ) -> Option { - let rect = Self::selection_focus_rect(rect_points, screen_rect); - - if rect.width() <= 0.0 || rect.height() <= 0.0 { - return None; - } - - Some(SelectionSizeBadgeTarget { rect, size_points: rect_points }) - } - - fn live_capture_size_badge_target( - state: &OverlayState, - monitor: MonitorRect, - screen_rect: Rect, - primary_not_down: bool, - ) -> Option { - if let Some(drag_rect) = state.drag_rect - && drag_rect.monitor_id == monitor.id - && let Some(target) = - Self::selection_size_badge_target_from_rect(drag_rect.rect, screen_rect) - { - return Some(target); - } - if let Some(hovered_window) = state.hovered_window_rect - && hovered_window.monitor_id == monitor.id - && let Some(target) = - Self::selection_size_badge_target_from_rect(hovered_window.rect, screen_rect) - { - return Some(target); - } - - if primary_not_down && state.cursor.is_some_and(|cursor| monitor.contains(cursor)) { - return Some(SelectionSizeBadgeTarget { - rect: screen_rect, - size_points: RectPoints::new(0, 0, monitor.width, monitor.height), - }); - } - - None - } - - fn frozen_capture_size_badge_target( - state: &OverlayState, - screen_rect: Rect, - ) -> Option { - let capture_rect = state.frozen_capture_rect?; - - Self::selection_size_badge_target_from_rect(capture_rect, screen_rect) - } - - fn frozen_toolbar_reserved_rect( - state: &OverlayState, - monitor: MonitorRect, - screen_rect: Rect, - toolbar_placement: ToolbarPlacement, - toolbar_state: &FrozenToolbarState, - ) -> Option { - if !toolbar_state.visible - || !matches!(state.mode, OverlayMode::Frozen) - || state.monitor != Some(monitor) - { - return None; - } - - let capture_rect = Self::frozen_toolbar_capture_rect(state, monitor, screen_rect); - let toolbar_size = Self::frozen_toolbar_size(toolbar_state); - let default_pos = Self::frozen_toolbar_default_pos( - screen_rect, - capture_rect, - toolbar_size, - toolbar_placement, - ); - let toolbar_pos = toolbar_state.floating_position.unwrap_or(default_pos); - - if !frozen_toolbar_matches_default_slot(toolbar_pos, default_pos) { - return None; - } - - Some(Rect::from_min_size(toolbar_pos, toolbar_size)) - } - - fn selection_size_badge_text(monitor: MonitorRect, size_points: RectPoints) -> String { - let size_pixels = monitor.local_rect_to_pixels(size_points); - - format!("{}x{}", size_pixels.width, size_pixels.height) - } - - fn selection_size_badge_visual_overflow(pixels_per_point: f32) -> SelectionSizeBadgePadding { - let points_per_pixel = 1.0 / pixels_per_point.max(f32::MIN_POSITIVE); - let outline_offset = SELECTION_SIZE_BADGE_OUTLINE_OFFSET_PX * points_per_pixel; - let near_shadow_offset = SELECTION_SIZE_BADGE_NEAR_SHADOW_OFFSET_PX * points_per_pixel; - let far_shadow_offset = SELECTION_SIZE_BADGE_FAR_SHADOW_OFFSET_PX * points_per_pixel; - - SelectionSizeBadgePadding { - left: outline_offset, - right: outline_offset.max(near_shadow_offset), - top: outline_offset, - bottom: outline_offset.max(near_shadow_offset).max(far_shadow_offset), - } - } - - fn selection_size_badge_layout( - ctx: &egui::Context, - text: &str, - theme: HudTheme, - pixels_per_point: f32, - ) -> SelectionSizeBadgeLayout { - let text_color = Self::hud_text_colors(theme).0; - let font_id = FontId::new(SELECTION_SIZE_BADGE_FONT_SIZE_POINTS, FontFamily::Monospace); - let galley = ctx - .fonts_mut(|fonts| fonts.layout_no_wrap(text.to_owned(), font_id.clone(), text_color)); - let text_size = galley.size(); - let visual_overflow = Self::selection_size_badge_visual_overflow(pixels_per_point); - let base_padding = SELECTION_SIZE_BADGE_TEXT_OUTSET_POINTS * 0.5; - let padding = SelectionSizeBadgePadding { - left: base_padding + visual_overflow.left, - right: base_padding + visual_overflow.right, - top: base_padding + visual_overflow.top, - bottom: base_padding + visual_overflow.bottom, - }; - - SelectionSizeBadgeLayout { - text_size, - badge_size: Vec2::new( - (text_size.x + padding.left + padding.right).ceil(), - (text_size.y + padding.top + padding.bottom).ceil(), - ), - padding, - } - } - - #[cfg(test)] - fn selection_size_badge_rect(screen_rect: Rect, capture_rect: Rect, badge_size: Vec2) -> Rect { - Self::selection_size_badge_rect_with_reserved_rect( - screen_rect, - capture_rect, - badge_size, - None, - ) - } - - fn selection_size_badge_rect_with_reserved_rect( - screen_rect: Rect, - capture_rect: Rect, - badge_size: Vec2, - reserved_rect: Option, - ) -> Rect { - // Geometry priority contract: - // 1. Keep the badge fully visible inside the viewport whenever the viewport can fit it. - // 2. Keep the badge right-aligned to the capture rect whenever that still satisfies (1). - // 3. Prefer the below-capture slot when it fits and does not hit a reserved rect. - // 4. Otherwise stay inside the capture while avoiding the reserved rect when a - // non-overlapping inside band exists. - // 5. If the reserved rect exhausts the in-capture space, try a right-aligned - // above-capture slot before accepting overlap. - let min_x = screen_rect.min.x; - let max_x = (screen_rect.max.x - badge_size.x).max(min_x); - let aligned_x = capture_rect.max.x - badge_size.x; - let x = aligned_x.clamp(min_x, max_x); - let below_y = capture_rect.max.y + SELECTION_SIZE_BADGE_GAP_PX; - let below_rect = Rect::from_min_size(Pos2::new(x, below_y), badge_size); - let fits_below = below_rect.max.y - <= screen_rect.max.y - SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX - && reserved_rect.is_none_or(|rect| !below_rect.intersects(rect)); - - if fits_below { - return below_rect; - } - - let screen_max_y = (screen_rect.max.y - badge_size.y).max(screen_rect.min.y); - let max_inside_y = - (capture_rect.max.y - badge_size.y).min(screen_max_y).max(screen_rect.min.y); - let min_inside_y = capture_rect.min.y.min(max_inside_y).max(screen_rect.min.y); - let preferred_inside_y = - (capture_rect.max.y - SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX - badge_size.y) - .clamp(min_inside_y, max_inside_y); - let preferred_inside_rect = - Rect::from_min_size(Pos2::new(x, preferred_inside_y), badge_size); - - if reserved_rect.is_none_or(|rect| !preferred_inside_rect.intersects(rect)) { - return preferred_inside_rect; - } - - if let Some(reserved_rect) = reserved_rect { - let upper_y = - reserved_rect.min.y - SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX - badge_size.y; - let lower_y = reserved_rect.max.y + SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX; - let candidate_ys = if reserved_rect.center().y <= capture_rect.center().y { - [Some(lower_y), Some(upper_y)] - } else { - [Some(upper_y), Some(lower_y)] - }; - - for candidate_y in candidate_ys.into_iter().flatten() { - if candidate_y < min_inside_y || candidate_y > max_inside_y { - continue; - } - - let candidate_rect = Rect::from_min_size(Pos2::new(x, candidate_y), badge_size); - - if !candidate_rect.intersects(reserved_rect) { - return candidate_rect; - } - } - - let above_y = capture_rect.min.y - SELECTION_SIZE_BADGE_GAP_PX - badge_size.y; - - if above_y >= screen_rect.min.y { - let above_rect = Rect::from_min_size(Pos2::new(x, above_y), badge_size); - - if !above_rect.intersects(reserved_rect) { - return above_rect; - } - } - } - - preferred_inside_rect - } - - fn snap_points_to_pixel_grid(value: f32, pixels_per_point: f32) -> f32 { - let pixels_per_point = pixels_per_point.max(f32::MIN_POSITIVE); - - (value * pixels_per_point).round() / pixels_per_point - } - - fn snap_pos_to_pixel_grid(pos: Pos2, pixels_per_point: f32) -> Pos2 { - Pos2::new( - Self::snap_points_to_pixel_grid(pos.x, pixels_per_point), - Self::snap_points_to_pixel_grid(pos.y, pixels_per_point), - ) - } - - fn selection_size_badge_text_anchor( - badge_rect: Rect, - layout: SelectionSizeBadgeLayout, - pixels_per_point: f32, - ) -> Pos2 { - Self::snap_pos_to_pixel_grid( - Pos2::new( - badge_rect.max.x - layout.padding.right, - badge_rect.min.y + layout.padding.top + layout.text_size.y * 0.5, - ), - pixels_per_point, - ) - } - - #[cfg(test)] - fn selection_size_badge_visual_bounds( - text_anchor: Pos2, - text_size: Vec2, - pixels_per_point: f32, - ) -> Rect { - let visual_overflow = Self::selection_size_badge_visual_overflow(pixels_per_point); - - Rect::from_min_max( - Pos2::new( - text_anchor.x - text_size.x - visual_overflow.left, - text_anchor.y - text_size.y * 0.5 - visual_overflow.top, - ), - Pos2::new( - text_anchor.x + visual_overflow.right, - text_anchor.y + text_size.y * 0.5 + visual_overflow.bottom, - ), - ) - } - - fn selection_size_badge_text_colors(theme: HudTheme) -> (Color32, Color32, Color32, Color32) { - match theme { - HudTheme::Dark => ( - Color32::from_rgba_unmultiplied(255, 255, 255, 248), - Color32::from_rgba_unmultiplied(0, 0, 0, 108), - Color32::from_rgba_unmultiplied(0, 0, 0, 154), - Color32::from_rgba_unmultiplied(0, 0, 0, 72), - ), - HudTheme::Light => ( - Color32::from_rgba_unmultiplied(255, 255, 255, 252), - Color32::from_rgba_unmultiplied(0, 0, 0, 156), - Color32::from_rgba_unmultiplied(0, 0, 0, 196), - Color32::from_rgba_unmultiplied(0, 0, 0, 96), - ), - } - } - - fn render_selection_size_badge( - ctx: &egui::Context, - painter: &Painter, - monitor: MonitorRect, - screen_rect: Rect, - target: SelectionSizeBadgeTarget, - reserved_rect: Option, - theme: HudTheme, - ) { - let text = Self::selection_size_badge_text(monitor, target.size_points); - let pixels_per_point = painter.pixels_per_point(); - let layout = Self::selection_size_badge_layout(ctx, &text, theme, pixels_per_point); - let badge_rect = Self::selection_size_badge_rect_with_reserved_rect( - screen_rect, - target.rect, - layout.badge_size, - reserved_rect, - ); - let font_id = FontId::new(SELECTION_SIZE_BADGE_FONT_SIZE_POINTS, FontFamily::Monospace); - let points_per_pixel = 1.0 / pixels_per_point.max(f32::MIN_POSITIVE); - let outline_offset = SELECTION_SIZE_BADGE_OUTLINE_OFFSET_PX * points_per_pixel; - let near_shadow_offset = SELECTION_SIZE_BADGE_NEAR_SHADOW_OFFSET_PX * points_per_pixel; - let far_shadow_offset = SELECTION_SIZE_BADGE_FAR_SHADOW_OFFSET_PX * points_per_pixel; - let text_anchor = - Self::selection_size_badge_text_anchor(badge_rect, layout, pixels_per_point); - let (text_color, outline_color, near_shadow_color, far_shadow_color) = - Self::selection_size_badge_text_colors(theme); - - painter.text( - Self::snap_pos_to_pixel_grid( - text_anchor + Vec2::new(0.0, far_shadow_offset), - pixels_per_point, - ), - Align2::RIGHT_CENTER, - text.clone(), - font_id.clone(), - far_shadow_color, - ); - - for offset in [ - Vec2::new(-outline_offset, 0.0), - Vec2::new(outline_offset, 0.0), - Vec2::new(0.0, -outline_offset), - Vec2::new(0.0, outline_offset), - ] { - painter.text( - Self::snap_pos_to_pixel_grid(text_anchor + offset, pixels_per_point), - Align2::RIGHT_CENTER, - text.clone(), - font_id.clone(), - outline_color, - ); - } - - painter.text( - Self::snap_pos_to_pixel_grid( - text_anchor + Vec2::new(near_shadow_offset, near_shadow_offset), - pixels_per_point, - ), - Align2::RIGHT_CENTER, - text.clone(), - font_id.clone(), - near_shadow_color, - ); - painter.text(text_anchor, Align2::RIGHT_CENTER, text, font_id, text_color); - } - - fn frozen_selection_scrim_rects(screen_rect: Rect, focus_rect: Rect) -> [Rect; 4] { - [ - Rect::from_min_max(screen_rect.min, Pos2::new(screen_rect.max.x, focus_rect.min.y)), - Rect::from_min_max(Pos2::new(screen_rect.min.x, focus_rect.max.y), screen_rect.max), - Rect::from_min_max( - Pos2::new(screen_rect.min.x, focus_rect.min.y), - Pos2::new(focus_rect.min.x, focus_rect.max.y), - ), - Rect::from_min_max( - Pos2::new(focus_rect.max.x, focus_rect.min.y), - Pos2::new(screen_rect.max.x, focus_rect.max.y), - ), - ] - } - - fn frozen_selection_scrim_color(theme: HudTheme) -> Color32 { - let alpha = match theme { - HudTheme::Light => FROZEN_SELECTION_SCRIM_ALPHA_LIGHT, - HudTheme::Dark => FROZEN_SELECTION_SCRIM_ALPHA_DARK, - }; - - Color32::from_rgba_unmultiplied(0, 0, 0, alpha) - } - - fn live_drag_selection_scrim_color(theme: HudTheme) -> Color32 { - let alpha = match theme { - HudTheme::Light => LIVE_DRAG_SELECTION_SCRIM_ALPHA_LIGHT, - HudTheme::Dark => LIVE_DRAG_SELECTION_SCRIM_ALPHA_DARK, - }; - - Color32::from_rgba_unmultiplied(0, 0, 0, alpha) - } - - fn render_frozen_selection_scrim( - painter: &Painter, - focus_rect: Rect, - screen_rect: Rect, - theme: HudTheme, - selection_dashed_border_cache: &mut SelectionDashedBorderCache, - ) -> bool { - Self::render_selection_scrim( - painter, - focus_rect, - screen_rect, - Self::frozen_selection_scrim_color(theme), - selection_dashed_border_cache, - ) - } - - fn render_live_drag_selection_scrim( - painter: &Painter, - focus_rect: Rect, - screen_rect: Rect, - theme: HudTheme, - ) -> bool { - Self::render_selection_scrim_fill( - painter, - focus_rect, - screen_rect, - Self::live_drag_selection_scrim_color(theme), - ) - } - - fn render_selection_scrim( - painter: &Painter, - focus_rect: Rect, - screen_rect: Rect, - scrim_fill: Color32, - selection_dashed_border_cache: &mut SelectionDashedBorderCache, - ) -> bool { - let drew_scrim = - Self::render_selection_scrim_fill(painter, focus_rect, screen_rect, scrim_fill); - let drew_border = Self::render_selection_dashed_border( - painter, - focus_rect, - screen_rect, - selection_dashed_border_cache, - ); - - drew_scrim || drew_border - } - - fn render_selection_scrim_fill( - painter: &Painter, - focus_rect: Rect, - screen_rect: Rect, - scrim_fill: Color32, - ) -> bool { - let scrim_rects = Self::frozen_selection_scrim_rects(screen_rect, focus_rect); - let mut drew_scrim = false; - - for rect in scrim_rects { - if rect.width() <= 0.0 || rect.height() <= 0.0 { - continue; - } - - painter.rect_filled(rect, 0.0, scrim_fill); - - drew_scrim = true; - } - - drew_scrim - } - - fn render_selection_dashed_border( - painter: &Painter, - focus_rect: Rect, - screen_rect: Rect, - selection_dashed_border_cache: &mut SelectionDashedBorderCache, - ) -> bool { - let metrics = Self::selection_dashed_border_metrics(painter.pixels_per_point()); - let border_outset = - Self::selection_dashed_border_outset(metrics.stroke_width, painter.pixels_per_point()); - let Some(border_rect) = - Self::selection_dashed_border_rect(screen_rect, focus_rect, border_outset) - else { - return false; - }; - let segments = Self::selection_dashed_border_cached_segments( - selection_dashed_border_cache, - border_rect, - metrics.dash_length, - metrics.gap_length, - ); - - if segments.is_empty() { - return false; - } - - let stroke = Stroke::new( - metrics.stroke_width, - Color32::from_rgba_unmultiplied(255, 255, 255, SELECTION_DASHED_BORDER_ALPHA), - ); - - for segment in segments { - painter.add(Shape::line_segment(*segment, stroke)); - } - - true - } - - fn selection_dashed_border_metrics(pixels_per_point: f32) -> SelectionDashedBorderMetrics { - let points_per_pixel = 1.0 / pixels_per_point.max(f32::MIN_POSITIVE); - - SelectionDashedBorderMetrics { - stroke_width: SELECTION_DASHED_BORDER_WIDTH_PX * points_per_pixel, - dash_length: SELECTION_DASHED_BORDER_DASH_LENGTH_PX * points_per_pixel, - gap_length: SELECTION_DASHED_BORDER_GAP_LENGTH_PX * points_per_pixel, - } - } - - fn selection_dashed_border_rect( - screen_rect: Rect, - focus_rect: Rect, - border_outset: f32, - ) -> Option { - Self::selection_has_outside_region(screen_rect, focus_rect) - .then_some(focus_rect.expand(border_outset)) - } - - fn selection_dashed_border_outset(stroke_width: f32, pixels_per_point: f32) -> f32 { - let feathering = 1.0 / pixels_per_point.max(f32::MIN_POSITIVE); - - // Match epaint's outer stroke radius so the anti-aliased dashed keyline - // stays fully in the scrim instead of bleeding into the capture rect. - (stroke_width + feathering) * 0.5 - } - - fn selection_has_outside_region(screen_rect: Rect, focus_rect: Rect) -> bool { - Self::frozen_selection_scrim_rects(screen_rect, focus_rect) - .into_iter() - .any(|rect| rect.width() > 0.0 && rect.height() > 0.0) - } - - fn selection_dashed_border_segments( - rect: Rect, - target_dash_length: f32, - target_gap_length: f32, - ) -> Vec<[Pos2; 2]> { - let perimeter = Self::selection_dashed_border_perimeter(rect); - - if perimeter <= 0.0 { - return Vec::new(); - } - - let mut segments = Vec::new(); - - for (dash_start, dash_end) in Self::selection_dashed_border_dash_ranges( - perimeter, - target_dash_length, - target_gap_length, - ) { - Self::append_selection_dashed_border_dash_segments( - rect, - dash_start, - dash_end, - &mut segments, - ); - } - - segments - } - - fn selection_dashed_border_cached_segments( - selection_dashed_border_cache: &mut SelectionDashedBorderCache, - rect: Rect, - target_dash_length: f32, - target_gap_length: f32, - ) -> &[[Pos2; 2]] { - let key = SelectionDashedBorderCacheKey::new(rect, target_dash_length, target_gap_length); - - if selection_dashed_border_cache.key != Some(key) { - selection_dashed_border_cache.segments.clear(); - selection_dashed_border_cache.segments.extend(Self::selection_dashed_border_segments( - rect, - target_dash_length, - target_gap_length, - )); - - selection_dashed_border_cache.key = Some(key); - } - - selection_dashed_border_cache.segments.as_slice() - } - - fn selection_dashed_border_dash_ranges( - perimeter: f32, - target_dash_length: f32, - target_gap_length: f32, - ) -> Vec<(f32, f32)> { - if perimeter <= 0.0 { - return Vec::new(); - } - - let target_cycle = (target_dash_length + target_gap_length).max(f32::MIN_POSITIVE); - let cycle_count = (perimeter / target_cycle).round().max(1.0) as usize; - let cycle_span = perimeter / cycle_count as f32; - let dash_length = target_dash_length.min(cycle_span); - - (0..cycle_count) - .map(|index| { - let dash_start = index as f32 * cycle_span; - - (dash_start, dash_start + dash_length) - }) - .collect() - } - - fn append_selection_dashed_border_dash_segments( - rect: Rect, - dash_start: f32, - dash_end: f32, - segments: &mut Vec<[Pos2; 2]>, - ) { - let mut segment_start = dash_start; - - for corner_distance in Self::selection_dashed_border_corner_distances(rect) { - if segment_start >= dash_end { - break; - } - if corner_distance <= segment_start || corner_distance >= dash_end { - continue; - } - - Self::push_selection_dashed_border_segment( - rect, - segment_start, - corner_distance, - segments, - ); - - segment_start = corner_distance; - } - - if segment_start < dash_end { - Self::push_selection_dashed_border_segment(rect, segment_start, dash_end, segments); - } - } - - fn push_selection_dashed_border_segment( - rect: Rect, - start_distance: f32, - end_distance: f32, - segments: &mut Vec<[Pos2; 2]>, - ) { - let start = Self::selection_dashed_border_point_at(rect, start_distance); - let end = Self::selection_dashed_border_point_at(rect, end_distance); - - if start != end { - segments.push([start, end]); - } - } - - fn selection_dashed_border_point_at(rect: Rect, distance: f32) -> Pos2 { - let width = rect.width(); - let height = rect.height(); - let perimeter = Self::selection_dashed_border_perimeter(rect); - let distance = distance.rem_euclid(perimeter); - - if distance < width { - return Pos2::new(rect.min.x + distance, rect.min.y); - } - if distance < width + height { - return Pos2::new(rect.max.x, rect.min.y + (distance - width)); - } - if distance < width * 2.0 + height { - return Pos2::new(rect.max.x - (distance - width - height), rect.max.y); - } - - Pos2::new(rect.min.x, rect.max.y - (distance - width * 2.0 - height)) - } - - fn selection_dashed_border_corner_distances(rect: Rect) -> [f32; 4] { - let width = rect.width(); - let height = rect.height(); - - [width, width + height, width * 2.0 + height, Self::selection_dashed_border_perimeter(rect)] - } - - fn selection_dashed_border_perimeter(rect: Rect) -> f32 { - if rect.width() <= 0.0 || rect.height() <= 0.0 { - return 0.0; - } - - (rect.width() + rect.height()) * 2.0 - } - - fn render_selection_flow_ring( - painter: &Painter, - rect: Rect, - ctx: &egui::Context, - theme: HudTheme, - style: SelectionFlowStyle, - selection_flow_stroke_width_px: f32, - selection_flow_geometry_cache: &mut SelectionFlowGeometryCache, - ) { - if rect.width() < LIVE_DRAG_START_THRESHOLD_PX - || rect.height() < LIVE_DRAG_START_THRESHOLD_PX - { - return; - } - - let corner_radius = Self::selection_flow_corner_radius(rect); - let perimeter = Self::selection_flow_perimeter(rect, corner_radius); - let time = ctx.input(|i| i.time) as f32; - let sample_count = Self::selection_flow_sample_count(perimeter); - let seam_offset = if rect.width() > corner_radius * 2.0 { - (rect.width() - corner_radius * 2.0) * 0.5 - } else { - 0.0 - }; - let (samples, normals) = Self::selection_flow_cached_geometry( - selection_flow_geometry_cache, - rect, - corner_radius, - sample_count, - seam_offset, - ); - let base_alpha_scale = 1.0; - let stroke_width = selection_flow_stroke_width_px.clamp(1.0, 8.0); - - if samples.is_empty() { - return; - } - - let flow_time = time * SELECTION_FLOW_SPEED; - let phase = flow_time * 1.28 + 0.72; - - match style { - SelectionFlowStyle::Band => Self::selection_flow_draw_layer( - painter, - samples, - normals, - stroke_width, - base_alpha_scale * 0.52, - phase, - SELECTION_FLOW_CORE_FLOW_WIDTH, - theme, - ), - SelectionFlowStyle::FullBorder => Self::selection_flow_draw_layer_full_border( - painter, - samples, - normals, - stroke_width, - base_alpha_scale * SELECTION_FLOW_FROZEN_ALPHA_SCALE, - phase, - SELECTION_FLOW_FROZEN_INTENSITY, - theme, - ), - } - } - - fn selection_flow_corner_radius(rect: Rect) -> f32 { - SELECTION_FLOW_CORNER_RADIUS_PX - .min(rect.width() / 2.0 - 0.25) - .min(rect.height() / 2.0 - 0.25) - .max(0.0) - } - - fn selection_flow_palette(theme: HudTheme) -> &'static [(u8, u8, u8); 3] { - match theme { - HudTheme::Dark => &SELECTION_FLOW_PALETTE, - HudTheme::Light => &SELECTION_FLOW_LIGHT_PALETTE, - } - } - - fn selection_flow_cached_geometry( - selection_flow_geometry_cache: &mut SelectionFlowGeometryCache, - rect: Rect, - corner_radius: f32, - sample_count: usize, - seam_offset: f32, - ) -> (&[(Pos2, f32)], &[Vec2]) { - let key = - SelectionFlowGeometryCacheKey::new(rect, corner_radius, seam_offset, sample_count); - - if selection_flow_geometry_cache.key == Some(key) - && !selection_flow_geometry_cache.samples.is_empty() - { - return ( - &selection_flow_geometry_cache.samples, - &selection_flow_geometry_cache.normals, - ); - } - - let samples = - Self::selection_flow_path_samples(rect, corner_radius, sample_count, seam_offset); - let normals = Self::selection_flow_compute_normals(&samples); - - selection_flow_geometry_cache.key = Some(key); - selection_flow_geometry_cache.samples = samples; - selection_flow_geometry_cache.normals = normals; - - (&selection_flow_geometry_cache.samples, &selection_flow_geometry_cache.normals) - } - - fn selection_flow_compute_normals(samples: &[(Pos2, f32)]) -> Vec { - let n = samples.len(); - - if n == 0 { - return Vec::new(); - } - - let mut normals = Vec::with_capacity(n); - let mut first_non_zero = None; - - for i in 0..n { - let (current_point, _) = samples[i]; - let (prev_point, _) = samples[(i + n - 1) % n]; - let (next_point, _) = samples[(i + 1) % n]; - let prev_tangent = current_point - prev_point; - let next_tangent = next_point - current_point; - let mut normal = Vec2::ZERO; - - if prev_tangent.length_sq() > f32::EPSILON { - let prev_len = prev_tangent.length(); - - normal += Vec2::new(-prev_tangent.y / prev_len, prev_tangent.x / prev_len); - } - if next_tangent.length_sq() > f32::EPSILON { - let next_len = next_tangent.length(); - - normal += Vec2::new(-next_tangent.y / next_len, next_tangent.x / next_len); - } - if normal.length_sq() <= f32::EPSILON { - if next_tangent.length_sq() > f32::EPSILON { - let next_len = next_tangent.length(); - - normal = Vec2::new(-next_tangent.y / next_len, next_tangent.x / next_len); - } else if prev_tangent.length_sq() > f32::EPSILON { - let prev_len = prev_tangent.length(); - - normal = Vec2::new(-prev_tangent.y / prev_len, prev_tangent.x / prev_len); - } - } - - let normal = if normal.length_sq() > f32::EPSILON { - let normalized = normal / normal.length(); - - if first_non_zero.is_none() && normalized.length_sq() > f32::EPSILON { - first_non_zero = Some(i); - } - - normalized - } else { - Vec2::ZERO - }; - - normals.push(normal); - } - - if let Some(first_idx) = first_non_zero { - let mut previous = normals[first_idx]; - - for normal in normals.iter_mut().skip(first_idx + 1) { - if normal.length_sq() > f32::EPSILON && normal.dot(previous) < 0.0 { - *normal = -*normal; - } - if normal.length_sq() > f32::EPSILON { - previous = *normal; - } - } - for normal in normals.iter_mut().take(first_idx).rev() { - if normal.length_sq() > f32::EPSILON && normal.dot(previous) < 0.0 { - *normal = -*normal; - } - if normal.length_sq() > f32::EPSILON { - previous = *normal; - } - } - - if normals[first_idx].length_sq() > f32::EPSILON - && normals[(first_idx + n - 1) % n].length_sq() > f32::EPSILON - && normals[first_idx].dot(normals[(first_idx + n - 1) % n]) < 0.0 - { - for normal in &mut normals { - *normal = -*normal; - } - } - } - - normals - } - - #[allow(clippy::too_many_arguments)] - fn selection_flow_draw_layer( - painter: &Painter, - samples: &[(Pos2, f32)], - normals: &[Vec2], - line_width: f32, - alpha_scale: f32, - phase: f32, - flow_band_width: f32, - theme: HudTheme, - ) { - if samples.is_empty() || normals.is_empty() || samples.len() != normals.len() { - return; - } - - let half = (line_width * 0.5).max(0.1); - let n = samples.len(); - let mut mesh = Mesh::default(); - - for i in 0..n { - let (current_point, t) = samples[i]; - let movement = Self::selection_flow_flow_band(t, phase, flow_band_width); - let intensity = SELECTION_FLOW_FLOW_BOOST * movement; - let color = Self::selection_flow_color(t + phase, theme, alpha_scale, intensity); - let normal = normals[i] * half; - - mesh.colored_vertex(current_point + normal, color); - mesh.colored_vertex(current_point - normal, color); - } - for i in 0..n { - let i0 = (i * 2) as u32; - let i1 = ((i * 2) + 1) as u32; - let n0 = (((i + 1) % n) * 2) as u32; - let n1 = (((i + 1) % n) * 2 + 1) as u32; - - mesh.add_triangle(i0, i1, n0); - mesh.add_triangle(i1, n1, n0); - } - - painter.add(Shape::Mesh(mesh.into())); - } - - #[allow(clippy::too_many_arguments)] - fn selection_flow_draw_layer_full_border( - painter: &Painter, - samples: &[(Pos2, f32)], - normals: &[Vec2], - line_width: f32, - alpha_scale: f32, - phase: f32, - intensity: f32, - theme: HudTheme, - ) { - if samples.is_empty() || normals.is_empty() || samples.len() != normals.len() { - return; - } - - let half = (line_width * 0.5).max(0.1); - let n = samples.len(); - let mut mesh = Mesh::default(); - - for i in 0..n { - let (current_point, t) = samples[i]; - let color = Self::selection_flow_color(t + phase, theme, alpha_scale, intensity); - let normal = normals[i] * half; - - mesh.colored_vertex(current_point + normal, color); - mesh.colored_vertex(current_point - normal, color); - } - for i in 0..n { - let i0 = (i * 2) as u32; - let i1 = ((i * 2) + 1) as u32; - let n0 = (((i + 1) % n) * 2) as u32; - let n1 = (((i + 1) % n) * 2 + 1) as u32; - - mesh.add_triangle(i0, i1, n0); - mesh.add_triangle(i1, n1, n0); - } - - painter.add(Shape::Mesh(mesh.into())); - } - - fn selection_flow_flow_band(progress: f32, phase: f32, band_width: f32) -> f32 { - let width = band_width.clamp(0.001, 0.5); - let distance = (progress - phase).rem_euclid(1.0); - let distance = distance.min(1.0 - distance); - let normalized = (distance / width).min(1.0); - - (1.0 - normalized).powf(2.0) - } - - fn selection_flow_sample_count(perimeter: f32) -> usize { - if perimeter <= 0.0 || !perimeter.is_finite() { - return SELECTION_FLOW_MIN_SEGMENTS; - } - - let by_step = (perimeter / SELECTION_FLOW_SAMPLE_STEP_PX).ceil() as usize; - - by_step.clamp(SELECTION_FLOW_MIN_SEGMENTS, SELECTION_FLOW_MAX_SEGMENTS) - } - - fn selection_flow_path_samples( - rect: Rect, - corner_radius: f32, - sample_count: usize, - start_offset: f32, - ) -> Vec<(Pos2, f32)> { - let perimeter = Self::selection_flow_perimeter(rect, corner_radius); - - if perimeter <= 0.0 { - return Vec::new(); - } - - let start = (start_offset / perimeter).rem_euclid(1.0); - - (0..sample_count) - .map(|index| { - let t = (index as f32 + 0.5) / sample_count as f32; - let progress = (t + start).rem_euclid(1.0); - - ( - Self::selection_flow_sample_at_distance( - rect, - corner_radius, - perimeter * progress, - ), - t, - ) - }) - .collect() - } - - fn selection_flow_sample_at_distance(rect: Rect, corner_radius: f32, distance: f32) -> Pos2 { - if corner_radius <= f32::EPSILON { - let perimeter = Self::selection_flow_perimeter(rect, 0.0); - let keep = distance.rem_euclid(perimeter); - let edge_top = rect.width(); - let edge_right = rect.height(); - - if keep < edge_top { - return Pos2::new(rect.min.x + keep, rect.min.y); - } - if keep < edge_top + edge_right { - return Pos2::new(rect.max.x, rect.min.y + (keep - edge_top)); - } - if keep < edge_top * 2.0 + edge_right { - return Pos2::new(rect.max.x - (keep - edge_top - edge_right), rect.max.y); - } - - return Pos2::new(rect.min.x, rect.max.y - (keep - edge_top * 2.0 - edge_right)); - } - - let x0 = rect.min.x; - let x1 = rect.max.x; - let y0 = rect.min.y; - let y1 = rect.max.y; - let perimeter = Self::selection_flow_perimeter(rect, corner_radius); - let remain = distance.rem_euclid(perimeter); - let edge_top_len = (rect.width() - corner_radius * 2.0).max(0.0); - let edge_right_len = (rect.height() - corner_radius * 2.0).max(0.0); - let corner_len = std::f32::consts::FRAC_PI_2 * corner_radius; - - if remain < edge_top_len { - return Pos2::new(x0 + corner_radius + remain, y0); - } - - let mut offset = remain - edge_top_len; - - if offset < corner_len { - let angle = -std::f32::consts::FRAC_PI_2 + offset / corner_radius; - - return Pos2::new( - x1 - corner_radius + corner_radius * angle.cos(), - y0 + corner_radius + corner_radius * angle.sin(), - ); - } - - offset -= corner_len; - - if offset < edge_right_len { - return Pos2::new(x1, y0 + corner_radius + offset); - } - - offset -= edge_right_len; - - if offset < corner_len { - let angle = offset / corner_radius; - - return Pos2::new( - x1 - corner_radius + corner_radius * angle.cos(), - y1 - corner_radius + corner_radius * angle.sin(), - ); - } - - offset -= corner_len; - - if offset < edge_top_len { - return Pos2::new(x1 - corner_radius - offset, y1); - } - - offset -= edge_top_len; - - if offset < corner_len { - let angle = std::f32::consts::FRAC_PI_2 + offset / corner_radius; - - return Pos2::new( - x0 + corner_radius + corner_radius * angle.cos(), - y1 - corner_radius + corner_radius * angle.sin(), - ); - } - - offset -= corner_len; - - if offset < edge_right_len { - return Pos2::new(x0, y1 - corner_radius - offset); - } - - offset -= edge_right_len; - - if offset < corner_len { - let angle = std::f32::consts::PI + offset / corner_radius; - - return Pos2::new( - x0 + corner_radius + corner_radius * angle.cos(), - y0 + corner_radius + corner_radius * angle.sin(), - ); - } - - Pos2::new(x0 + corner_radius, y0) - } - - fn selection_flow_perimeter(rect: Rect, corner_radius: f32) -> f32 { - let edge_top_len = (rect.width() - corner_radius * 2.0).max(0.0); - let edge_right_len = (rect.height() - corner_radius * 2.0).max(0.0); - let corner_len = std::f32::consts::FRAC_PI_2 * corner_radius; - - 2.0 * (edge_top_len + edge_right_len) + 4.0 * corner_len - } - - fn selection_flow_color( - progress: f32, - theme: HudTheme, - alpha_scale: f32, - intensity: f32, - ) -> Color32 { - let palette = Self::selection_flow_palette(theme); - let normalized = progress.rem_euclid(1.0); - let band_position = normalized * palette.len() as f32; - let band = band_position.floor() as usize % palette.len(); - let local = band_position - band as f32; - let (r0, g0, b0) = palette[band]; - let (r1, g1, b1) = palette[(band + 1) % palette.len()]; - let blend = |a: u8, b: u8, ratio: f32| -> u8 { - (a as f32 + (b as f32 - a as f32) * ratio).clamp(0.0, 255.0).round() as u8 - }; - let theme_alpha = 1.0; - let alpha = (255.0 * alpha_scale * intensity * theme_alpha).clamp(0.0, 255.0); - - Color32::from_rgba_unmultiplied( - blend(r0, r1, local), - blend(g0, g1, local), - blend(b0, b1, local), - alpha as u8, - ) - } - - #[allow(clippy::too_many_arguments)] - fn render_frozen_toolbar_ui( - ctx: &egui::Context, - state: &OverlayState, - monitor: MonitorRect, - theme: HudTheme, - toolbar_placement: ToolbarPlacement, - hud_blur_active: bool, - hud_opaque: bool, - hud_opacity: f32, - hud_milk_amount: f32, - hud_tint_hue: f32, - toolbar_state: Option<&mut FrozenToolbarState>, - pointer_state: Option, - hud_pill_out: &mut Option, - ) { - let Some(toolbar_state) = toolbar_state else { - return; - }; - - if !matches!(state.mode, OverlayMode::Frozen) || !toolbar_state.visible { - return; - } - if state.monitor != Some(monitor) { - return; - } - - let (cursor, left_button_down) = if let Some(pointer_state) = pointer_state { - (pointer_state.cursor_local, pointer_state.left_button_down) - } else { - toolbar_state.dragging = false; - - (Pos2::new(-1.0, -1.0), false) - }; - let toolbar_size = Self::frozen_toolbar_size(toolbar_state); - let screen_rect = ctx.input(|i| i.viewport_rect()); - let capture_rect = Self::frozen_toolbar_capture_rect(state, monitor, screen_rect); - let Some(toolbar_pos) = Self::resolve_frozen_toolbar_birth( - ctx, - state, - monitor, - toolbar_state, - screen_rect, - capture_rect, - toolbar_size, - toolbar_placement, - ) else { - return; - }; - - #[cfg(any(not(target_os = "macos"), test))] - { - if !advance_frozen_toolbar_readiness_sample_state(toolbar_state, screen_rect) { - ctx.request_repaint(); - - return; - } - } - - Self::draw_frozen_toolbar( - ctx, - toolbar_state, - monitor, - screen_rect, - toolbar_pos, - toolbar_size, - theme, - hud_blur_active, - hud_opaque, - hud_opacity, - hud_milk_amount, - hud_tint_hue, - cursor, - left_button_down, - hud_pill_out, - ); - } - - fn frozen_toolbar_tools(toolbar_state: &FrozenToolbarState) -> &'static [FrozenToolbarTool] { - #[cfg(target_os = "macos")] - const TOOLS_SCROLL_MODE: [FrozenToolbarTool; 3] = - [FrozenToolbarTool::Ocr, FrozenToolbarTool::Copy, FrozenToolbarTool::Save]; - #[cfg(not(target_os = "macos"))] - const TOOLS_SCROLL_MODE: [FrozenToolbarTool; 2] = - [FrozenToolbarTool::Copy, FrozenToolbarTool::Save]; - #[cfg(target_os = "macos")] - const TOOLS_WITH_SCROLL_AND_AUTO_CENTER: [FrozenToolbarTool; 11] = [ - FrozenToolbarTool::Pointer, - FrozenToolbarTool::Pen, - FrozenToolbarTool::Text, - FrozenToolbarTool::Mosaic, - FrozenToolbarTool::Undo, - FrozenToolbarTool::Redo, - FrozenToolbarTool::AutoCenter, - FrozenToolbarTool::Scroll, - FrozenToolbarTool::Ocr, - FrozenToolbarTool::Copy, - FrozenToolbarTool::Save, - ]; - #[cfg(not(target_os = "macos"))] - const TOOLS_WITH_SCROLL_AND_AUTO_CENTER: [FrozenToolbarTool; 10] = [ - FrozenToolbarTool::Pointer, - FrozenToolbarTool::Pen, - FrozenToolbarTool::Text, - FrozenToolbarTool::Mosaic, - FrozenToolbarTool::Undo, - FrozenToolbarTool::Redo, - FrozenToolbarTool::AutoCenter, - FrozenToolbarTool::Scroll, - FrozenToolbarTool::Copy, - FrozenToolbarTool::Save, - ]; - #[cfg(target_os = "macos")] - const TOOLS_WITH_AUTO_CENTER: [FrozenToolbarTool; 10] = [ - FrozenToolbarTool::Pointer, - FrozenToolbarTool::Pen, - FrozenToolbarTool::Text, - FrozenToolbarTool::Mosaic, - FrozenToolbarTool::Undo, - FrozenToolbarTool::Redo, - FrozenToolbarTool::AutoCenter, - FrozenToolbarTool::Ocr, - FrozenToolbarTool::Copy, - FrozenToolbarTool::Save, - ]; - #[cfg(not(target_os = "macos"))] - const TOOLS_WITH_AUTO_CENTER: [FrozenToolbarTool; 9] = [ - FrozenToolbarTool::Pointer, - FrozenToolbarTool::Pen, - FrozenToolbarTool::Text, - FrozenToolbarTool::Mosaic, - FrozenToolbarTool::Undo, - FrozenToolbarTool::Redo, - FrozenToolbarTool::AutoCenter, - FrozenToolbarTool::Copy, - FrozenToolbarTool::Save, - ]; - #[cfg(target_os = "macos")] - const TOOLS_WITH_SCROLL: [FrozenToolbarTool; 10] = [ - FrozenToolbarTool::Pointer, - FrozenToolbarTool::Pen, - FrozenToolbarTool::Text, - FrozenToolbarTool::Mosaic, - FrozenToolbarTool::Undo, - FrozenToolbarTool::Redo, - FrozenToolbarTool::Scroll, - FrozenToolbarTool::Ocr, - FrozenToolbarTool::Copy, - FrozenToolbarTool::Save, - ]; - #[cfg(not(target_os = "macos"))] - const TOOLS_WITH_SCROLL: [FrozenToolbarTool; 9] = [ - FrozenToolbarTool::Pointer, - FrozenToolbarTool::Pen, - FrozenToolbarTool::Text, - FrozenToolbarTool::Mosaic, - FrozenToolbarTool::Undo, - FrozenToolbarTool::Redo, - FrozenToolbarTool::Scroll, - FrozenToolbarTool::Copy, - FrozenToolbarTool::Save, - ]; - #[cfg(target_os = "macos")] - const TOOLS_WITHOUT_SCROLL: [FrozenToolbarTool; 9] = [ - FrozenToolbarTool::Pointer, - FrozenToolbarTool::Pen, - FrozenToolbarTool::Text, - FrozenToolbarTool::Mosaic, - FrozenToolbarTool::Undo, - FrozenToolbarTool::Redo, - FrozenToolbarTool::Ocr, - FrozenToolbarTool::Copy, - FrozenToolbarTool::Save, - ]; - #[cfg(not(target_os = "macos"))] - const TOOLS_WITHOUT_SCROLL: [FrozenToolbarTool; 8] = [ - FrozenToolbarTool::Pointer, - FrozenToolbarTool::Pen, - FrozenToolbarTool::Text, - FrozenToolbarTool::Mosaic, - FrozenToolbarTool::Undo, - FrozenToolbarTool::Redo, - FrozenToolbarTool::Copy, - FrozenToolbarTool::Save, - ]; - - if toolbar_state.scroll_capture_active { - &TOOLS_SCROLL_MODE - } else if toolbar_state.auto_center_available && toolbar_state.scroll_capture_available { - &TOOLS_WITH_SCROLL_AND_AUTO_CENTER - } else if toolbar_state.auto_center_available { - &TOOLS_WITH_AUTO_CENTER - } else if toolbar_state.scroll_capture_available { - &TOOLS_WITH_SCROLL - } else { - &TOOLS_WITHOUT_SCROLL - } - } - - fn frozen_toolbar_size(toolbar_state: &FrozenToolbarState) -> Vec2 { - let tool_count = Self::frozen_toolbar_tools(toolbar_state).len() as f32; - let spacing_count = (tool_count - 1.0).max(0.0); - let width = tool_count * FROZEN_TOOLBAR_BUTTON_SIZE_POINTS - + spacing_count * FROZEN_TOOLBAR_ITEM_SPACING_POINTS - + 2.0 * HUD_PILL_INNER_MARGIN_X_POINTS - + 2.0 * HUD_PILL_STROKE_WIDTH_POINTS; - let height = toolbar_state.pill_height_points.unwrap_or(TOOLBAR_EXPANDED_HEIGHT_PX); - - Vec2::new(width, height) - } - - #[allow(clippy::too_many_arguments)] - fn resolve_frozen_toolbar_birth( - ctx: &egui::Context, - state: &OverlayState, - monitor: MonitorRect, - toolbar_state: &mut FrozenToolbarState, - screen_rect: Rect, - capture_rect: Rect, - toolbar_size: Vec2, - toolbar_placement: ToolbarPlacement, - ) -> Option { - if let Some(pos) = toolbar_state.floating_position { - return Some(pos); - } - - let screen_size_points = screen_rect.size(); - - tracing::trace!( - monitor_id = monitor.id, - frozen_generation = state.frozen_generation, - screen_rect = ?screen_rect, - screen_size_points = ?screen_size_points, - pixels_per_point = ctx.pixels_per_point(), - last_screen_size_points = ?toolbar_state.layout_last_screen_size_points, - stable_frames = toolbar_state.layout_stable_frames, - "Frozen toolbar birth attempt." - ); - - let needs_new_sample = frozen_toolbar_needs_new_sample( - toolbar_state.layout_last_screen_size_points, - screen_size_points, - ); - - if needs_new_sample { - toolbar_state.layout_last_screen_size_points = Some(screen_size_points); - toolbar_state.layout_stable_frames = 0; - toolbar_state.needs_redraw = true; - - tracing::debug!( - monitor_id = monitor.id, - frozen_generation = state.frozen_generation, - new_screen_size_points = ?screen_size_points, - "Frozen toolbar waiting for stable screen rect (new sample)." - ); - - ctx.request_repaint(); - - return None; - } - if toolbar_state.layout_stable_frames < 1 { - toolbar_state.layout_stable_frames = - toolbar_state.layout_stable_frames.saturating_add(1); - toolbar_state.needs_redraw = true; - - tracing::debug!( - monitor_id = monitor.id, - frozen_generation = state.frozen_generation, - screen_size_points = ?screen_size_points, - stable_frames = toolbar_state.layout_stable_frames, - "Frozen toolbar waiting for stable screen rect (confirm)." - ); - - ctx.request_repaint(); - - return None; - } - - let default_pos = Self::frozen_toolbar_default_pos( - screen_rect, - capture_rect, - toolbar_size, - toolbar_placement, - ); - - tracing::debug!( - monitor_id = monitor.id, - frozen_generation = state.frozen_generation, - toolbar_size_points = ?toolbar_size, - default_pos = ?default_pos, - "Frozen toolbar birth resolved." - ); - - toolbar_state.default_slot_position = Some(default_pos); - toolbar_state.floating_position = Some(default_pos); - - Some(default_pos) - } - - fn frozen_toolbar_capture_rect( - state: &OverlayState, - monitor: MonitorRect, - screen_rect: Rect, - ) -> Rect { - let Some(capture_rect) = state.frozen_capture_rect else { - return screen_rect; - }; - let Some(frozen_monitor) = state.monitor else { - return screen_rect; - }; - - if frozen_monitor != monitor { - return screen_rect; - } - - let capture_rect = Rect::from_min_size( - Pos2::new(capture_rect.x as f32, capture_rect.y as f32), - Vec2::new(capture_rect.width as f32, capture_rect.height as f32), - ); - - capture_rect.intersect(screen_rect) - } - - fn frozen_toolbar_default_pos( - screen_rect: Rect, - capture_rect: Rect, - toolbar_size: Vec2, - toolbar_placement: ToolbarPlacement, - ) -> Pos2 { - let y = match toolbar_placement { - ToolbarPlacement::Bottom => { - let below_y = capture_rect.max.y + TOOLBAR_CAPTURE_GAP_PX; - let within_screen = - below_y + toolbar_size.y + TOOLBAR_SCREEN_MARGIN_PX <= screen_rect.max.y; - - if within_screen { - below_y - } else { - capture_rect.max.y - TOOLBAR_SCREEN_MARGIN_PX - toolbar_size.y - } - }, - ToolbarPlacement::Top => { - let above_y = capture_rect.min.y - TOOLBAR_CAPTURE_GAP_PX - toolbar_size.y; - let within_screen = above_y >= screen_rect.min.y + TOOLBAR_SCREEN_MARGIN_PX; - - if within_screen { above_y } else { capture_rect.min.y + TOOLBAR_SCREEN_MARGIN_PX } - }, - }; - let min_y = screen_rect.min.y + TOOLBAR_SCREEN_MARGIN_PX; - let max_y = (screen_rect.max.y - toolbar_size.y - TOOLBAR_SCREEN_MARGIN_PX).max(min_y); - let x = Self::frozen_toolbar_default_x(screen_rect, toolbar_size, capture_rect.center().x); - let y = y.max(min_y).min(max_y); - - Pos2::new(x, y) - } - - fn frozen_toolbar_default_x( - screen_rect: Rect, - toolbar_size: Vec2, - anchor_center_x: f32, - ) -> f32 { - let min_x = screen_rect.min.x + TOOLBAR_SCREEN_MARGIN_PX; - let max_x = (screen_rect.max.x - toolbar_size.x - TOOLBAR_SCREEN_MARGIN_PX).max(min_x); - - (anchor_center_x - toolbar_size.x / 2.0).clamp(min_x, max_x) - } - - #[allow(clippy::too_many_arguments)] - fn draw_frozen_toolbar( - ctx: &egui::Context, - toolbar_state: &mut FrozenToolbarState, - monitor: MonitorRect, - screen_rect: Rect, - toolbar_pos: Pos2, - toolbar_size: Vec2, - theme: HudTheme, - hud_blur_active: bool, - hud_opaque: bool, - hud_opacity: f32, - hud_milk_amount: f32, - hud_tint_hue: f32, - cursor: Pos2, - left_button_down: bool, - hud_pill_out: &mut Option, - ) { - Area::new(Id::new(format!("frozen-toolbar-{}", monitor.id))) - .order(Order::Foreground) - .fixed_pos(toolbar_pos) - .show(ctx, |ui| { - let (rect, response) = - ui.allocate_exact_size(toolbar_size, Sense::click_and_drag()); - let body_fill = Self::tinted_hud_body_fill( - theme, - hud_blur_active, - hud_opaque, - hud_opacity, - hud_milk_amount, - hud_tint_hue, - ); - let toolbar_frame = - Self::hud_pill_frame(theme, hud_opaque, hud_opacity, body_fill, false); - - if response.drag_started() { - toolbar_state.dragging = true; - toolbar_state.floating_position = Some(toolbar_pos); - toolbar_state.drag_offset = cursor - toolbar_pos; - } - if toolbar_state.dragging && left_button_down { - let desired_pos = cursor - toolbar_state.drag_offset; - - toolbar_state.floating_position = Some(Self::clamp_toolbar_position( - screen_rect, - toolbar_size, - desired_pos, - TOOLBAR_SCREEN_MARGIN_PX, - TOOLBAR_SCREEN_MARGIN_PX, - )); - } else if toolbar_state.dragging { - toolbar_state.dragging = false; - } - - // Draw the capsule ourselves at the exact allocated rect. This keeps the visible pill - // and the blur rect perfectly aligned (no shrink-to-content surprises on first frame). - ui.painter().rect_filled( - rect, - f32::from(HUD_PILL_CORNER_RADIUS_POINTS), - toolbar_frame.fill, - ); - ui.painter().rect_stroke( - rect.shrink(0.5), - CornerRadius::same(HUD_PILL_CORNER_RADIUS_POINTS), - toolbar_frame.stroke, - StrokeKind::Inside, - ); - - let inner_stroke_color = match theme { - HudTheme::Dark => Color32::from_rgba_unmultiplied(0, 0, 0, 44), - HudTheme::Light => Color32::from_rgba_unmultiplied(255, 255, 255, 140), - }; - let inner_stroke = Stroke::new(1.0, inner_stroke_color); - let inner_rect = rect.shrink(1.0); - - ui.painter().rect_stroke( - inner_rect, - CornerRadius::same(HUD_PILL_CORNER_RADIUS_POINTS.saturating_sub(1)), - inner_stroke, - StrokeKind::Inside, - ); - - let inner_rect = rect.shrink2(egui::vec2( - HUD_PILL_INNER_MARGIN_X_POINTS, - HUD_PILL_INNER_MARGIN_Y_POINTS, - )); - let _ = ui.scope_builder(UiBuilder::new().max_rect(inner_rect), |ui| { - ui.with_layout(Layout::left_to_right(Align::Center), |ui| { - ui.spacing_mut().item_spacing = egui::vec2(4.0, 0.0); - - Self::render_frozen_toolbar_controls(ui, toolbar_state, theme); - }); - }); - - *hud_pill_out = Some(HudPillGeometry { - rect, - radius_points: f32::from(HUD_PILL_CORNER_RADIUS_POINTS), - }); - }); - } - - #[allow(clippy::too_many_arguments)] - fn render_frozen_toolbar_controls( - ui: &mut Ui, - toolbar_state: &mut FrozenToolbarState, - theme: HudTheme, - ) { - if toolbar_state.selected_tool == FrozenToolbarTool::Scroll { - toolbar_state.selected_tool = FrozenToolbarTool::Pointer; - } - - let tools = Self::frozen_toolbar_tools(toolbar_state); - let button_size = FROZEN_TOOLBAR_BUTTON_SIZE_POINTS; - let button_font_size = 18.0; - let item_spacing = FROZEN_TOOLBAR_ITEM_SPACING_POINTS; - let hit_area_inset = 5.0; - - ui.horizontal_centered(|ui| { - ui.spacing_mut().item_spacing.x = item_spacing; - - for tool in tools { - let is_mode_tool = tool.is_mode_tool(); - let action_ready = - !tool.requires_final_capture() || toolbar_state.final_capture_ready; - let response = - ui.allocate_response(Vec2::new(button_size, button_size), Sense::click()); - let hovered = action_ready && response.hovered(); - let response = if action_ready { - response.on_hover_text(tool.label()) - } else { - response.on_hover_text("Preparing capture...") - }; - let hover_anim: f32 = if hovered { 1.0 } else { 0.0 }; - - if action_ready && response.clicked() { - let tool = *tool; - - if is_mode_tool { - toolbar_state.selected_tool = tool; - } else { - toolbar_state.pending_action = Some(tool); - } - - toolbar_state.needs_redraw = true; - } - - let selected = is_mode_tool && *tool == toolbar_state.selected_tool; - let selected_anim: f32 = if selected { 1.0 } else { 0.0 }; - let glow = hover_anim.max(selected_anim); - let icon_font = if selected { - FontFamily::Name("phosphor-fill".into()) - } else { - FontFamily::Proportional - }; - let style = - Self::frozen_toolbar_button_style(theme, action_ready, hovered, selected); - - if glow > 0.0 { - let bg_rect = response.rect.shrink(hit_area_inset); - - ui.painter().rect_filled(bg_rect, 8.0, style.bg_color); - } - - if let Some(border_color) = style.border_color { - ui.painter().rect_stroke( - response.rect.shrink(hit_area_inset), - 8.0, - Stroke::new(1.0, border_color), - StrokeKind::Inside, - ); - } - - ui.painter().text( - response.rect.center(), - Align2::CENTER_CENTER, - tool.icon(), - FontId::new(button_font_size, icon_font), - style.icon_color, - ); - } - }); - } - - fn frozen_toolbar_button_style( - theme: HudTheme, - action_ready: bool, - hovered: bool, - selected: bool, - ) -> FrozenToolbarButtonStyle { - let hover_anim = if hovered { 1.0 } else { 0.0 }; - let selected_anim = if selected { 1.0 } else { 0.0 }; - let (normal_color, hover_color, selected_color, hover_bg, selected_bg) = - Self::frozen_toolbar_colors(theme); - let mut icon_color = if action_ready { - normal_color - } else { - Color32::from_rgba_unmultiplied( - normal_color.r(), - normal_color.g(), - normal_color.b(), - (normal_color.a() as f32 * 0.45).round() as u8, - ) - }; - let mut bg_color = Color32::from_rgba_unmultiplied(255, 255, 255, 0); - - if selected_anim > 0.0 { - icon_color = Self::blend_color(icon_color, selected_color, selected_anim); - bg_color = Self::blend_color(bg_color, selected_bg, selected_anim); - } - if hover_anim > 0.0 { - icon_color = Self::blend_color(icon_color, hover_color, hover_anim); - bg_color = Self::blend_color(bg_color, hover_bg, hover_anim * (1.0 - selected_anim)); - } - - FrozenToolbarButtonStyle { icon_color, bg_color, border_color: None } - } - - fn frozen_toolbar_colors(theme: HudTheme) -> (Color32, Color32, Color32, Color32, Color32) { - let (normal_color, hover_color, selected_color) = match theme { - HudTheme::Dark => ( - Color32::from_rgba_unmultiplied(255, 255, 255, 160), - Color32::from_rgba_unmultiplied(255, 255, 255, 222), - Color32::from_rgba_unmultiplied(255, 255, 255, 255), - ), - HudTheme::Light => ( - Color32::from_rgba_unmultiplied(28, 28, 32, 182), - Color32::from_rgba_unmultiplied(28, 28, 32, 220), - Color32::from_rgba_unmultiplied(28, 28, 32, 255), - ), - }; - let hover_bg = match theme { - HudTheme::Dark => Color32::from_rgba_unmultiplied(255, 255, 255, 20), - HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 20), - }; - let selected_bg = match theme { - HudTheme::Dark => Color32::from_rgba_unmultiplied(255, 255, 255, 28), - HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 24), - }; - - (normal_color, hover_color, selected_color, hover_bg, selected_bg) - } - - fn blend_color(a: Color32, b: Color32, t: f32) -> Color32 { - let t = t.clamp(0.0, 1.0); - let u = 1.0 - t; - - Color32::from_rgba_unmultiplied( - ((f32::from(a.r()) * u + f32::from(b.r()) * t).round().clamp(0.0, 255.0)) as u8, - ((f32::from(a.g()) * u + f32::from(b.g()) * t).round().clamp(0.0, 255.0)) as u8, - ((f32::from(a.b()) * u + f32::from(b.b()) * t).round().clamp(0.0, 255.0)) as u8, - ((f32::from(a.a()) * u + f32::from(b.a()) * t).round().clamp(0.0, 255.0)) as u8, - ) - } - - fn clamp_toolbar_position( - screen_rect: Rect, - toolbar_size: Vec2, - cursor: Pos2, - side_margin: f32, - top_margin: f32, - ) -> Pos2 { - let min_x = screen_rect.min.x + side_margin; - let min_y = screen_rect.min.y + top_margin; - let max_x = (screen_rect.max.x - toolbar_size.x - side_margin).max(min_x); - let max_y = (screen_rect.max.y - toolbar_size.y - top_margin * 0.5).max(min_y); - - Pos2::new(cursor.x.clamp(min_x, max_x.max(min_x)), cursor.y.clamp(min_y, max_y.max(min_y))) - } - - fn should_draw_hud(state: &OverlayState, monitor: MonitorRect) -> bool { - if cfg!(target_os = "macos") && matches!(state.mode, OverlayMode::Frozen) { - return true; - } - - !matches!(state.mode, OverlayMode::Frozen) - || state.monitor != Some(monitor) - || state.frozen_image.is_some() - || state.error_message.is_some() - } - - #[allow(clippy::too_many_arguments)] - fn render_hud( - &mut self, - ctx: &egui::Context, - state: &OverlayState, - monitor: MonitorRect, - cursor: GlobalPoint, - local_cursor: Pos2, - hud_compact: bool, - hud_anchor: HudAnchor, - show_alt_hint_keycap: bool, - hud_blur_active: bool, - hud_opaque: bool, - hud_opacity: f32, - hud_milk_amount: f32, - hud_tint_hue: f32, - theme: HudTheme, - hud_pill_out: &mut Option, - ) { - let (hud_x, hud_y) = match hud_anchor { - HudAnchor::Cursor => (local_cursor.x + 14.0, local_cursor.y + 14.0), - }; - - Area::new("hud".into()).order(Order::Foreground).fixed_pos(Pos2::new(hud_x, hud_y)).show( - ctx, - |ui| { - self.render_hud_frame( - ui, - state, - monitor, - cursor, - hud_compact, - show_alt_hint_keycap, - hud_blur_active, - hud_opaque, - hud_opacity, - hud_milk_amount, - hud_tint_hue, - theme, - hud_pill_out, - ); - }, - ); - } - - #[allow(clippy::too_many_arguments)] - fn render_hud_frame( - &mut self, - ui: &mut Ui, - state: &OverlayState, - monitor: MonitorRect, - cursor: GlobalPoint, - hud_compact: bool, - show_alt_hint_keycap: bool, - hud_blur_active: bool, - hud_opaque: bool, - hud_opacity: f32, - hud_milk_amount: f32, - hud_tint_hue: f32, - theme: HudTheme, - hud_pill_out: &mut Option, - ) { - let body_fill = Self::tinted_hud_body_fill( - theme, - hud_blur_active, - hud_opaque, - hud_opacity, - hud_milk_amount, - hud_tint_hue, - ); - let pill_frame = - Self::hud_pill_frame(theme, hud_opaque, hud_opacity, body_fill, !hud_compact); - let inner = pill_frame.show(ui, |ui| { - ui.spacing_mut().item_spacing = egui::vec2(10.0, 6.0); - - if let Some(err) = &state.error_message { - let err_color = match theme { - HudTheme::Dark => Color32::from_rgba_unmultiplied(235, 235, 245, 235), - HudTheme::Light => Color32::from_rgba_unmultiplied(28, 28, 32, 235), - }; - - ui.label(RichText::new(err).color(err_color).monospace()); - } else { - Self::render_hud_content(ui, state, monitor, cursor, show_alt_hint_keycap, theme); - } - }); - let pill_rect = inner.response.rect; - - *hud_pill_out = Some(HudPillGeometry { - rect: pill_rect, - radius_points: f32::from(HUD_PILL_CORNER_RADIUS_POINTS), - }); - - if hud_compact { - return; - } - - let inner_stroke_color = match theme { - HudTheme::Dark => Color32::from_rgba_unmultiplied(0, 0, 0, 44), - HudTheme::Light => Color32::from_rgba_unmultiplied(255, 255, 255, 140), - }; - let inner_stroke = Stroke::new(1.0, inner_stroke_color); - let inner_rect = pill_rect.shrink(1.0); - - ui.painter().rect_stroke( - inner_rect, - CornerRadius::same(HUD_PILL_CORNER_RADIUS_POINTS.saturating_sub(1)), - inner_stroke, - StrokeKind::Inside, - ); - - if !hud_compact { - self.render_loupe_tile( - ui, - state, - pill_rect, - hud_blur_active, - hud_opaque, - body_fill, - theme, - ); - } - } - - fn hud_pill_frame( - theme: HudTheme, - _hud_opaque: bool, - _hud_opacity: f32, - body_fill: Color32, - with_shadow: bool, - ) -> Frame { - let outer_stroke_color = match theme { - HudTheme::Dark => Color32::from_rgba_unmultiplied(255, 255, 255, 40), - HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 44), - }; - let pill_shadow = if with_shadow { - egui::epaint::Shadow { - offset: [0, 0], - blur: 10, - spread: 0, - color: match theme { - HudTheme::Dark => Color32::from_rgba_unmultiplied(0, 0, 0, 28), - HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 18), - }, - } - } else { - egui::Shadow::NONE - }; - - Frame { - fill: body_fill, - stroke: Stroke::new(1.0, outer_stroke_color), - shadow: pill_shadow, - corner_radius: CornerRadius::same(HUD_PILL_CORNER_RADIUS_POINTS), - inner_margin: Margin::symmetric(12, 8), - ..Frame::default() - } - } - - fn render_hud_content( - ui: &mut Ui, - state: &OverlayState, - monitor: MonitorRect, - cursor: GlobalPoint, - show_alt_hint_keycap: bool, - theme: HudTheme, - ) { - let (label_color, secondary_color) = Self::hud_text_colors(theme); - let pos_text = hud_helpers::format_live_hud_position_text(monitor, cursor); - let (hex_text, rgb_text) = hud_helpers::format_live_hud_rgb_text(state.rgb); - let swatch_size = egui::vec2(10.0, 10.0); - - ui.vertical(|ui| { - ui.with_layout(Layout::left_to_right(Align::Center), |ui| { - ui.label(RichText::new(pos_text).color(label_color).monospace()); - ui.label(RichText::new("•").color(secondary_color).monospace()); - - let (rect, _) = ui.allocate_exact_size(swatch_size, Sense::hover()); - let swatch_color = match state.rgb { - Some(rgb) => Color32::from_rgb(rgb.r, rgb.g, rgb.b), - None => Color32::from_rgba_unmultiplied(255, 255, 255, 26), - }; - - ui.painter().rect_filled(rect, 3.0, swatch_color); - ui.painter().rect_stroke( - rect, - 3.0, - Stroke::new( - 1.0, - match theme { - HudTheme::Dark => Color32::from_rgba_unmultiplied(255, 255, 255, 36), - HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 44), - }, - ), - StrokeKind::Inside, - ); - ui.label(RichText::new(hex_text).color(label_color).monospace()); - ui.label(RichText::new(rgb_text).color(secondary_color).monospace()); - - if show_alt_hint_keycap { - let alt_active = state.alt_held; - let (keycap_fill, keycap_stroke, keycap_text) = match theme { - HudTheme::Dark if alt_active => ( - Color32::from_rgba_unmultiplied(255, 255, 255, 40), - Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 70)), - label_color, - ), - HudTheme::Dark => ( - Color32::from_rgba_unmultiplied(255, 255, 255, 18), - Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 30)), - secondary_color, - ), - HudTheme::Light if alt_active => ( - Color32::from_rgba_unmultiplied(0, 0, 0, 22), - Stroke::new(1.0, Color32::from_rgba_unmultiplied(0, 0, 0, 64)), - label_color, - ), - HudTheme::Light => ( - Color32::from_rgba_unmultiplied(0, 0, 0, 12), - Stroke::new(1.0, Color32::from_rgba_unmultiplied(0, 0, 0, 32)), - secondary_color, - ), - }; - - Frame { - fill: keycap_fill, - stroke: keycap_stroke, - corner_radius: CornerRadius::same(6), - inner_margin: Margin::symmetric(6, 2), - ..Frame::default() - } - .show(ui, |ui| { - ui.label(RichText::new("Tab").color(keycap_text).monospace()); - }); - } - }); - }); - } - - fn hud_text_colors(theme: HudTheme) -> (Color32, Color32) { - match theme { - HudTheme::Dark => ( - Color32::from_rgba_unmultiplied(235, 235, 245, 235), - Color32::from_rgba_unmultiplied(235, 235, 245, 150), - ), - HudTheme::Light => ( - Color32::from_rgba_unmultiplied(28, 28, 32, 235), - Color32::from_rgba_unmultiplied(28, 28, 32, 160), - ), - } - } - - #[allow(clippy::too_many_arguments)] - fn render_loupe_tile( - &mut self, - ui: &mut Ui, - state: &OverlayState, - pill_rect: Rect, - hud_blur_active: bool, - hud_opaque: bool, - body_fill: Color32, - theme: HudTheme, - ) { - let ctx = ui.ctx().clone(); - - self.loupe_tile = None; - - if !state.alt_held { - return; - } - - const CELL: f32 = 10.0; - - let side = hud_helpers::stable_live_loupe_side_points(state, CELL); - let tile_padding = Margin::same(10); - let tile_w = side + (tile_padding.left as f32) + (tile_padding.right as f32); - let tile_h = side + (tile_padding.top as f32) + (tile_padding.bottom as f32); - let screen = ctx.content_rect(); - let gap = HUD_LOUPE_STRIP_GAP_POINTS as f32; - let mut x = pill_rect.min.x; - - x = x.clamp(screen.min.x + 6.0, (screen.max.x - tile_w - 6.0).max(screen.min.x + 6.0)); - - let below_y = pill_rect.max.y + gap; - let above_y = pill_rect.min.y - gap - tile_h; - let mut y = if below_y + tile_h <= screen.max.y { below_y } else { above_y }; - - y = y.clamp(screen.min.y + 6.0, (screen.max.y - tile_h - 6.0).max(screen.min.y + 6.0)); - - let pos = Pos2::new(x, y); - let tile = Area::new(Id::new("rsnap-loupe-tile")) - .order(Order::Foreground) - .fixed_pos(pos) - .show(&ctx, |ui| { - let _ = hud_blur_active; - let fill = body_fill; - let outer_stroke_color = match theme { - HudTheme::Dark => Color32::from_rgba_unmultiplied(255, 255, 255, 40), - HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 44), - }; - let outer_stroke = Stroke::new(1.0, outer_stroke_color); - let shadow = egui::epaint::Shadow { - offset: [0, 0], - blur: 10, - spread: 0, - color: match theme { - HudTheme::Dark => Color32::from_rgba_unmultiplied(0, 0, 0, 28), - HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 18), - }, - }; - let frame = Frame { - fill, - stroke: outer_stroke, - shadow, - corner_radius: CornerRadius::same(18), - inner_margin: tile_padding, - ..Frame::default() - }; - - frame.show(ui, |ui| { - ui.set_min_size(Vec2::new(side, side)); - self.render_loupe(ui, state, hud_blur_active, hud_opaque, theme); - }); - }); - - self.loupe_tile = Some(tile.response.rect); - } - - fn render_loupe( - &mut self, - ui: &mut Ui, - state: &OverlayState, - hud_blur_active: bool, - hud_opaque: bool, - theme: HudTheme, - ) { - const CELL: f32 = 10.0; - - let mode = state.mode; - - if matches!(mode, OverlayMode::Live) { - self.render_live_loupe(ui, state, CELL, hud_blur_active, hud_opaque, theme); - } else if matches!(mode, OverlayMode::Frozen) - && (state.frozen_image.is_some() || state.loupe.is_some()) - { - let Some(monitor) = state.monitor else { - return; - }; - let Some(cursor) = state.cursor else { - return; - }; - - self.render_frozen_loupe( - ui, - state, - monitor, - cursor, - CELL, - hud_blur_active, - hud_opaque, - theme, - ); - } - } - - fn sync_live_loupe_texture( - &mut self, - loupe: Option<&crate::state::LoupeSample>, - ) -> Option { - let Some(loupe) = loupe else { - self.live_loupe_texture = None; - - return None; - }; - let patch_size_px = [loupe.patch.width() as usize, loupe.patch.height() as usize]; - let patch_rgba = loupe.patch.as_raw(); - - match self.live_loupe_texture.as_mut() { - Some(cached) if cached.patch_size_px == patch_size_px => { - if cached.rgba != *patch_rgba { - let color_image = ColorImage::from_rgba_unmultiplied( - [patch_size_px[0], patch_size_px[1]], - patch_rgba, - ); - - cached.texture.set(color_image, TextureOptions::NEAREST); - cached.rgba.clone_from(patch_rgba); - } - }, - _ => { - let color_image = ColorImage::from_rgba_unmultiplied( - [patch_size_px[0], patch_size_px[1]], - patch_rgba, - ); - let texture = self.egui_ctx.load_texture( - String::from("live-loupe-image"), - color_image, - TextureOptions::NEAREST, - ); - - self.live_loupe_texture = - Some(LiveLoupeTexture { texture, patch_size_px, rgba: patch_rgba.clone() }); - }, - } - - self.live_loupe_texture.as_ref().map(|cached| cached.texture.id()) - } - - fn render_live_loupe( - &mut self, - ui: &mut Ui, - state: &OverlayState, - cell: f32, - _hud_blur_active: bool, - hud_opaque: bool, - theme: HudTheme, - ) { - let fallback_side_px = state.loupe_patch_side_px.max(1); - let (w, h) = state - .loupe - .as_ref() - .map(|loupe| loupe.patch.dimensions()) - .unwrap_or((fallback_side_px, fallback_side_px)); - let side = hud_helpers::stable_live_loupe_side_points(state, cell); - let (rect, _) = ui.allocate_exact_size(Vec2::new(side, side), Sense::hover()); - let body_fill = hud_helpers::hud_body_fill_srgba8(theme, hud_opaque); - let stroke = Stroke::new(1.0, Color32::from_rgba_unmultiplied(0, 0, 0, 140)); - let placeholder_fill = - Color32::from_rgba_unmultiplied(body_fill[0], body_fill[1], body_fill[2], 255); - let image_rect = - Rect::from_center_size(rect.center(), Vec2::new((w as f32) * cell, (h as f32) * cell)); - - if let Some(texture_id) = self.sync_live_loupe_texture(state.loupe.as_ref()) { - ui.painter().rect_filled(rect, 3.0, placeholder_fill); - ui.painter().image( - texture_id, - image_rect, - Rect::from_min_max(Pos2::new(0.0, 0.0), Pos2::new(1.0, 1.0)), - Color32::WHITE, - ); - } else { - ui.painter().rect_filled(rect, 3.0, placeholder_fill); - } - - ui.painter().rect_stroke(rect, 3.0, stroke, StrokeKind::Outside); - - let center_x = (w / 2) as f32; - let center_y = (h / 2) as f32; - let center_min = - Pos2::new(image_rect.min.x + center_x * cell, image_rect.min.y + center_y * cell); - let center_rect = Rect::from_min_size(center_min, Vec2::splat(cell)); - - ui.painter().rect_stroke( - center_rect, - 0.0, - Stroke::new(2.0, Color32::from_rgba_unmultiplied(255, 255, 255, 180)), - StrokeKind::Inside, - ); - } - - #[allow(clippy::too_many_arguments)] - fn render_frozen_loupe( - &mut self, - ui: &mut Ui, - state: &OverlayState, - monitor: MonitorRect, - cursor: GlobalPoint, - cell: f32, - hud_blur_active: bool, - hud_opaque: bool, - theme: HudTheme, - ) { - if state.loupe.is_some() { - self.render_live_loupe(ui, state, cell, hud_blur_active, hud_opaque, theme); - - return; - } - - const LOUPE_RADIUS_PX: i32 = 5; - const LOUPE_SIDE_PX: i32 = (LOUPE_RADIUS_PX * 2) + 1; - - let side = (LOUPE_SIDE_PX as f32) * cell; - let (rect, _) = ui.allocate_exact_size(Vec2::new(side, side), Sense::hover()); - let Some(image) = state.frozen_image.as_ref() else { - return; - }; - let Some((center_x, center_y)) = monitor.local_u32_pixels(cursor) else { - return; - }; - let (width, height) = image.dimensions(); - let width = width as i32; - let height = height as i32; - let center_x = center_x as i32; - let center_y = center_y as i32; - let stroke = Stroke::new(1.0, Color32::from_rgba_unmultiplied(0, 0, 0, 140)); - let grid_stroke = Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 26)); - - for dy in -LOUPE_RADIUS_PX..=LOUPE_RADIUS_PX { - for dx in -LOUPE_RADIUS_PX..=LOUPE_RADIUS_PX { - let x = center_x + dx; - let y = center_y + dy; - let cell_x = dx + LOUPE_RADIUS_PX; - let cell_y = dy + LOUPE_RADIUS_PX; - let cell_min = Pos2::new( - rect.min.x + (cell_x as f32) * cell, - rect.min.y + (cell_y as f32) * cell, - ); - let cell_rect = Rect::from_min_size(cell_min, Vec2::splat(cell)); - let fill = if x < 0 || y < 0 || x >= width || y >= height { - Color32::from_rgba_unmultiplied(0, 0, 0, 0) - } else { - let pixel = - image.get_pixel_checked(x as u32, y as u32).expect("pixel bounds checked"); - - Color32::from_rgb(pixel.0[0], pixel.0[1], pixel.0[2]) - }; - - ui.painter().rect_filled(cell_rect, 0.0, fill); - } - } - for i in 0..=LOUPE_SIDE_PX { - let x = rect.min.x + (i as f32) * cell; - let y = rect.min.y + (i as f32) * cell; - - ui.painter() - .line_segment([Pos2::new(x, rect.min.y), Pos2::new(x, rect.max.y)], grid_stroke); - ui.painter() - .line_segment([Pos2::new(rect.min.x, y), Pos2::new(rect.max.x, y)], grid_stroke); - } - - ui.painter().rect_stroke(rect, 3.0, stroke, StrokeKind::Outside); - - let center_min = Pos2::new( - rect.min.x + (LOUPE_RADIUS_PX as f32) * cell, - rect.min.y + (LOUPE_RADIUS_PX as f32) * cell, - ); - let center_rect = Rect::from_min_size(center_min, Vec2::splat(cell)); - - ui.painter().rect_stroke( - center_rect, - 0.0, - Stroke::new(2.0, Color32::from_rgba_unmultiplied(255, 255, 255, 180)), - StrokeKind::Inside, - ); - } - - fn sync_egui_textures(&mut self, gpu: &GpuContext, full_output: &FullOutput) { - for (id, image_delta) in &full_output.textures_delta.set { - self.egui_renderer.update_texture(&gpu.device, &gpu.queue, *id, image_delta); - } - for id in &full_output.textures_delta.free { - self.egui_renderer.free_texture(id); - } - } - - fn acquire_frame(&mut self, gpu: &GpuContext) -> Result { - let started_at = Instant::now(); - let frame = { - let mut acquired = None; - - for attempt in 0..2 { - match self.surface.get_current_texture() { - CurrentSurfaceTexture::Success(frame) => { - acquired = Some(Ok(AcquiredSurfaceFrame::Ready(frame))); - - break; - }, - CurrentSurfaceTexture::Suboptimal(frame) => { - self.needs_reconfigure = true; - acquired = Some(Ok(AcquiredSurfaceFrame::Ready(frame))); - - break; - }, - CurrentSurfaceTexture::Outdated if attempt == 0 => { - self.reconfigure(gpu); - - self.needs_reconfigure = false; - }, - CurrentSurfaceTexture::Lost if attempt == 0 => { - let surface = gpu - .instance - .create_surface(Arc::clone(&self.window)) - .wrap_err("Failed to recreate lost surface")?; - - self.surface = surface; - - self.reconfigure(gpu); - - self.needs_reconfigure = false; - }, - CurrentSurfaceTexture::Outdated => { - acquired = Some(Err(eyre::eyre!( - "Failed to acquire surface texture after reconfigure: surface stayed outdated" - ))); - - break; - }, - CurrentSurfaceTexture::Lost => { - acquired = Some(Err(eyre::eyre!( - "Failed to acquire surface texture after recreate: surface stayed lost" - ))); - - break; - }, - CurrentSurfaceTexture::Timeout => { - acquired = Some(Ok(AcquiredSurfaceFrame::Skipped( - SurfaceFrameSkipReason::Timeout, - ))); - - break; - }, - CurrentSurfaceTexture::Occluded => { - acquired = Some(Ok(AcquiredSurfaceFrame::Skipped( - SurfaceFrameSkipReason::Occluded, - ))); - - break; - }, - CurrentSurfaceTexture::Validation => { - acquired = Some(Err(eyre::eyre!( - "Failed to acquire surface texture: validation error" - ))); - - break; - }, - } - } - - acquired.unwrap_or_else(|| { - Err(eyre::eyre!( - "Failed to acquire surface texture: bounded retries exhausted unexpectedly" - )) - }) - }; - let elapsed = started_at.elapsed(); - - self.slow_op_logger.warn_if_slow( - "overlay.window_renderer_acquire_frame", - elapsed, - SLOW_OP_WARN_RENDER, - || format!("needs_reconfigure={}", self.needs_reconfigure), - ); - - frame - } - - #[allow(clippy::too_many_arguments)] - fn render_frame( - &mut self, - gpu: &GpuContext, - draw_frozen_bg: bool, - hud_blur_active: bool, - frame: SurfaceTexture, - paint_jobs: &[ClippedPrimitive], - screen_descriptor: &ScreenDescriptor, - ) -> Result<()> { - let started_at = Instant::now(); - let view = frame.texture.create_view(&TextureViewDescriptor::default()); - let mut encoder = gpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("rsnap-overlay encoder"), - }); - let _user_cmds = self.egui_renderer.update_buffers( - &gpu.device, - &gpu.queue, - &mut encoder, - paint_jobs, - screen_descriptor, - ); - - { - let rpass_desc = wgpu::RenderPassDescriptor { - label: Some("rsnap-overlay renderpass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &view, - depth_slice: None, - resolve_target: None, - ops: wgpu::Operations { - load: LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }), - store: StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }; - let mut rpass = encoder.begin_render_pass(&rpass_desc).forget_lifetime(); - - if draw_frozen_bg && let Some(bg) = &self.hud_bg { - rpass.set_pipeline(&self.mipgen_surface_pipeline); - rpass.set_bind_group(0, &bg.mipgen_bind_group, &[]); - rpass.draw(0..3, 0..1); - } - if hud_blur_active - && self.hud_pill.is_some() - && let Some(bg) = &self.hud_bg - { - if let Some(pill) = self.hud_pill { - let ppp = screen_descriptor.pixels_per_point; - let pad_px = (24.0 * ppp).ceil() as i32; - let surface_w = screen_descriptor.size_in_pixels[0].max(1) as i32; - let surface_h = screen_descriptor.size_in_pixels[1].max(1) as i32; - let min_x_bound = (surface_w - 1).max(0); - let min_y_bound = (surface_h - 1).max(0); - let min_x = - ((pill.rect.min.x * ppp).floor() as i32 - pad_px).clamp(0, min_x_bound); - let min_y = - ((pill.rect.min.y * ppp).floor() as i32 - pad_px).clamp(0, min_y_bound); - let max_x = - ((pill.rect.max.x * ppp).ceil() as i32 + pad_px).clamp(0, surface_w); - let max_y = - ((pill.rect.max.y * ppp).ceil() as i32 + pad_px).clamp(0, surface_h); - let w = (max_x - min_x).max(1) as u32; - let h = (max_y - min_y).max(1) as u32; - - rpass.set_scissor_rect(min_x as u32, min_y as u32, w, h); - } - - rpass.set_pipeline(&self.hud_blur_pipeline); - rpass.set_bind_group(0, &bg.hud_blur_bind_group, &[]); - rpass.draw(0..3, 0..1); - rpass.set_scissor_rect( - 0, - 0, - screen_descriptor.size_in_pixels[0].max(1), - screen_descriptor.size_in_pixels[1].max(1), - ); - } - - self.egui_renderer.render(&mut rpass, paint_jobs, screen_descriptor); - } - - gpu.queue.submit(Some(encoder.finish())); - frame.present(); - self.slow_op_logger.warn_if_slow( - "overlay.window_renderer_render_frame", - started_at.elapsed(), - SLOW_OP_WARN_RENDER, - || { - format!( - "draw_frozen_bg={} hud_blur_active={} paint_jobs={}", - draw_frozen_bg, - hud_blur_active, - paint_jobs.len() - ) - }, - ); - - Ok(()) - } - - fn new( - gpu: &GpuContext, - window: Arc, - egui_repaint_deadline: Arc>>, - ) -> Result { - let surface = gpu - .instance - .create_surface(Arc::clone(&window)) - .wrap_err("wgpu create_surface failed")?; - let caps = surface.get_capabilities(&gpu.adapter); - let surface_format = Self::pick_surface_format(&caps); - let surface_alpha = Self::pick_surface_alpha(&caps); - let surface_config = - Self::make_surface_config(window.as_ref(), surface_format, surface_alpha); - - surface.configure(&gpu.device, &surface_config); - - let egui_ctx = egui::Context::default(); - let mut fonts = FontDefinitions::default(); - - egui_phosphor::add_to_fonts(&mut fonts, Variant::Regular); - - let phosphor_fill = String::from("phosphor-fill"); - let proportional_fallback = - fonts.families.get(&FontFamily::Proportional).and_then(|names| names.first()).cloned(); - - fonts.font_data.insert(phosphor_fill.clone(), Variant::Fill.font_data().into()); - - { - let family = - fonts.families.entry(FontFamily::Name(phosphor_fill.clone().into())).or_default(); - - family.insert(0, phosphor_fill.clone()); - - if let Some(fallback) = proportional_fallback - && !family.contains(&fallback) - { - family.push(fallback); - } - } - - egui_ctx.set_fonts(fonts); - - let repaint_deadline = Arc::clone(&egui_repaint_deadline); - - egui_ctx.set_request_repaint_callback(move |info| { - let deadline = Instant::now() + info.delay; - let mut next_repaint = repaint_deadline.lock().unwrap_or_else(|err| err.into_inner()); - let needs_update = next_repaint.is_none_or(|previous| deadline < previous); - - if needs_update { - *next_repaint = Some(deadline); - } - }); - - let egui_renderer = Renderer::new( - &gpu.device, - surface_format, - egui_wgpu::RendererOptions { - msaa_samples: 1, - depth_stencil_format: None, - dithering: false, - predictable_texture_filtering: false, - }, - ); - let bg_sampler = Self::create_bg_sampler(gpu); - let (mipgen_pipeline, mipgen_bind_group_layout) = - Self::create_mipgen_pipeline(gpu, wgpu::TextureFormat::Rgba8UnormSrgb); - let mipgen_surface_pipeline = - Self::create_mipgen_surface_pipeline(gpu, surface_format, &mipgen_bind_group_layout); - let (hud_blur_pipeline, hud_blur_bind_group_layout) = - Self::create_hud_blur_pipeline(gpu, surface_format); - let hud_blur_uniform = gpu.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("rsnap-hud-blur uniform"), - size: mem::size_of::() as u64, - usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let now = Instant::now(); - - Ok(Self { - window, - surface, - surface_config, - needs_reconfigure: false, - egui_ctx, - egui_renderer, - bg_sampler, - mipgen_pipeline, - mipgen_surface_pipeline, - mipgen_bind_group_layout, - hud_blur_pipeline, - hud_blur_bind_group_layout, - hud_blur_uniform, - hud_bg: None, - hud_bg_generation: 0, - hud_pill: None, - loupe_tile: None, - live_loupe_texture: None, - hud_theme: None, - egui_start_time: now, - egui_last_frame_time: now, - selection_flow_cache: SelectionFlowGeometryCache::default(), - selection_dashed_border_cache: SelectionDashedBorderCache::default(), - slow_op_logger: SlowOperationLogger::default(), - occluded_redraw_retry_until: None, - }) - } - - fn resize(&mut self, size: PhysicalSize) -> Result<()> { - self.surface_config.width = size.width.max(1); - self.surface_config.height = size.height.max(1); - self.needs_reconfigure = true; - - Ok(()) - } - - fn reconfigure(&mut self, gpu: &GpuContext) { - self.surface.configure(&gpu.device, &self.surface_config); - } - - fn sync_egui_theme(&mut self, theme: HudTheme) { - if self.hud_theme == Some(theme) { - return; - } - - match theme { - HudTheme::Dark => self.egui_ctx.set_visuals(Visuals::dark()), - HudTheme::Light => self.egui_ctx.set_visuals(Visuals::light()), - } - - self.hud_theme = Some(theme); - } - - fn prepare_window_renderer_input( - &mut self, - gpu: &GpuContext, - monitor: MonitorRect, - toolbar_pointer: Option, - theme_mode: ThemeMode, - phase_timings: &mut WindowRendererPhaseTimings, - ) -> (HudTheme, PhysicalSize, f32, egui::RawInput) { - self.apply_pending_reconfigure(gpu); - - let theme = hud_helpers::effective_hud_theme(theme_mode, self.window.theme()); - - self.sync_egui_theme(theme); - - let prepare_input_started_at = Instant::now(); - let (size, pixels_per_point, raw_input) = - self.prepare_egui_input(gpu, toolbar_pointer, Some(monitor.scale_factor())); - - phase_timings.prepare_input = prepare_input_started_at.elapsed(); - - (theme, size, pixels_per_point, raw_input) - } - - #[allow(clippy::too_many_arguments)] - fn maybe_update_hud_blur_uniform( - &mut self, - gpu: &GpuContext, - size: PhysicalSize, - pixels_per_point: f32, - theme: HudTheme, - hud_shader_blur_active: bool, - hud_fog_amount: f32, - hud_milk_amount: f32, - hud_tint_hue: f32, - phase_timings: &mut WindowRendererPhaseTimings, - ) { - if !hud_shader_blur_active { - return; - } - - let update_hud_blur_uniform_started_at = Instant::now(); - - self.update_hud_blur_uniform( - gpu, - size, - pixels_per_point, - theme, - hud_fog_amount, - hud_milk_amount, - hud_tint_hue, - ); - - phase_timings.update_hud_blur_uniform = update_hud_blur_uniform_started_at.elapsed(); - } - - #[allow(clippy::too_many_arguments)] - fn finish_window_renderer_draw( - &mut self, - gpu: &GpuContext, - state: &OverlayState, - path: WindowRendererPath, - monitor: MonitorRect, - size: PhysicalSize, - pixels_per_point: f32, - draw_started_at: Instant, - phase_timings: &mut WindowRendererPhaseTimings, - paint_jobs: Vec, - draw_frozen_bg: bool, - hud_shader_blur_active: bool, - toolbar_active: bool, - ) -> Result<()> { - let screen_descriptor = - ScreenDescriptor { size_in_pixels: [size.width, size.height], pixels_per_point }; - let acquire_frame_started_at = Instant::now(); - let frame = self.acquire_frame(gpu)?; - - phase_timings.acquire_frame = acquire_frame_started_at.elapsed(); - - let frame = match frame { - AcquiredSurfaceFrame::Ready(frame) => frame, - AcquiredSurfaceFrame::Skipped(reason) => { - phase_timings.total = draw_started_at.elapsed(); - - phase_timings.warn_if_substeps_slow( - &mut self.slow_op_logger, - path, - self.window.id(), - monitor.id, - state.mode, - paint_jobs.len(), - ); - phase_timings.trace( - path, - self.window.id(), - monitor.id, - state.mode, - toolbar_active, - paint_jobs.len(), - ); - - tracing::trace!( - path = path.as_str(), - window_id = ?self.window.id(), - monitor_id = monitor.id, - reason = reason.as_str(), - "Skipped overlay window frame acquisition." - ); - - if should_request_overlay_redraw_after_surface_skip( - reason, - Instant::now(), - &mut self.occluded_redraw_retry_until, - ) { - self.window.request_redraw(); - } - - return Ok(()); - }, - }; - let render_frame_started_at = Instant::now(); - - self.render_frame( - gpu, - draw_frozen_bg, - hud_shader_blur_active, - frame, - &paint_jobs, - &screen_descriptor, - )?; - self.note_successful_frame_presented(); - - phase_timings.render_frame = render_frame_started_at.elapsed(); - phase_timings.total = draw_started_at.elapsed(); - - phase_timings.warn_if_substeps_slow( - &mut self.slow_op_logger, - path, - self.window.id(), - monitor.id, - state.mode, - paint_jobs.len(), - ); - phase_timings.trace( - path, - self.window.id(), - monitor.id, - state.mode, - toolbar_active, - paint_jobs.len(), - ); - - Ok(()) - } - - #[allow(clippy::too_many_arguments)] - fn draw( - &mut self, - gpu: &GpuContext, - state: &OverlayState, - monitor: MonitorRect, - draw_hud: bool, - hud_local_cursor_override: Option, - hud_compact: bool, - hud_anchor: HudAnchor, - toolbar_placement: ToolbarPlacement, - show_alt_hint_keycap: bool, - show_hud_blur: bool, - hud_opaque: bool, - hud_opacity: f32, - hud_fog_amount: f32, - hud_milk_amount: f32, - hud_tint_hue: f32, - theme_mode: ThemeMode, - selection_flow_enabled: bool, - selection_flow_stroke_width_px: f32, - allow_frozen_surface_bg: bool, - show_frozen_capture_affordance: bool, - frozen_capture_is_fullscreen_fallback: bool, - frozen_toolbar_reserved_rect: Option, - toolbar_state: Option<&mut FrozenToolbarState>, - toolbar_pointer: Option, - ) -> Result<()> { - let draw_started_at = Instant::now(); - let mut phase_timings = WindowRendererPhaseTimings::default(); - let (theme, size, pixels_per_point, raw_input) = self.prepare_window_renderer_input( - gpu, - monitor, - toolbar_pointer, - theme_mode, - &mut phase_timings, - ); - let toolbar_active = toolbar_state.is_some(); - - self.trace_frozen_frame_metrics(state, monitor, size, pixels_per_point, toolbar_active); - - self.loupe_tile = None; - - let hud_cfg = Self::resolve_hud_draw_config( - state, - monitor, - draw_hud, - allow_frozen_surface_bg, - toolbar_active, - show_hud_blur, - hud_opaque, - ); - let sync_hud_bg_started_at = Instant::now(); - - self.sync_or_clear_hud_bg(gpu, state, monitor, hud_cfg)?; - - phase_timings.sync_hud_bg = sync_hud_bg_started_at.elapsed(); - - let hud_shader_blur_active = self.hud_shader_blur_active(state, monitor, hud_cfg); - let mut selection_flow_cache = mem::take(&mut self.selection_flow_cache); - let mut selection_dashed_border_cache = mem::take(&mut self.selection_dashed_border_cache); - let run_egui_started_at = Instant::now(); - let (full_output, hud_pill) = self.run_egui( - raw_input, - state, - monitor, - hud_cfg.can_draw_hud, - hud_local_cursor_override, - hud_compact, - show_hud_blur, - hud_anchor, - toolbar_placement, - show_alt_hint_keycap, - hud_cfg.hud_glass_active, - hud_opaque, - hud_opacity, - hud_milk_amount, - hud_tint_hue, - theme, - selection_flow_enabled, - selection_flow_stroke_width_px, - hud_cfg.needs_frozen_surface_bg, - show_frozen_capture_affordance, - frozen_capture_is_fullscreen_fallback, - frozen_toolbar_reserved_rect, - &mut selection_flow_cache, - &mut selection_dashed_border_cache, - toolbar_state, - toolbar_pointer, - ); - - phase_timings.run_egui = run_egui_started_at.elapsed(); - self.selection_flow_cache = selection_flow_cache; - self.selection_dashed_border_cache = selection_dashed_border_cache; - self.hud_pill = hud_pill; - - self.maybe_update_hud_blur_uniform( - gpu, - size, - pixels_per_point, - theme, - hud_shader_blur_active, - hud_fog_amount, - hud_milk_amount, - hud_tint_hue, - &mut phase_timings, - ); - - let sync_egui_textures_started_at = Instant::now(); - - self.sync_egui_textures(gpu, &full_output); - - phase_timings.sync_egui_textures = sync_egui_textures_started_at.elapsed(); - - let tessellate_started_at = Instant::now(); - let paint_jobs = self.egui_ctx.tessellate(full_output.shapes, pixels_per_point); - - phase_timings.tessellate = tessellate_started_at.elapsed(); - - let draw_frozen_bg = hud_cfg.needs_frozen_surface_bg - && state.monitor == Some(monitor) - && state.frozen_image.is_some(); - - self.finish_window_renderer_draw( - gpu, - state, - WindowRendererPath::Overlay, - monitor, - size, - pixels_per_point, - draw_started_at, - &mut phase_timings, - paint_jobs, - draw_frozen_bg, - hud_shader_blur_active, - toolbar_active, - ) - } - - fn trace_frozen_frame_metrics( - &self, - state: &OverlayState, - monitor: MonitorRect, - size: PhysicalSize, - pixels_per_point: f32, - toolbar_active: bool, - ) { - if !matches!(state.mode, OverlayMode::Frozen) || state.monitor != Some(monitor) { - return; - } - - let screen_size_points = - Vec2::new(size.width as f32 / pixels_per_point, size.height as f32 / pixels_per_point); - - tracing::trace!( - window_id = ?self.window.id(), - monitor_id = monitor.id, - window_scale_factor = self.window.scale_factor(), - monitor_scale_factor = monitor.scale_factor(), - size_in_pixels = ?size, - pixels_per_point, - screen_size_points = ?screen_size_points, - flip_y = false, - frozen_generation = state.frozen_generation, - frozen_image_ready = state.frozen_image.is_some(), - toolbar_active, - "Frozen frame metrics." - ); - } - - fn resolve_hud_draw_config( - state: &OverlayState, - monitor: MonitorRect, - draw_hud: bool, - allow_frozen_surface_bg: bool, - toolbar_active: bool, - show_hud_blur: bool, - hud_opaque: bool, - ) -> HudDrawConfig { - let can_draw_hud = draw_hud && Self::should_draw_hud(state, monitor); - let needs_frozen_surface_bg = - allow_frozen_surface_bg && !draw_hud && matches!(state.mode, OverlayMode::Frozen); - // `show_hud_blur` is a UX toggle for "glass mode". - // - On macOS: HUD uses native compositor blur; toolbar uses native HUD windowing, so shader - // blur stays tied to monitor-aligned overlay windows. - // - On non-macOS: HUD and toolbar remain in overlay windows with shader blur paths. - let hud_glass_active = can_draw_hud && show_hud_blur && !hud_opaque; - let toolbar_glass_active = toolbar_active && show_hud_blur && !hud_opaque; - let use_shader_blur_for_hud = !cfg!(target_os = "macos"); - let needs_shader_blur_bg = - toolbar_glass_active || (hud_glass_active && use_shader_blur_for_hud); - - HudDrawConfig { - can_draw_hud, - needs_frozen_surface_bg, - needs_shader_blur_bg, - hud_glass_active, - } - } - - fn sync_or_clear_hud_bg( - &mut self, - gpu: &GpuContext, - state: &OverlayState, - monitor: MonitorRect, - hud_cfg: HudDrawConfig, - ) -> Result<()> { - if hud_cfg.needs_frozen_surface_bg || hud_cfg.needs_shader_blur_bg { - return self.sync_hud_bg(gpu, state, monitor); - } - - self.hud_bg = None; - self.hud_bg_generation = match state.mode { - OverlayMode::Live => state.live_bg_generation, - OverlayMode::Frozen => state.frozen_generation, - }; - - Ok(()) - } - - fn hud_shader_blur_active( - &self, - state: &OverlayState, - monitor: MonitorRect, - hud_cfg: HudDrawConfig, - ) -> bool { - hud_cfg.needs_shader_blur_bg - && self.hud_bg.is_some() - && match state.mode { - OverlayMode::Live => state.live_bg_monitor == Some(monitor), - OverlayMode::Frozen => state.monitor == Some(monitor), - } - } - - #[allow(clippy::too_many_arguments)] - fn draw_loupe_tile_window( - &mut self, - gpu: &GpuContext, - state: &OverlayState, - monitor: MonitorRect, - show_hud_blur: bool, - hud_opaque: bool, - hud_opacity: f32, - hud_fog_amount: f32, - hud_milk_amount: f32, - hud_tint_hue: f32, - theme_mode: ThemeMode, - ) -> Result<()> { - let draw_started_at = Instant::now(); - let mut phase_timings = WindowRendererPhaseTimings::default(); - let (theme, size, pixels_per_point, raw_input) = - self.prepare_window_renderer_input(gpu, monitor, None, theme_mode, &mut phase_timings); - - self.loupe_tile = None; - - let shader_blur_active = !cfg!(target_os = "macos") - && matches!(state.mode, OverlayMode::Frozen) - && show_hud_blur - && !hud_opaque; - let hud_cfg = HudDrawConfig { - can_draw_hud: false, - needs_frozen_surface_bg: false, - needs_shader_blur_bg: shader_blur_active, - hud_glass_active: shader_blur_active, - }; - let sync_hud_bg_started_at = Instant::now(); - - self.sync_or_clear_hud_bg(gpu, state, monitor, hud_cfg)?; - - phase_timings.sync_hud_bg = sync_hud_bg_started_at.elapsed(); - - let hud_shader_blur_active = self.hud_shader_blur_active(state, monitor, hud_cfg); - let hud_blur_active = show_hud_blur && !hud_opaque; - let body_fill = Self::tinted_hud_body_fill( - theme, - hud_blur_active, - hud_opaque, - hud_opacity, - hud_milk_amount, - hud_tint_hue, - ); - let run_loupe_tile_egui_started_at = Instant::now(); - let (full_output, loupe_tile_rect) = self.run_loupe_tile_egui( - raw_input, - state, - theme, - hud_blur_active, - hud_opaque, - body_fill, - ); - - phase_timings.run_egui = run_loupe_tile_egui_started_at.elapsed(); - self.loupe_tile = loupe_tile_rect; - - if hud_shader_blur_active { - self.hud_pill = loupe_tile_rect.map(|rect| HudPillGeometry { - rect, - radius_points: LOUPE_TILE_CORNER_RADIUS_POINTS as f32, - }); - - if self.hud_pill.is_some() { - self.maybe_update_hud_blur_uniform( - gpu, - size, - pixels_per_point, - theme, - hud_shader_blur_active, - hud_fog_amount, - hud_milk_amount, - hud_tint_hue, - &mut phase_timings, - ); - } - } else { - self.hud_pill = None; - } - - let sync_egui_textures_started_at = Instant::now(); - - self.sync_egui_textures(gpu, &full_output); - - phase_timings.sync_egui_textures = sync_egui_textures_started_at.elapsed(); - - let tessellate_started_at = Instant::now(); - let paint_jobs = self.egui_ctx.tessellate(full_output.shapes, pixels_per_point); - - phase_timings.tessellate = tessellate_started_at.elapsed(); - - self.finish_window_renderer_draw( - gpu, - state, - WindowRendererPath::LoupeTile, - monitor, - size, - pixels_per_point, - draw_started_at, - &mut phase_timings, - paint_jobs, - false, - hud_shader_blur_active, - false, - ) - } - - fn tinted_hud_body_fill( - theme: HudTheme, - hud_blur_active: bool, - hud_opaque: bool, - hud_opacity: f32, - hud_milk_amount: f32, - hud_tint_hue: f32, - ) -> Color32 { - let mut opacity = if hud_opaque { 1.0 } else { hud_opacity.clamp(0.0, 1.0) }; - - if hud_blur_active { - opacity = opacity.max(hud_helpers::hud_blur_tint_alpha(theme)); - } - - let tint = hud_milk_amount.clamp(0.0, 1.0); - let mut fill = hud_helpers::hud_body_fill_srgba8(theme, false); - let tint_hue = hud_tint_hue.clamp(0.0, 1.0); - let tint_saturation = 1.0; - let (_, _, base_lightness) = hud_helpers::rgb_to_hsl(Rgb::new(fill[0], fill[1], fill[2])); - let tinted_target = hud_helpers::hsl_to_rgb(tint_hue, tint_saturation, base_lightness); - - fn lerp_u8(a: u8, b: u8, t: f32) -> u8 { - ((f32::from(a) + ((f32::from(b) - f32::from(a)) * t)).round().clamp(0.0, 255.0)) as u8 - } - - fill[0] = lerp_u8(fill[0], tinted_target.r, tint); - fill[1] = lerp_u8(fill[1], tinted_target.g, tint); - fill[2] = lerp_u8(fill[2], tinted_target.b, tint); - fill[3] = (opacity * 255.0).round().clamp(0.0, 255.0) as u8; - - Color32::from_rgba_unmultiplied(fill[0], fill[1], fill[2], fill[3]) - } - - #[allow(clippy::too_many_arguments)] - fn run_loupe_tile_egui( - &mut self, - raw_input: egui::RawInput, - state: &OverlayState, - theme: HudTheme, - hud_blur_active: bool, - hud_opaque: bool, - body_fill: Color32, - ) -> (FullOutput, Option) { - let mut loupe_tile_rect = None; - let egui_ctx = self.egui_ctx.clone(); - let full_output = egui_ctx.run_ui(raw_input, |ui| { - let ctx = ui.ctx(); - - if !state.alt_held { - return; - } - - const CELL: f32 = 10.0; - - let side = hud_helpers::stable_live_loupe_side_points(state, CELL); - let tile_padding = Margin::same(10); - let outer_stroke_color = match theme { - HudTheme::Dark => Color32::from_rgba_unmultiplied(255, 255, 255, 40), - HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 44), - }; - let outer_stroke = Stroke::new(1.0, outer_stroke_color); - let shadow = egui::epaint::Shadow { - offset: [0, 0], - blur: 10, - spread: 0, - color: match theme { - HudTheme::Dark => Color32::from_rgba_unmultiplied(0, 0, 0, 28), - HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 18), - }, - }; - let tile_radius = LOUPE_TILE_CORNER_RADIUS_POINTS as u8; - let frame = Frame { - fill: body_fill, - stroke: outer_stroke, - shadow, - corner_radius: CornerRadius::same(tile_radius), - inner_margin: tile_padding, - ..Frame::default() - }; - let pad = 6.0; - - Area::new(Id::new("rsnap-loupe-window")) - .order(Order::Foreground) - .fixed_pos(Pos2::new(pad, pad)) - .show(ctx, |ui| { - let inner = frame.show(ui, |ui| { - ui.set_min_size(Vec2::new(side, side)); - self.render_loupe(ui, state, hud_blur_active, hud_opaque, theme); - }); - let tile_rect = inner.response.rect; - - loupe_tile_rect = Some(tile_rect); - - let inner_stroke_color = match theme { - HudTheme::Dark => Color32::from_rgba_unmultiplied(0, 0, 0, 44), - HudTheme::Light => Color32::from_rgba_unmultiplied(255, 255, 255, 140), - }; - let inner_stroke = Stroke::new(1.0, inner_stroke_color); - let inner_rect = tile_rect.shrink(1.0); - - ui.painter().rect_stroke( - inner_rect, - CornerRadius::same(tile_radius.saturating_sub(1)), - inner_stroke, - StrokeKind::Inside, - ); - }); - }); - - (full_output, loupe_tile_rect) - } - - #[allow(clippy::too_many_arguments)] - fn update_hud_blur_uniform( - &mut self, - gpu: &GpuContext, - size: PhysicalSize, - pixels_per_point: f32, - theme: HudTheme, - hud_fog_amount: f32, - hud_milk_amount: f32, - hud_tint_hue: f32, - ) { - if self.hud_bg.is_none() { - return; - } - - let Some(hud_pill) = self.hud_pill else { - return; - }; - let surface_w = size.width as f32; - let surface_h = size.height as f32; - - if surface_w <= 0.0 || surface_h <= 0.0 { - return; - } - - let max_lod = self.hud_bg.as_ref().map(|bg| bg.max_lod).unwrap_or(0.0); - let rect_min_px = - [hud_pill.rect.min.x * pixels_per_point, hud_pill.rect.min.y * pixels_per_point]; - let rect_size_px = - [hud_pill.rect.width() * pixels_per_point, hud_pill.rect.height() * pixels_per_point]; - let rect_min_size = [rect_min_px[0], rect_min_px[1], rect_size_px[0], rect_size_px[1]]; - let tint = - Self::tinted_hud_body_fill(theme, false, false, 1.0, hud_milk_amount, hud_tint_hue); - let tint_rgba = [ - hud_helpers::srgb8_to_linear_f32(tint[0]), - hud_helpers::srgb8_to_linear_f32(tint[1]), - hud_helpers::srgb8_to_linear_f32(tint[2]), - hud_helpers::hud_blur_tint_alpha(theme), - ]; - let effects = - [hud_fog_amount.clamp(0.0, 1.0), hud_milk_amount.clamp(0.0, 1.0), max_lod, 0.0]; - let u = HudBlurUniformRaw { - rect_min_size, - radius_blur_soft: [ - hud_pill.radius_points * pixels_per_point, - (0.9 + (hud_fog_amount.clamp(0.0, 1.0) * 3.2)) * pixels_per_point, - 1.0 * pixels_per_point, - 0.0, - ], - surface_size_px: [surface_w, surface_h, 0.0, 0.0], - tint_rgba, - effects, - }; - - gpu.queue.write_buffer(&self.hud_blur_uniform, 0, u.as_bytes()); - } - - fn sync_hud_bg( - &mut self, - gpu: &GpuContext, - state: &OverlayState, - monitor: MonitorRect, - ) -> Result<()> { - let (target_generation, target_image) = match state.mode { - OverlayMode::Live if state.live_bg_monitor == Some(monitor) => { - (state.live_bg_generation, state.live_bg_image.as_ref()) - }, - OverlayMode::Frozen if state.monitor == Some(monitor) => { - (state.frozen_generation, state.frozen_image.as_ref()) - }, - OverlayMode::Live => { - self.hud_bg = None; - self.hud_bg_generation = state.live_bg_generation; - - return Ok(()); - }, - OverlayMode::Frozen => { - self.hud_bg = None; - self.hud_bg_generation = state.frozen_generation; - - return Ok(()); - }, - }; - - if self.hud_bg.is_some() && self.hud_bg_generation == target_generation { - if target_image.is_none() { - // Keep displaying the already-uploaded background even if image bytes moved. - return Ok(()); - } - - return Ok(()); - } - - let Some(image) = target_image else { - // Capture is in progress and no image is available yet. - self.hud_bg = None; - self.hud_bg_generation = target_generation; - - return Ok(()); - }; - - self.render_frozen_bg_to_texture(gpu, image, target_generation) - } - - fn render_frozen_bg_to_texture( - &mut self, - gpu: &GpuContext, - image: &RgbaImage, - target_generation: u64, - ) -> Result<()> { - let upload_image = image_helpers::downscale_for_gpu_upload( - image, - gpu.device.limits().max_texture_dimension_2d, - ); - let (width, height) = upload_image.dimensions(); - let max_side = gpu.device.limits().max_texture_dimension_2d; - let mip_level_count = Self::mip_level_count(width, height).min(10); - - debug_assert!(width <= max_side && height <= max_side); - - let texture = gpu.device.create_texture(&wgpu::TextureDescriptor { - label: Some("rsnap-frozen-bg texture"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, - mip_level_count, - sample_count: 1, - dimension: TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: TextureUsages::TEXTURE_BINDING - | TextureUsages::COPY_DST - | TextureUsages::RENDER_ATTACHMENT, - view_formats: &[], - }); - let upload_bytes = upload_image.as_raw(); - let bytes_per_pixel = 4_usize; - let unpadded_bytes_per_row = (width as usize) * bytes_per_pixel; - let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; - let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align; - let rgba_padded; - let rgba_bytes: &[u8] = if padded_bytes_per_row == unpadded_bytes_per_row { - upload_bytes - } else { - let src = upload_bytes; - - rgba_padded = image_helpers::pad_rows( - src, - unpadded_bytes_per_row, - padded_bytes_per_row, - height as usize, - ); - - &rgba_padded - }; - - gpu.queue.write_texture( - wgpu::TexelCopyTextureInfo { - texture: &texture, - mip_level: 0, - origin: Origin3d::ZERO, - aspect: TextureAspect::All, - }, - rgba_bytes, - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(padded_bytes_per_row as u32), - rows_per_image: Some(height), - }, - wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, - ); - self.generate_mipmaps(gpu, &texture, mip_level_count); - - let view = texture.create_view(&TextureViewDescriptor::default()); - let hud_blur_bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("rsnap-hud-blur bind group"), - layout: &self.hud_blur_bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: BindingResource::TextureView(&view) }, - wgpu::BindGroupEntry { - binding: 1, - resource: BindingResource::Sampler(&self.bg_sampler), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: self.hud_blur_uniform.as_entire_binding(), - }, - ], - }); - let mipgen_bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("rsnap-mipgen fullscreen bind group"), - layout: &self.mipgen_bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: BindingResource::TextureView(&view) }, - wgpu::BindGroupEntry { - binding: 1, - resource: BindingResource::Sampler(&self.bg_sampler), - }, - ], - }); - let max_lod = (mip_level_count.saturating_sub(1)) as f32; - - self.hud_bg = Some(HudBg { - _texture: texture, - _view: view, - hud_blur_bind_group, - mipgen_bind_group, - max_lod, - }); - self.hud_bg_generation = target_generation; - - Ok(()) - } -} - -struct HudBg { - _texture: Texture, - _view: TextureView, - hud_blur_bind_group: BindGroup, - mipgen_bind_group: BindGroup, - max_lod: f32, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct HudPillGeometry { - rect: Rect, - radius_points: f32, -} - -#[repr(C)] -#[derive(Clone, Copy, Debug)] -struct HudBlurUniformRaw { - rect_min_size: [f32; 4], - radius_blur_soft: [f32; 4], - surface_size_px: [f32; 4], - tint_rgba: [f32; 4], - effects: [f32; 4], -} -impl HudBlurUniformRaw { - fn as_bytes(&self) -> &[u8] { - unsafe { slice::from_raw_parts(ptr::from_ref(self).cast::(), mem::size_of::()) } - } -} - -#[cfg(target_os = "macos")] -#[repr(C)] -struct MacOSCGPoint { - x: f64, - y: f64, -} - -fn should_request_overlay_redraw_after_surface_skip( - reason: SurfaceFrameSkipReason, - now: Instant, - occluded_redraw_retry_until: &mut Option, -) -> bool { - match reason { - SurfaceFrameSkipReason::Timeout => true, - SurfaceFrameSkipReason::Occluded => match occluded_redraw_retry_until { - Some(deadline) if now >= *deadline => { - *occluded_redraw_retry_until = None; - - false - }, - Some(_) => true, - None => { - *occluded_redraw_retry_until = Some(now + OCCLUDED_FRAME_REDRAW_RETRY_WINDOW); - - true - }, - }, - } -} - -fn frozen_toolbar_needs_new_sample( - last_screen_size_points: Option, - screen_size_points: Vec2, -) -> bool { - match last_screen_size_points { - None => true, - Some(last) => { - let dx = (last.x - screen_size_points.x).abs(); - let dy = (last.y - screen_size_points.y).abs(); - - dx > 0.5 || dy > 0.5 - }, - } -} - -fn advance_frozen_toolbar_readiness_sample_state( - toolbar_state: &mut FrozenToolbarState, - screen_rect: Rect, -) -> bool { - let screen_size_points = screen_rect.size(); - - if frozen_toolbar_needs_new_sample( - toolbar_state.layout_last_screen_size_points, - screen_size_points, - ) { - toolbar_state.layout_last_screen_size_points = Some(screen_size_points); - toolbar_state.layout_stable_frames = 0; - - return false; - } - if toolbar_state.layout_stable_frames < 1 { - toolbar_state.layout_stable_frames = toolbar_state.layout_stable_frames.saturating_add(1); - - return false; - } - - true -} - -fn frozen_toolbar_matches_default_slot(toolbar_pos: Pos2, default_pos: Pos2) -> bool { - let dx = (toolbar_pos.x - default_pos.x).abs(); - let dy = (toolbar_pos.y - default_pos.y).abs(); - - dx <= TOOLBAR_DEFAULT_SLOT_POSITION_EPSILON_POINTS - && dy <= TOOLBAR_DEFAULT_SLOT_POSITION_EPSILON_POINTS -} - -#[cfg(target_os = "macos")] -fn macos_hid_event_source_state_id() -> u32 { - KCG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE -} - -fn global_to_local(cursor: GlobalPoint, monitor: MonitorRect) -> Option { - let (x, y) = monitor.local_u32(cursor)?; - - Some(Pos2::new(x as f32, y as f32)) -} - -#[cfg(target_os = "macos")] -#[link(name = "CoreGraphics", kind = "framework")] -unsafe extern "C" { - fn CGEventGetLocation(event: CGEventRef) -> MacOSCGPoint; - fn CGEventCreate(source: *const c_void) -> CGEventRef; - fn CGEventSourceCreate(source_state_id: u32) -> CFTypeRef; - fn CGEventCreateScrollWheelEvent2( - source: *const c_void, - units: u32, - wheel_count: u32, - wheel1: i32, - wheel2: i32, - wheel3: i32, - ) -> CGEventRef; - fn CGEventPost(tap_location: u32, event: CGEventRef); - fn CGEventSetLocation(event: CGEventRef, location: MacOSCGPoint); -} - -#[cfg(target_os = "macos")] -#[link(name = "CoreFoundation", kind = "framework")] -unsafe extern "C" { - fn CFRelease(obj: CFTypeRef); -} - -#[cfg(target_os = "macos")] -fn macos_mouse_location() -> Option { - let event = unsafe { CGEventCreate(ptr::null()) }; - - if event.is_null() { - return None; - } - - let point = unsafe { CGEventGetLocation(event) }; - - unsafe { CFRelease(event) }; - - Some(GlobalPoint::new(point.x as i32, point.y as i32)) -} - -#[cfg(target_os = "macos")] -fn macos_activate_app() { - unsafe { - let app: *mut Object = objc::msg_send![objc::class!(NSApplication), sharedApplication]; - - if app.is_null() { - return; - } - - let _: () = objc::msg_send![app, activateIgnoringOtherApps: YES]; - } -} - -#[cfg(target_os = "macos")] -fn macos_make_window_key(window: &winit::window::Window) { - let Ok(handle) = window.window_handle() else { - return; - }; - let RawWindowHandle::AppKit(appkit) = handle.as_raw() else { - return; - }; - let ns_view = appkit.ns_view.as_ptr().cast::(); - - unsafe { - let ns_window: *mut Object = objc::msg_send![ns_view, window]; - - if ns_window.is_null() { - return; - } - - let nil: *mut Object = ptr::null_mut(); - let _: () = objc::msg_send![ns_window, makeKeyAndOrderFront: nil]; - } - - window.focus_window(); -} - -#[cfg(target_os = "macos")] -fn macos_post_scroll_wheel_event( - delta: MacOSScrollWheelEvent, - target_point: GlobalPoint, -) -> Result<()> { - let units = delta.units; - let wheel1 = delta.posted_y; - let wheel2 = delta.posted_x; - - if wheel1 == 0 && wheel2 == 0 { - return Ok(()); - } - - let source = unsafe { CGEventSourceCreate(macos_hid_event_source_state_id()) }; - - if source.is_null() { - return Err(eyre::eyre!("failed to create macOS scroll wheel event source")); - } - - let wheel_count = if wheel2 != 0 { 2 } else { 1 }; - let event = - unsafe { CGEventCreateScrollWheelEvent2(source, units, wheel_count, wheel1, wheel2, 0) }; - - if event.is_null() { - unsafe { - CFRelease(source); - } - - return Err(eyre::eyre!("failed to create macOS scroll wheel event")); - } - - unsafe { - CGEventSetLocation( - event, - MacOSCGPoint { x: f64::from(target_point.x), y: f64::from(target_point.y) }, - ); - CGEventPost(KCG_HID_EVENT_TAP, event); - CFRelease(event); - CFRelease(source); - } - - Ok(()) -} - -#[cfg(target_os = "macos")] -fn macos_configure_overlay_window_mouse_moved_events(window: &winit::window::Window) { - let Ok(handle) = window.window_handle() else { - return; - }; - let RawWindowHandle::AppKit(appkit) = handle.as_raw() else { - return; - }; - let ns_view = appkit.ns_view.as_ptr().cast::(); - - unsafe { - let ns_window: *mut Object = objc::msg_send![ns_view, window]; - - if ns_window.is_null() { - return; - } - - let _: () = objc::msg_send![ns_window, setOpaque: false]; - let _: () = objc::msg_send![ns_window, setHasShadow: false]; - let sharing_type_none = 0_u64; - let _: () = objc::msg_send![ns_window, setSharingType: sharing_type_none]; - let clear: *mut Object = objc::msg_send![objc::class!(NSColor), clearColor]; - let _: () = objc::msg_send![ns_window, setBackgroundColor: clear]; - let _: () = objc::msg_send![ns_window, setLevel: MACOS_OVERLAY_WINDOW_LEVEL]; - let _: () = objc::msg_send![ns_window, setAcceptsMouseMovedEvents: YES]; - } -} - -#[cfg(target_os = "macos")] -fn macos_configure_hud_window( - window: &winit::window::Window, - blur_enabled: bool, - blur_amount: f32, - corner_radius_points: Option, -) { - let Ok(handle) = window.window_handle() else { - return; - }; - let RawWindowHandle::AppKit(appkit) = handle.as_raw() else { - return; - }; - let ns_view = appkit.ns_view.as_ptr().cast::(); - - unsafe { - let ns_window: *mut Object = objc::msg_send![ns_view, window]; - - if ns_window.is_null() { - return; - } - - // winit exposes blur as a boolean. We also set an explicit radius so we can drive it from - // settings (this uses the same private CGS API that winit uses internally). - { - #[link(name = "CoreGraphics", kind = "framework")] - unsafe extern "C" { - fn CGSMainConnectionID() -> *mut c_void; - - fn CGSSetWindowBackgroundBlurRadius( - connection_id: *mut c_void, - window_id: isize, - radius: i64, - ) -> i32; - } - - let amount = blur_amount.clamp(0.0, 1.0); - let radius = if blur_enabled { - // Map the slider linearly (0..=1) to the native blur radius. - // Keep the upper bound conservative; CGS blur radius gets strong quickly. - let max_radius = 12.0; - - (amount * max_radius).round().clamp(0.0, 200.0) as i64 - } else { - 0 - }; - let window_number: isize = objc::msg_send![ns_window, windowNumber]; - let _ = CGSSetWindowBackgroundBlurRadius(CGSMainConnectionID(), window_number, radius); - } - - let _: () = objc::msg_send![ns_window, setOpaque: false]; - let _: () = objc::msg_send![ns_window, setHasShadow: false]; - let _: () = objc::msg_send![ns_window, setAcceptsMouseMovedEvents: YES]; - let _: () = objc::msg_send![ns_window, setLevel: MACOS_HUD_WINDOW_LEVEL]; - let sharing_type_none = 0_u64; - let _: () = objc::msg_send![ns_window, setSharingType: sharing_type_none]; - let clear: *mut Object = objc::msg_send![objc::class!(NSColor), clearColor]; - let _: () = objc::msg_send![ns_window, setBackgroundColor: clear]; - let content_view: *mut Object = objc::msg_send![ns_window, contentView]; - - if content_view.is_null() { - return; - } - - let _: () = objc::msg_send![content_view, setWantsLayer: YES]; - let layer: *mut Object = objc::msg_send![content_view, layer]; - - if layer.is_null() { - return; - } - - // Round the window itself so native blur doesn't show a rectangular boundary. - let scale = window.scale_factor().max(1.0); - let size = window.inner_size(); - let height_points = (size.height as f64) / scale; - let radius = corner_radius_points.unwrap_or(height_points * 0.5); - let _: () = objc::msg_send![layer, setCornerRadius: radius]; - let _: () = objc::msg_send![layer, setMasksToBounds: YES]; - } -} - -#[cfg(test)] -mod tests { - #[cfg(target_os = "macos")] - use std::collections::VecDeque; - #[cfg(target_os = "macos")] - use std::sync::Arc; - #[cfg(target_os = "macos")] - use std::thread; - use std::time::Duration; - use std::time::Instant; - - use image::{Rgba, RgbaImage}; - #[cfg(target_os = "macos")] - use winit::dpi::PhysicalPosition; - use winit::event::{ElementState, MouseButton, MouseScrollDelta}; - #[cfg(target_os = "macos")] - use winit::keyboard::ModifiersState; - #[cfg(target_os = "macos")] - use winit::window::WindowId; - - #[cfg(target_os = "macos")] - use crate::backend; - #[cfg(target_os = "macos")] - use crate::backend::CaptureBackend; - #[cfg(target_os = "macos")] - use crate::live_frame_stream_macos::MacLiveFrameStream; - use crate::overlay::FrozenCaptureSource; - use crate::overlay::PngAction; - #[cfg(target_os = "macos")] - use crate::overlay::session_state::ScrollCaptureLiveFrame; - use crate::overlay::{ - self, FrozenSelectionDragState, FrozenToolbarState, FrozenToolbarTool, - HUD_LOUPE_STRIP_GAP_POINTS, HudRedrawSummary, HudTheme, OCCLUDED_FRAME_REDRAW_RETRY_WINDOW, - OverlaySession, Pos2, Rect, SCROLL_CAPTURE_SAMPLE_INTERVAL, - SELECTION_DASHED_BORDER_DASH_LENGTH_PX, SELECTION_DASHED_BORDER_GAP_LENGTH_PX, - SELECTION_DASHED_BORDER_WIDTH_PX, SELECTION_SIZE_BADGE_GAP_PX, - SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX, SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX, - SelectionDashedBorderCache, SelectionDashedBorderMetrics, SelectionFlowGeometryCache, - SelectionSizeBadgeTarget, SurfaceFrameSkipReason, TOOLBAR_CAPTURE_GAP_PX, - TOOLBAR_SCREEN_MARGIN_PX, ToolbarPlacement, Vec2, WindowRenderer, hud_helpers, regular, - }; - #[cfg(target_os = "macos")] - use crate::overlay::{ - AltActivationMode, HUD_PILL_CORNER_RADIUS_POINTS, HudPillGeometry, - InflightScrollCaptureObservation, KCG_SCROLL_EVENT_UNIT_PIXEL, LiveSampleApplyResult, - LiveStreamStaleGrace, MacOSScrollPixelResidual, OverlayControl, - SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW, SCROLL_CAPTURE_INPUT_FRESHNESS, - SCROLL_CAPTURE_LIVE_STREAM_STALE_GRACE_FRAMES, SCROLL_CAPTURE_MOUSE_PASSTHROUGH_IDLE_GRACE, - ScrollCaptureFrameSource, StartupLiveRgbPlan, - }; - use crate::scroll_capture::{ScrollDirection, ScrollObserveOutcome, ScrollSession}; - #[cfg(target_os = "macos")] - use crate::state::LiveCursorSample; - use crate::state::{ - GlobalPoint, LoupeSample, MonitorRect, MonitorRectPoints, OverlayMode, OverlayState, - RectPoints, Rgb, - }; - #[cfg(target_os = "macos")] - use crate::state::{WindowListSnapshot, WindowRect}; - #[cfg(target_os = "macos")] - use crate::worker::OverlayWorker; - use crate::worker::{WorkerErrorSource, WorkerResponse}; - - #[cfg(target_os = "macos")] - struct SequenceScrollCaptureBackend { - frames: VecDeque>, - } - - #[cfg(target_os = "macos")] - impl SequenceScrollCaptureBackend { - fn new(frames: impl IntoIterator>) -> Self { - Self { frames: frames.into_iter().collect() } - } - } - - #[cfg(target_os = "macos")] - impl CaptureBackend for SequenceScrollCaptureBackend { - fn capture_monitor( - &mut self, - _monitor: MonitorRect, - ) -> color_eyre::eyre::Result { - Err(color_eyre::eyre::eyre!("unused in this test")) - } - - fn capture_monitor_region_for_scroll_capture( - &mut self, - _monitor: MonitorRect, - _rect_px: RectPoints, - ) -> color_eyre::eyre::Result> { - Ok(self.frames.pop_front().unwrap_or(None)) - } - - fn pixel_rgb_in_monitor( - &mut self, - _monitor: MonitorRect, - _point: GlobalPoint, - ) -> color_eyre::eyre::Result> { - Ok(None) - } - - fn rgba_patch_in_monitor( - &mut self, - _monitor: MonitorRect, - _point: GlobalPoint, - _width_px: u32, - _height_px: u32, - ) -> color_eyre::eyre::Result> { - Ok(None) - } - - fn refresh_window_cache(&mut self) -> color_eyre::eyre::Result> { - Err(color_eyre::eyre::eyre!("unused in this test")) - } - } - - fn make_scroll_capture_test_image(width: u32, rows: &[[u8; 4]]) -> image::RgbaImage { - let mut image = image::RgbaImage::new(width, rows.len() as u32); - - for (y, row) in rows.iter().enumerate() { - for x in 0..width { - image.put_pixel(x, y as u32, Rgba(*row)); - } - } - - image - } - - fn make_scroll_capture_window( - document: &[[u8; 4]], - width: u32, - start_row: usize, - window_rows: usize, - ) -> image::RgbaImage { - make_scroll_capture_test_image(width, &document[start_row..start_row + window_rows]) - } - - #[cfg(target_os = "macos")] - fn make_sparse_worker_capture_window( - width: u32, - height: u32, - start_row: u32, - ) -> image::RgbaImage { - let stripe_x = 104_u32; - let mut image = image::RgbaImage::from_pixel(width, height, Rgba([255, 255, 255, 255])); - - for y in 0..height { - let document_row = start_row.saturating_add(y); - let shade = ((document_row.saturating_mul(17)) % 180) as u8; - - for x in stripe_x..stripe_x.saturating_add(6) { - image.put_pixel(x, y, Rgba([shade, shade, shade, 255])); - } - for x in stripe_x.saturating_add(10)..stripe_x.saturating_add(13) { - if document_row % 19 < 9 { - image.put_pixel(x, y, Rgba([40, 40, 40, 255])); - } - } - } - - image - } - - #[cfg(target_os = "macos")] - fn make_browser_like_worker_capture_window( - width: u32, - height: u32, - start_row: u32, - ) -> image::RgbaImage { - let scrollbar_left = width.saturating_sub(18); - let content_left = 56_u32; - let content_right = width.saturating_sub(48); - let heading_width = 220_u32; - let paragraph_width = content_right.saturating_sub(content_left); - let mut image = make_sparse_worker_capture_window(width, height, start_row); - - for y in 0..height { - let document_row = start_row.saturating_add(y); - - if document_row % 420 < 18 { - for x in content_left..content_left.saturating_add(heading_width) { - image.put_pixel(x, y, Rgba([26, 26, 26, 255])); - } - } else if document_row % 420 >= 54 && document_row % 420 < 220 { - if document_row % 24 < 3 { - let trim = ((document_row / 24) % 5) * 18; - - for x in content_left - ..content_left.saturating_add(paragraph_width.saturating_sub(trim)) - { - image.put_pixel(x, y, Rgba([72, 72, 72, 255])); - } - } - } else if document_row % 420 >= 270 && document_row % 420 < 360 && document_row % 20 < 2 - { - for x in content_left.saturating_add(20) - ..content_left.saturating_add(paragraph_width.saturating_sub(70)) - { - image.put_pixel(x, y, Rgba([98, 98, 98, 255])); - } - } - - for x in scrollbar_left..width { - image.put_pixel(x, y, Rgba([232, 232, 232, 255])); - } - } - - let thumb_height = (height / 5).max(16); - let thumb_top = (start_row / 3) % height.max(thumb_height + 1); - let thumb_top = thumb_top.min(height.saturating_sub(thumb_height)); - - for y in thumb_top..thumb_top.saturating_add(thumb_height) { - for x in scrollbar_left.saturating_add(3)..width.saturating_sub(4) { - image.put_pixel(x, y, Rgba([96, 96, 96, 255])); - } - } - - image - } - - fn set_scroll_capture_input(session: &mut OverlaySession, direction: ScrollDirection) { - session.scroll_capture.input_direction = Some(direction); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; - } - - #[cfg(target_os = "macos")] - fn enable_test_worker_scroll_capture_path(session: &mut OverlaySession) { - session.scroll_capture.force_worker_sampling_in_tests = true; - } - - #[cfg(target_os = "macos")] - fn drain_scroll_capture_worker_until_idle(session: &mut OverlaySession) { - for _ in 0..64 { - let _ = session.drain_worker_responses(); - - if session.scroll_capture.inflight_request_id.is_none() { - return; - } - - thread::sleep(Duration::from_millis(5)); - } - - panic!( - "timed out waiting for worker scroll-capture response; inflight_request_id={:?}", - session.scroll_capture.inflight_request_id - ); - } - - fn observe_scroll_capture_frame( - session: &mut OverlaySession, - frame: image::RgbaImage, - ) -> Option { - session.observe_scroll_capture_frame(frame).transpose().unwrap() - } - - fn scroll_capture_export_height(session: &OverlaySession) -> u32 { - session.scroll_capture.session.as_ref().unwrap().export_image().height() - } - - fn test_monitor() -> MonitorRect { - MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - } - } - - fn test_monitor_with_scale(width: u32, height: u32, scale_factor_x1000: u32) -> MonitorRect { - MonitorRect { id: 1, origin: GlobalPoint::new(0, 0), width, height, scale_factor_x1000 } - } - - fn test_frozen_image() -> RgbaImage { - RgbaImage::from_pixel(8, 8, Rgba([12, 34, 56, 255])) - } - - fn test_egui_context() -> egui::Context { - let ctx = egui::Context::default(); - let mut fonts = egui::FontDefinitions::default(); - let phosphor_fill = String::from("phosphor-fill"); - let proportional_fallback = fonts - .families - .get(&egui::FontFamily::Proportional) - .and_then(|names| names.first()) - .cloned(); - - egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular); - - fonts - .font_data - .insert(phosphor_fill.clone(), egui_phosphor::Variant::Fill.font_data().into()); - fonts - .families - .entry(egui::FontFamily::Name(phosphor_fill.clone().into())) - .or_default() - .extend([phosphor_fill]); - - if let Some(fallback) = proportional_fallback { - let family = - fonts.families.entry(egui::FontFamily::Name("phosphor-fill".into())).or_default(); - - if !family.contains(&fallback) { - family.push(fallback); - } - } - - ctx.set_fonts(fonts); - - let _ = ctx.run_ui(egui::RawInput::default(), |_ui| {}); - - ctx - } - - #[cfg(target_os = "macos")] - fn configured_session_with_macos_worker() -> (OverlaySession, u64) { - let worker = OverlayWorker::new(backend::default_capture_backend(), None); - let worker_debug_id = worker.debug_id(); - let mut session = OverlaySession::new(); - - session.worker = Some(worker); - session.live_sample_stream = Some(MacLiveFrameStream::new()); - session.scroll_capture.active = true; - session.scroll_capture.live_stream = Some(MacLiveFrameStream::with_waker(None)); - session.config.self_capture_exception_window_ids = vec![17]; - - (session, worker_debug_id) - } - - #[cfg(target_os = "macos")] - fn seed_ready_scroll_capture_selection(session: &mut OverlaySession) { - let monitor = test_monitor_with_scale(8, 8, 1_000); - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); - - session.state.frozen_capture_rect = Some(RectPoints::new(1, 1, 4, 4)); - session.frozen_capture_source = FrozenCaptureSource::DragRegion; - session.authoritative_frozen_capture_ready = true; - } - - #[cfg(target_os = "macos")] - #[test] - fn pending_freeze_capture_dispatches_even_with_seeded_preview() { - let monitor = test_monitor(); - let mut session = OverlaySession::new(); - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); - - session.pending_freeze_capture = Some(monitor); - - assert!(session.should_dispatch_pending_freeze_capture(monitor)); - } - - #[cfg(not(target_os = "macos"))] - #[test] - fn pending_freeze_capture_waits_for_empty_frozen_image_off_macos() { - let monitor = test_monitor(); - let mut session = OverlaySession::new(); - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); - - session.pending_freeze_capture = Some(monitor); - - assert!(!session.should_dispatch_pending_freeze_capture(monitor)); - } - - #[test] - fn frozen_final_capture_ready_requires_no_pending_or_inflight_capture() { - let monitor = test_monitor(); - let mut session = OverlaySession::new(); - - session.state.begin_freeze(monitor); - - assert!(!session.frozen_final_capture_ready()); - - session.state.finish_freeze(monitor, test_frozen_image()); - - session.authoritative_frozen_capture_ready = true; - - assert!(session.frozen_final_capture_ready()); - - session.pending_freeze_capture = Some(monitor); - - assert!(!session.frozen_final_capture_ready()); - - session.pending_freeze_capture = None; - session.inflight_freeze_capture = Some(monitor); - - assert!(!session.frozen_final_capture_ready()); - } - - #[test] - fn frozen_preview_does_not_become_final_ready_when_capture_tracking_clears_without_success() { - let monitor = test_monitor(); - let capture_rect = RectPoints::new(100, 120, 220, 180); - let mut session = OverlaySession::new(); - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); - - session.state.frozen_capture_rect = Some(capture_rect); - session.frozen_capture_source = super::FrozenCaptureSource::DragRegion; - session.inflight_freeze_capture = Some(monitor); - - assert!(!session.frozen_final_capture_ready()); - assert!(!session.scroll_capture_selection_is_ready()); - - // Emulate a preview-first failure where the authoritative capture tracking clears. - session.inflight_freeze_capture = None; - - assert!(!session.frozen_final_capture_ready()); - assert!(!session.scroll_capture_selection_is_ready()); - - session.begin_png_action(PngAction::Copy); - - assert_eq!(session.pending_png_action, None); - assert!(session.pending_encode_png.is_none()); - assert_eq!(session.state.error_message.as_deref(), Some("Preparing capture...")); - } - - #[test] - fn unrelated_worker_errors_do_not_clear_pending_freeze_capture_state() { - let monitor = test_monitor(); - let mut session = OverlaySession::new(); - - session.state.begin_freeze(monitor); - - session.pending_freeze_capture = Some(monitor); - session.pending_freeze_capture_armed = true; - session.pending_window_freeze_capture = Some(super::WindowFreezeCaptureTarget { - monitor, - window_id: 42, - rect: RectPoints::new(10, 20, 30, 40), - }); - - let control = session.maybe_tick_worker_response_limiter(WorkerResponse::Error { - source: WorkerErrorSource::RefreshWindowList, - message: String::from("window refresh failed"), - }); - - assert!(matches!(control, super::OverlayControl::Continue)); - assert_eq!(session.pending_freeze_capture, Some(monitor)); - assert!(session.pending_freeze_capture_armed); - assert!(session.inflight_freeze_capture.is_none()); - assert!(session.pending_window_freeze_capture.is_some()); - assert_eq!(session.state.error_message.as_deref(), Some("window refresh failed")); - } - - #[test] - fn frozen_selection_drag_starts_only_for_drag_region_inside_capture_rect() { - let monitor = test_monitor(); - let capture_rect = RectPoints::new(100, 120, 200, 240); - let mut session = OverlaySession::new(); - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); - - session.state.frozen_capture_rect = Some(capture_rect); - - assert!(!session.begin_frozen_selection_drag(GlobalPoint::new(150, 180))); - - session.frozen_capture_source = FrozenCaptureSource::DragRegion; - - assert!(!session.begin_frozen_selection_drag(GlobalPoint::new(50, 80))); - assert!(session.begin_frozen_selection_drag(GlobalPoint::new(150, 180))); - assert_eq!( - session.frozen_selection_drag, - FrozenSelectionDragState { active: true, pointer_offset_x: 50, pointer_offset_y: 60 } - ); - - session.stop_frozen_selection_drag(); - - session.state.frozen_capture_rect = Some(RectPoints::new(0, 120, 200, 240)); - - assert!(!session.begin_frozen_selection_drag(GlobalPoint::new(-1, 180))); - } - - #[test] - fn frozen_selection_drag_updates_capture_rect_and_toolbar_position() { - let monitor = test_monitor(); - let capture_rect = RectPoints::new(100, 120, 200, 240); - let mut session = OverlaySession::new(); - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); - - session.state.frozen_capture_rect = Some(capture_rect); - session.frozen_capture_source = FrozenCaptureSource::DragRegion; - - session.seed_frozen_toolbar_default_position(monitor, capture_rect); - - assert!(session.begin_frozen_selection_drag(GlobalPoint::new(110, 130))); - assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(260, 310))); - - let expected_rect = RectPoints::new(250, 300, 200, 240); - let expected_toolbar_pos = - session.frozen_toolbar_default_position_for_capture_rect(monitor, expected_rect); - - assert_eq!(session.state.frozen_capture_rect, Some(expected_rect)); - assert_eq!(session.toolbar_state.floating_position, Some(expected_toolbar_pos)); - } - - #[test] - fn frozen_selection_drag_clamps_capture_rect_to_monitor_bounds() { - let monitor = test_monitor(); - let capture_rect = RectPoints::new(100, 120, 200, 240); - let mut session = OverlaySession::new(); - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); - - session.state.frozen_capture_rect = Some(capture_rect); - session.frozen_capture_source = FrozenCaptureSource::DragRegion; - - assert!(session.begin_frozen_selection_drag(GlobalPoint::new(110, 130))); - assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(-200, -300))); - assert_eq!(session.state.frozen_capture_rect, Some(RectPoints::new(0, 0, 200, 240))); - assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(1_500, 1_400))); - assert_eq!(session.state.frozen_capture_rect, Some(RectPoints::new(800, 560, 200, 240))); - } - - #[test] - fn cropped_frozen_capture_image_uses_moved_capture_rect() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 4, - height: 3, - scale_factor_x1000: 1_000, - }; - let image = RgbaImage::from_fn(4, 3, |x, y| Rgba([x as u8, y as u8, 0, 255])); - let mut session = OverlaySession::new(); - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, image); - - session.state.frozen_capture_rect = Some(RectPoints::new(0, 0, 2, 1)); - session.frozen_capture_source = FrozenCaptureSource::DragRegion; - - assert!(session.begin_frozen_selection_drag(GlobalPoint::new(0, 0))); - assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(1, 1))); - - let cropped = session.cropped_frozen_capture_image().expect("moved frozen crop"); - - assert_eq!(cropped.width(), 2); - assert_eq!(cropped.height(), 1); - assert_eq!(cropped.get_pixel(0, 0), &Rgba([1, 1, 0, 255])); - assert_eq!(cropped.get_pixel(1, 0), &Rgba([2, 1, 0, 255])); - } - - #[test] - fn auto_center_frozen_capture_rect_recenters_detected_content() { - let monitor = test_monitor_with_scale(80, 60, 2_000); - let capture_rect = RectPoints::new(20, 16, 40, 24); - let mut image = RgbaImage::from_pixel(160, 120, Rgba([14, 16, 20, 255])); - let mut session = OverlaySession::new(); - - for y in 40..52 { - for x in 52..68 { - image.put_pixel(x, y, Rgba([228, 232, 240, 255])); - } - } - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, image); - - session.state.frozen_capture_rect = Some(capture_rect); - session.frozen_capture_source = FrozenCaptureSource::DragRegion; - - session.seed_frozen_toolbar_default_position(monitor, capture_rect); - - assert!(session.auto_center_frozen_capture_rect()); - - let expected_rect = RectPoints::new(10, 11, 40, 24); - let expected_toolbar_pos = - session.frozen_toolbar_default_position_for_capture_rect(monitor, expected_rect); - - assert_eq!(session.state.frozen_capture_rect, Some(expected_rect)); - assert_eq!(session.toolbar_state.floating_position, Some(expected_toolbar_pos)); - } - - #[test] - fn frozen_toolbar_default_position_centers_on_capture_rect_midpoint() { - let monitor = test_monitor_with_scale(400, 300, 2_000); - let capture_rect = RectPoints::new(150, 100, 100, 60); - let session = OverlaySession::new(); - let toolbar_size = WindowRenderer::frozen_toolbar_size(&session.toolbar_state); - let toolbar_pos = - session.frozen_toolbar_default_position_for_capture_rect(monitor, capture_rect); - let toolbar_midpoint_x = toolbar_pos.x + toolbar_size.x * 0.5; - let capture_midpoint_x = capture_rect.x as f32 + capture_rect.width as f32 * 0.5; - - assert_eq!(toolbar_midpoint_x, capture_midpoint_x); - } - - #[test] - fn auto_center_frozen_capture_rect_noops_for_uniform_crop() { - let monitor = test_monitor_with_scale(80, 60, 1_000); - let capture_rect = RectPoints::new(20, 16, 40, 24); - let mut session = OverlaySession::new(); - - session.state.begin_freeze(monitor); - session - .state - .finish_freeze(monitor, RgbaImage::from_pixel(80, 60, Rgba([24, 24, 28, 255]))); - - session.state.frozen_capture_rect = Some(capture_rect); - session.frozen_capture_source = FrozenCaptureSource::DragRegion; - - assert!(!session.auto_center_frozen_capture_rect()); - assert_eq!(session.state.frozen_capture_rect, Some(capture_rect)); - } - - #[test] - fn global_left_release_stops_frozen_selection_drag() { - let mut session = OverlaySession::new(); - - session.frozen_selection_drag = - FrozenSelectionDragState { active: true, pointer_offset_x: 12, pointer_offset_y: 34 }; - - session.maybe_stop_frozen_selection_drag_for_mouse_input( - ElementState::Pressed, - MouseButton::Left, - ); - - assert!(session.frozen_selection_drag.active); - - session.maybe_stop_frozen_selection_drag_for_mouse_input( - ElementState::Released, - MouseButton::Right, - ); - - assert!(session.frozen_selection_drag.active); - - session.maybe_stop_frozen_selection_drag_for_mouse_input( - ElementState::Released, - MouseButton::Left, - ); - - assert_eq!(session.frozen_selection_drag, FrozenSelectionDragState::default()); - } - - #[test] - fn scroll_capture_and_export_wait_for_authoritative_frozen_capture() { - let monitor = test_monitor(); - let capture_rect = RectPoints::new(100, 120, 220, 180); - let mut session = OverlaySession::new(); - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); - - session.authoritative_frozen_capture_ready = true; - session.state.frozen_capture_rect = Some(capture_rect); - session.frozen_capture_source = super::FrozenCaptureSource::DragRegion; - - assert!(session.scroll_capture_selection_is_ready()); - - session.inflight_freeze_capture = Some(monitor); - - assert!(!session.scroll_capture_selection_is_ready()); - - session.begin_png_action(PngAction::Copy); - - assert_eq!(session.pending_png_action, None); - assert!(session.pending_encode_png.is_none()); - assert_eq!(session.state.error_message.as_deref(), Some("Preparing capture...")); - } - - #[test] - fn frozen_selection_scrim_rects_frame_focus_rect_without_covering_it() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 80.0)); - let focus_rect = Rect::from_min_size(Pos2::new(20.0, 10.0), Vec2::new(40.0, 30.0)); - let scrim_rects = WindowRenderer::frozen_selection_scrim_rects(screen_rect, focus_rect); - - assert_eq!( - scrim_rects, - [ - Rect::from_min_max(Pos2::new(0.0, 0.0), Pos2::new(100.0, 10.0)), - Rect::from_min_max(Pos2::new(0.0, 40.0), Pos2::new(100.0, 80.0)), - Rect::from_min_max(Pos2::new(0.0, 10.0), Pos2::new(20.0, 40.0)), - Rect::from_min_max(Pos2::new(60.0, 10.0), Pos2::new(100.0, 40.0)), - ] - ); - assert!(scrim_rects.into_iter().all(|rect| !rect.contains(focus_rect.center()))); - } - - #[test] - fn frozen_selection_scrim_rects_leave_zero_area_regions_at_screen_edges() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 80.0)); - let focus_rect = Rect::from_min_size(Pos2::new(0.0, 10.0), Vec2::new(40.0, 30.0)); - let scrim_rects = WindowRenderer::frozen_selection_scrim_rects(screen_rect, focus_rect); - let non_empty = - scrim_rects.iter().filter(|rect| rect.width() > 0.0 && rect.height() > 0.0).count(); - - assert_eq!(scrim_rects[2].width(), 0.0); - assert_eq!(non_empty, 3); - } - - #[test] - fn frozen_selection_scrim_rects_are_empty_for_fullscreen_rect() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 80.0)); - let scrim_rects = WindowRenderer::frozen_selection_scrim_rects(screen_rect, screen_rect); - - assert!(scrim_rects.into_iter().all(|rect| rect.width() <= 0.0 || rect.height() <= 0.0)); - } - - #[test] - fn selection_dashed_border_rect_is_absent_for_fullscreen_rect() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 80.0)); - let border_outset = - WindowRenderer::selection_dashed_border_outset(SELECTION_DASHED_BORDER_WIDTH_PX, 1.0); - - assert_eq!( - WindowRenderer::selection_dashed_border_rect(screen_rect, screen_rect, border_outset,), - None - ); - } - - #[test] - fn selection_dashed_border_rect_expands_focus_rect_outward() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 80.0)); - let focus_rect = Rect::from_min_size(Pos2::new(20.0, 10.0), Vec2::new(40.0, 30.0)); - let border_outset = - WindowRenderer::selection_dashed_border_outset(SELECTION_DASHED_BORDER_WIDTH_PX, 1.0); - - assert_eq!( - WindowRenderer::selection_dashed_border_rect(screen_rect, focus_rect, border_outset,), - Some(Rect::from_min_max(Pos2::new(18.5, 8.5), Pos2::new(61.5, 41.5),)) - ); - } - - #[test] - fn selection_dashed_border_rect_can_extend_beyond_screen_edge() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 80.0)); - let focus_rect = Rect::from_min_size(Pos2::new(0.0, 10.0), Vec2::new(40.0, 30.0)); - let border_outset = - WindowRenderer::selection_dashed_border_outset(SELECTION_DASHED_BORDER_WIDTH_PX, 1.0); - - assert_eq!( - WindowRenderer::selection_dashed_border_rect(screen_rect, focus_rect, border_outset,), - Some(Rect::from_min_max(Pos2::new(-1.5, 8.5), Pos2::new(41.5, 41.5),)) - ); - } - - #[test] - fn selection_dashed_border_dash_ranges_distribute_remainder_evenly() { - const EPSILON: f32 = 1e-4; - - let rect = Rect::from_min_max(Pos2::new(18.5, 8.5), Pos2::new(61.5, 41.5)); - let perimeter = WindowRenderer::selection_dashed_border_perimeter(rect); - let ranges = WindowRenderer::selection_dashed_border_dash_ranges( - perimeter, - SELECTION_DASHED_BORDER_DASH_LENGTH_PX, - SELECTION_DASHED_BORDER_GAP_LENGTH_PX, - ); - - assert_eq!(ranges.len(), 15); - - let dash_length = ranges[0].1 - ranges[0].0; - let gap_length = ranges[1].0 - ranges[0].1; - - assert!((dash_length - SELECTION_DASHED_BORDER_DASH_LENGTH_PX).abs() < EPSILON); - - for window in ranges.windows(2) { - let current_dash_length = window[0].1 - window[0].0; - let current_gap_length = window[1].0 - window[0].1; - - assert!((current_dash_length - dash_length).abs() < EPSILON); - assert!((current_gap_length - gap_length).abs() < EPSILON); - } - - let seam_gap_length = perimeter - ranges.last().unwrap().1 + ranges[0].0; - - assert!((seam_gap_length - gap_length).abs() < EPSILON); - } - - #[test] - fn selection_dashed_border_segments_split_at_square_corners() { - let rect = Rect::from_min_max(Pos2::new(18.5, 8.5), Pos2::new(38.5, 18.5)); - - assert_eq!( - WindowRenderer::selection_dashed_border_segments(rect, 25.0, 5.0), - vec![ - [Pos2::new(18.5, 8.5), Pos2::new(38.5, 8.5)], - [Pos2::new(38.5, 8.5), Pos2::new(38.5, 13.5)], - [Pos2::new(38.5, 18.5), Pos2::new(18.5, 18.5)], - [Pos2::new(18.5, 18.5), Pos2::new(18.5, 13.5)], - ] - ); - } - - #[test] - fn selection_dashed_border_cache_reuses_geometry_for_same_rect() { - let rect = Rect::from_min_max(Pos2::new(18.5, 8.5), Pos2::new(61.5, 41.5)); - let other_rect = Rect::from_min_max(Pos2::new(18.5, 8.5), Pos2::new(41.5, 41.5)); - let sentinel = [Pos2::new(-1.0, -1.0), Pos2::new(-2.0, -2.0)]; - let mut cache = SelectionDashedBorderCache::default(); - let initial = WindowRenderer::selection_dashed_border_cached_segments( - &mut cache, - rect, - SELECTION_DASHED_BORDER_DASH_LENGTH_PX, - SELECTION_DASHED_BORDER_GAP_LENGTH_PX, - ) - .to_vec(); - - assert!(!initial.is_empty()); - - cache.segments[0] = sentinel; - - let cached = WindowRenderer::selection_dashed_border_cached_segments( - &mut cache, - rect, - SELECTION_DASHED_BORDER_DASH_LENGTH_PX, - SELECTION_DASHED_BORDER_GAP_LENGTH_PX, - ); - - assert_eq!(cached[0], sentinel); - - let rebuilt = WindowRenderer::selection_dashed_border_cached_segments( - &mut cache, - other_rect, - SELECTION_DASHED_BORDER_DASH_LENGTH_PX, - SELECTION_DASHED_BORDER_GAP_LENGTH_PX, - ); - - assert_ne!(rebuilt[0], sentinel); - } - - #[test] - fn selection_dashed_border_outset_accounts_for_feathering() { - assert_eq!( - WindowRenderer::selection_dashed_border_outset(SELECTION_DASHED_BORDER_WIDTH_PX, 1.0), - 1.5 - ); - assert_eq!( - WindowRenderer::selection_dashed_border_outset(SELECTION_DASHED_BORDER_WIDTH_PX, 2.0), - 1.25 - ); - } - - #[test] - fn selection_dashed_border_metrics_track_physical_pixels() { - assert_eq!( - WindowRenderer::selection_dashed_border_metrics(1.0), - SelectionDashedBorderMetrics { stroke_width: 2.0, dash_length: 6.0, gap_length: 4.0 } - ); - assert_eq!( - WindowRenderer::selection_dashed_border_metrics(2.0), - SelectionDashedBorderMetrics { stroke_width: 1.0, dash_length: 3.0, gap_length: 2.0 } - ); - assert_eq!( - WindowRenderer::selection_dashed_border_metrics(1.5), - SelectionDashedBorderMetrics { - stroke_width: 2.0 / 1.5, - dash_length: 6.0 / 1.5, - gap_length: 4.0 / 1.5, - } - ); - } - - #[test] - fn frozen_selection_scrim_is_stronger_than_live_drag_scrim_in_light_theme() { - let frozen_scrim = WindowRenderer::frozen_selection_scrim_color(HudTheme::Light); - let drag_scrim = WindowRenderer::live_drag_selection_scrim_color(HudTheme::Light); - - assert!(frozen_scrim.a() > drag_scrim.a()); - } - - #[test] - fn selection_flow_palette_tracks_hud_theme() { - assert_eq!( - WindowRenderer::selection_flow_palette(HudTheme::Dark), - &super::SELECTION_FLOW_PALETTE - ); - assert_eq!( - WindowRenderer::selection_flow_palette(HudTheme::Light), - &super::SELECTION_FLOW_LIGHT_PALETTE - ); - } - - #[test] - fn selection_flow_color_can_share_theme_rgb() { - let dark = WindowRenderer::selection_flow_color(0.17, HudTheme::Dark, 0.4, 1.0); - let light = WindowRenderer::selection_flow_color(0.17, HudTheme::Light, 0.4, 1.0); - - assert_eq!((dark.r(), dark.g(), dark.b()), (light.r(), light.g(), light.b())); - assert_eq!(dark.a(), light.a()); - } - - #[test] - fn frozen_toolbar_default_position_fits_below_capture_rect() { - let monitor = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); - let capture_rect = Rect::from_min_size(Pos2::new(50.0, 100.0), Vec2::new(300.0, 200.0)); - let toolbar_size = Vec2::new(460.0, 54.0); - let pos = WindowRenderer::frozen_toolbar_default_pos( - monitor, - capture_rect, - toolbar_size, - ToolbarPlacement::Bottom, - ); - let expected_x = (capture_rect.center().x - toolbar_size.x / 2.0).clamp( - TOOLBAR_SCREEN_MARGIN_PX, - (monitor.max.x - toolbar_size.x - TOOLBAR_SCREEN_MARGIN_PX) - .max(TOOLBAR_SCREEN_MARGIN_PX), - ); - - assert!((pos.x - expected_x).abs() < f32::EPSILON); - assert_eq!(pos.y, capture_rect.max.y + TOOLBAR_CAPTURE_GAP_PX); - } - - #[test] - fn frozen_toolbar_default_position_falls_inside_when_no_space_below_capture_rect() { - let monitor = Rect::from_min_size(Pos2::ZERO, Vec2::new(500.0, 600.0)); - let toolbar_size = Vec2::new(460.0, 54.0); - let capture_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(500.0, 560.0)); - let pos = WindowRenderer::frozen_toolbar_default_pos( - monitor, - capture_rect, - toolbar_size, - ToolbarPlacement::Bottom, - ); - let expected_x = (capture_rect.center().x - toolbar_size.x / 2.0).clamp( - TOOLBAR_SCREEN_MARGIN_PX, - (monitor.max.x - toolbar_size.x - TOOLBAR_SCREEN_MARGIN_PX) - .max(TOOLBAR_SCREEN_MARGIN_PX), - ); - let expected_y = capture_rect.max.y - TOOLBAR_SCREEN_MARGIN_PX - toolbar_size.y; - - assert_eq!(pos.x, expected_x); - assert_eq!(pos.y, capture_rect.max.y - TOOLBAR_SCREEN_MARGIN_PX - toolbar_size.y); - assert_eq!(pos.y, expected_y); - } - - #[test] - fn frozen_toolbar_top_default_position_fits_above_capture_rect() { - let monitor = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); - let capture_rect = Rect::from_min_size(Pos2::new(50.0, 180.0), Vec2::new(300.0, 200.0)); - let toolbar_size = Vec2::new(460.0, 54.0); - let pos = WindowRenderer::frozen_toolbar_default_pos( - monitor, - capture_rect, - toolbar_size, - ToolbarPlacement::Top, - ); - let expected_x = (capture_rect.center().x - toolbar_size.x / 2.0).clamp( - TOOLBAR_SCREEN_MARGIN_PX, - (monitor.max.x - toolbar_size.x - TOOLBAR_SCREEN_MARGIN_PX) - .max(TOOLBAR_SCREEN_MARGIN_PX), - ); - - assert_eq!(pos.x, expected_x); - assert_eq!(pos.y, capture_rect.min.y - TOOLBAR_CAPTURE_GAP_PX - toolbar_size.y); - } - - #[test] - fn frozen_toolbar_top_default_position_falls_inside_when_no_space_above_capture_rect() { - let monitor = Rect::from_min_size(Pos2::ZERO, Vec2::new(500.0, 600.0)); - let capture_rect = Rect::from_min_size(Pos2::new(0.0, 20.0), Vec2::new(500.0, 400.0)); - let toolbar_size = Vec2::new(460.0, 54.0); - let pos = WindowRenderer::frozen_toolbar_default_pos( - monitor, - capture_rect, - toolbar_size, - ToolbarPlacement::Top, - ); - let expected_x = (capture_rect.center().x - toolbar_size.x / 2.0).clamp( - TOOLBAR_SCREEN_MARGIN_PX, - (monitor.max.x - toolbar_size.x - TOOLBAR_SCREEN_MARGIN_PX) - .max(TOOLBAR_SCREEN_MARGIN_PX), - ); - - assert_eq!(pos.x, expected_x); - assert_eq!(pos.y, capture_rect.min.y + TOOLBAR_SCREEN_MARGIN_PX); - } - - #[test] - fn selection_size_badge_rect_fits_below_capture_rect() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); - let capture_rect = Rect::from_min_size(Pos2::new(120.0, 160.0), Vec2::new(320.0, 240.0)); - let badge_rect = WindowRenderer::selection_size_badge_rect( - screen_rect, - capture_rect, - Vec2::new(92.0, 26.0), - ); - - assert_eq!(badge_rect.max.x, capture_rect.max.x); - assert_eq!(badge_rect.min.y, capture_rect.max.y + SELECTION_SIZE_BADGE_GAP_PX); - } - - #[test] - fn selection_size_badge_rect_falls_inside_when_no_space_below() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); - let capture_rect = Rect::from_min_size(Pos2::new(120.0, 420.0), Vec2::new(320.0, 160.0)); - let badge_rect = WindowRenderer::selection_size_badge_rect( - screen_rect, - capture_rect, - Vec2::new(92.0, 26.0), - ); - - assert_eq!(badge_rect.max.x, capture_rect.max.x); - assert_eq!(badge_rect.max.y, capture_rect.max.y - SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX); - assert!(badge_rect.max.y <= screen_rect.max.y - SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX); - } - - #[test] - fn selection_size_badge_rect_clamps_narrow_left_capture_into_viewport() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); - let capture_rect = Rect::from_min_size(Pos2::new(0.0, 160.0), Vec2::new(40.0, 120.0)); - let badge_rect = WindowRenderer::selection_size_badge_rect( - screen_rect, - capture_rect, - Vec2::new(92.0, 26.0), - ); - - assert_eq!(badge_rect.min.x, screen_rect.min.x); - assert!(badge_rect.max.x > capture_rect.max.x); - } - - #[test] - fn selection_size_badge_rect_clamps_near_left_narrow_capture_into_viewport() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); - let capture_rect = Rect::from_min_size(Pos2::new(20.0, 160.0), Vec2::new(40.0, 120.0)); - let badge_rect = WindowRenderer::selection_size_badge_rect( - screen_rect, - capture_rect, - Vec2::new(92.0, 26.0), - ); - - assert_eq!(badge_rect.min.x, screen_rect.min.x); - assert!(badge_rect.max.x > capture_rect.max.x); - } - - #[test] - fn selection_size_badge_rect_keeps_tiny_bottom_capture_visible() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); - let capture_rect = Rect::from_min_size(Pos2::new(120.0, 588.0), Vec2::new(140.0, 12.0)); - let badge_rect = WindowRenderer::selection_size_badge_rect( - screen_rect, - capture_rect, - Vec2::new(92.0, 26.0), - ); - - assert_eq!(badge_rect.max.y, screen_rect.max.y); - assert!(badge_rect.min.y < capture_rect.min.y); - assert!(badge_rect.min.y >= screen_rect.min.y); - } - - #[test] - fn frozen_selection_size_badge_falls_inside_when_default_bottom_toolbar_slot_overlaps() { - let monitor = test_monitor(); - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let capture_rect_points = RectPoints::new(200, 180, 200, 300); - let capture_rect = Rect::from_min_size( - Pos2::new(capture_rect_points.x as f32, capture_rect_points.y as f32), - Vec2::new(capture_rect_points.width as f32, capture_rect_points.height as f32), - ); - let mut state = OverlayState::new(); - - state.mode = OverlayMode::Frozen; - state.monitor = Some(monitor); - state.frozen_capture_rect = Some(capture_rect_points); - - let toolbar_state = FrozenToolbarState { visible: true, ..FrozenToolbarState::default() }; - let reserved_rect = WindowRenderer::frozen_toolbar_reserved_rect( - &state, - monitor, - screen_rect, - ToolbarPlacement::Bottom, - &toolbar_state, - ) - .expect("default bottom toolbar slot should be reserved"); - let badge_rect = WindowRenderer::selection_size_badge_rect_with_reserved_rect( - screen_rect, - capture_rect, - Vec2::new(92.0, 26.0), - Some(reserved_rect), - ); - - assert_eq!(reserved_rect.min.y, capture_rect.max.y + TOOLBAR_CAPTURE_GAP_PX); - assert_eq!(badge_rect.max.x, capture_rect.max.x); - assert_eq!(badge_rect.max.y, capture_rect.max.y - SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX); - assert!(!badge_rect.intersects(reserved_rect)); - } - - #[test] - fn frozen_selection_size_badge_keeps_below_placement_after_toolbar_leaves_default_slot() { - let monitor = test_monitor(); - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let capture_rect_points = RectPoints::new(200, 180, 200, 300); - let capture_rect = Rect::from_min_size( - Pos2::new(capture_rect_points.x as f32, capture_rect_points.y as f32), - Vec2::new(capture_rect_points.width as f32, capture_rect_points.height as f32), - ); - let mut state = OverlayState::new(); - - state.mode = OverlayMode::Frozen; - state.monitor = Some(monitor); - state.frozen_capture_rect = Some(capture_rect_points); - - let default_toolbar_pos = WindowRenderer::frozen_toolbar_default_pos( - screen_rect, - capture_rect, - WindowRenderer::frozen_toolbar_size(&FrozenToolbarState::default()), - ToolbarPlacement::Bottom, - ); - let toolbar_state = FrozenToolbarState { - visible: true, - floating_position: Some(default_toolbar_pos + Vec2::new(0.0, 24.0)), - ..FrozenToolbarState::default() - }; - let reserved_rect = WindowRenderer::frozen_toolbar_reserved_rect( - &state, - monitor, - screen_rect, - ToolbarPlacement::Bottom, - &toolbar_state, - ); - let badge_rect = WindowRenderer::selection_size_badge_rect_with_reserved_rect( - screen_rect, - capture_rect, - Vec2::new(92.0, 26.0), - reserved_rect, - ); - - assert!(reserved_rect.is_none()); - assert_eq!(badge_rect.max.x, capture_rect.max.x); - assert_eq!(badge_rect.min.y, capture_rect.max.y + SELECTION_SIZE_BADGE_GAP_PX); - } - - #[test] - fn frozen_top_toolbar_reserved_rect_uses_inside_fallback_slot() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 400, - height: 160, - scale_factor_x1000: 1_000, - }; - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let capture_rect_points = RectPoints::new(40, 20, 240, 110); - let capture_rect = Rect::from_min_size( - Pos2::new(capture_rect_points.x as f32, capture_rect_points.y as f32), - Vec2::new(capture_rect_points.width as f32, capture_rect_points.height as f32), - ); - let mut state = OverlayState::new(); - - state.mode = OverlayMode::Frozen; - state.monitor = Some(monitor); - state.frozen_capture_rect = Some(capture_rect_points); - - let toolbar_state = FrozenToolbarState::default(); - let reserved_rect = WindowRenderer::frozen_toolbar_reserved_rect( - &state, - monitor, - screen_rect, - ToolbarPlacement::Top, - &toolbar_state, - ) - .expect("top fallback slot should still be reserved"); - - assert_eq!(reserved_rect.min.y, capture_rect.min.y + TOOLBAR_SCREEN_MARGIN_PX); - assert_eq!(reserved_rect.height(), WindowRenderer::frozen_toolbar_size(&toolbar_state).y); - } - - #[test] - fn overlay_session_computes_frozen_toolbar_reserved_rect_without_inline_toolbar_state() { - let monitor = test_monitor(); - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let mut session = OverlaySession::new(); - - session.state.mode = OverlayMode::Frozen; - session.state.monitor = Some(monitor); - session.state.frozen_capture_rect = Some(RectPoints::new(200, 180, 200, 300)); - - let reserved_rect = session - .frozen_size_badge_toolbar_reserved_rect(monitor, screen_rect, true) - .expect("overlay redraw should reserve the default toolbar slot"); - - assert_eq!(reserved_rect.min.y, 480.0 + TOOLBAR_CAPTURE_GAP_PX); - assert_eq!( - reserved_rect.height(), - WindowRenderer::frozen_toolbar_size(&session.toolbar_state).y - ); - } - - #[test] - fn frozen_toolbar_reserved_rect_uses_overlay_viewport_size() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 400, - height: 260, - scale_factor_x1000: 1_000, - }; - let overlay_screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(400.0, 120.0)); - let toolbar_window_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(92.0, 26.0)); - let capture_rect_points = RectPoints::new(60, 40, 220, 60); - let capture_rect = Rect::from_min_size( - Pos2::new(capture_rect_points.x as f32, capture_rect_points.y as f32), - Vec2::new(capture_rect_points.width as f32, capture_rect_points.height as f32), - ); - let mut session = OverlaySession::new(); - - session.state.mode = OverlayMode::Frozen; - session.state.monitor = Some(monitor); - session.state.frozen_capture_rect = Some(capture_rect_points); - session.toolbar_state.layout_last_screen_size_points = Some(toolbar_window_rect.size()); - - let toolbar_size = WindowRenderer::frozen_toolbar_size(&session.toolbar_state); - let overlay_default_pos = WindowRenderer::frozen_toolbar_default_pos( - overlay_screen_rect, - capture_rect.intersect(overlay_screen_rect), - toolbar_size, - session.config.toolbar_placement, - ); - let toolbar_window_default_pos = WindowRenderer::frozen_toolbar_default_pos( - toolbar_window_rect, - capture_rect.intersect(toolbar_window_rect), - toolbar_size, - session.config.toolbar_placement, - ); - - session.toolbar_state.floating_position = Some(overlay_default_pos); - - let reserved_rect = session - .frozen_size_badge_toolbar_reserved_rect(monitor, overlay_screen_rect, true) - .expect("overlay viewport-aligned toolbar slot should still be reserved"); - - assert_ne!(overlay_default_pos, toolbar_window_default_pos); - assert_eq!(reserved_rect.min, overlay_default_pos); - assert_eq!(reserved_rect.size(), toolbar_size); - } - - #[test] - fn frozen_toolbar_reserved_rect_skips_hidden_toolbar_slot() { - let monitor = test_monitor(); - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let mut session = OverlaySession::new(); - - session.state.mode = OverlayMode::Frozen; - session.state.monitor = Some(monitor); - session.state.frozen_capture_rect = Some(RectPoints::new(200, 180, 200, 300)); - - assert_eq!( - session.frozen_size_badge_toolbar_reserved_rect(monitor, screen_rect, false), - None - ); - } - - #[test] - fn frozen_toolbar_reserved_rect_waits_for_toolbar_birth_readiness() { - let monitor = test_monitor(); - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let mut session = OverlaySession::new(); - - session.state.mode = OverlayMode::Frozen; - session.state.monitor = Some(monitor); - session.state.frozen_capture_rect = Some(RectPoints::new(200, 180, 200, 300)); - session.toolbar_state.layout_last_screen_size_points = Some(screen_rect.size()); - session.toolbar_state.layout_stable_frames = 0; - - assert!(!session.frozen_toolbar_ready_for_draw(screen_rect)); - assert_eq!( - session.frozen_size_badge_toolbar_reserved_rect( - monitor, - screen_rect, - session.frozen_toolbar_ready_for_draw(screen_rect) - ), - None - ); - - session.toolbar_state.layout_stable_frames = 1; - - assert!(session.frozen_toolbar_ready_for_draw(screen_rect)); - assert!( - session - .frozen_size_badge_toolbar_reserved_rect( - monitor, - screen_rect, - session.frozen_toolbar_ready_for_draw(screen_rect) - ) - .is_some() - ); - } - - #[test] - fn frozen_toolbar_ready_for_draw_ignores_preseeded_position_until_viewport_stabilizes() { - let monitor = test_monitor(); - let capture_rect = RectPoints::new(200, 180, 200, 300); - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let mut session = OverlaySession::new(); - - session.begin_frozen_capture_with_rect(monitor, Some(capture_rect), None, None); - - assert!(session.toolbar_state.floating_position.is_some()); - assert_eq!(session.toolbar_state.layout_last_screen_size_points, None); - assert_eq!(session.toolbar_state.layout_stable_frames, 0); - assert!(!session.frozen_toolbar_ready_for_draw(screen_rect)); - assert_eq!( - session.frozen_size_badge_toolbar_reserved_rect( - monitor, - screen_rect, - session.frozen_toolbar_ready_for_draw(screen_rect) - ), - None - ); - } - - #[test] - fn frozen_toolbar_ready_for_draw_recovers_after_preseeded_position_is_sampled() { - let monitor = test_monitor(); - let capture_rect = RectPoints::new(200, 180, 200, 300); - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let mut session = OverlaySession::new(); - - session.begin_frozen_capture_with_rect(monitor, Some(capture_rect), None, None); - - assert!(!session.advance_frozen_toolbar_readiness_sample(screen_rect)); - assert_eq!(session.toolbar_state.layout_last_screen_size_points, Some(screen_rect.size())); - assert_eq!(session.toolbar_state.layout_stable_frames, 0); - assert!(!session.frozen_toolbar_ready_for_draw(screen_rect)); - assert!(!session.advance_frozen_toolbar_readiness_sample(screen_rect)); - assert_eq!(session.toolbar_state.layout_stable_frames, 1); - assert!(session.frozen_toolbar_ready_for_draw(screen_rect)); - } - - #[test] - fn render_frozen_toolbar_ui_waits_for_readiness_before_first_visible_frame() { - let ctx = test_egui_context(); - let monitor = test_monitor(); - let capture_rect = RectPoints::new(200, 180, 200, 300); - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let mut session = OverlaySession::new(); - let toolbar_placement = session.config.toolbar_placement; - - session.begin_frozen_capture_with_rect(monitor, Some(capture_rect), None, None); - - assert!(session.toolbar_state.visible); - assert_eq!(session.toolbar_state.layout_last_screen_size_points, None); - assert_eq!(session.toolbar_state.layout_stable_frames, 0); - - for frame in 0..2 { - let state = &session.state; - let toolbar_state = &mut session.toolbar_state; - let mut hud_pill = None; - let _ = ctx.run_ui( - egui::RawInput { screen_rect: Some(screen_rect), ..Default::default() }, - |ui| { - WindowRenderer::render_frozen_toolbar_ui( - ui.ctx(), - state, - monitor, - HudTheme::Dark, - toolbar_placement, - false, - false, - 1.0, - 0.0, - 0.0, - Some(toolbar_state), - None, - &mut hud_pill, - ); - }, - ); - - assert!( - hud_pill.is_none(), - "frame {frame} should not draw the toolbar before readiness stabilizes" - ); - } - - let state = &session.state; - let toolbar_state = &mut session.toolbar_state; - let mut hud_pill = None; - let _ = ctx.run_ui( - egui::RawInput { screen_rect: Some(screen_rect), ..Default::default() }, - |ui| { - WindowRenderer::render_frozen_toolbar_ui( - ui.ctx(), - state, - monitor, - HudTheme::Dark, - toolbar_placement, - false, - false, - 1.0, - 0.0, - 0.0, - Some(toolbar_state), - None, - &mut hud_pill, - ); - }, - ); - - assert!(hud_pill.is_some(), "third frame should draw the stabilized toolbar"); - } - - #[test] - fn frozen_toolbar_reserved_rect_restores_near_default_slot() { - let monitor = test_monitor(); - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let capture_rect = Rect::from_min_size(Pos2::new(200.0, 180.0), Vec2::new(200.0, 300.0)); - let mut state = OverlayState::new(); - let mut toolbar_state = FrozenToolbarState::default(); - let toolbar_size = WindowRenderer::frozen_toolbar_size(&toolbar_state); - let default_pos = WindowRenderer::frozen_toolbar_default_pos( - screen_rect, - capture_rect, - toolbar_size, - ToolbarPlacement::Bottom, - ); - let restored_pos = default_pos + Vec2::new(0.4, -0.35); - - state.mode = OverlayMode::Frozen; - state.monitor = Some(monitor); - state.frozen_capture_rect = Some(RectPoints::new(200, 180, 200, 300)); - toolbar_state.visible = true; - toolbar_state.floating_position = Some(restored_pos); - - assert_eq!( - WindowRenderer::frozen_toolbar_reserved_rect( - &state, - monitor, - screen_rect, - ToolbarPlacement::Bottom, - &toolbar_state, - ), - Some(Rect::from_min_size(restored_pos, toolbar_size)) - ); - } - - #[test] - fn frozen_toolbar_overlay_viewport_sample_recovers_from_toolbar_window_pollution() { - let monitor = test_monitor(); - let overlay_screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let toolbar_window_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(92.0, 26.0)); - let mut session = OverlaySession::new(); - - session.state.mode = OverlayMode::Frozen; - session.state.monitor = Some(monitor); - session.state.frozen_capture_rect = Some(RectPoints::new(200, 180, 200, 300)); - session.toolbar_state.layout_last_screen_size_points = Some(toolbar_window_rect.size()); - session.toolbar_state.layout_stable_frames = 1; - - assert!(!session.frozen_toolbar_ready_for_draw(overlay_screen_rect)); - assert!(!session.advance_frozen_toolbar_readiness_sample(overlay_screen_rect)); - assert_eq!( - session.toolbar_state.layout_last_screen_size_points, - Some(overlay_screen_rect.size()) - ); - assert_eq!(session.toolbar_state.layout_stable_frames, 0); - assert_eq!( - session.frozen_size_badge_toolbar_reserved_rect( - monitor, - overlay_screen_rect, - session.frozen_toolbar_ready_for_draw(overlay_screen_rect) - ), - None - ); - assert!(!session.advance_frozen_toolbar_readiness_sample(overlay_screen_rect)); - assert_eq!(session.toolbar_state.layout_stable_frames, 1); - assert_eq!( - session.frozen_size_badge_toolbar_reserved_rect( - monitor, - overlay_screen_rect, - session.frozen_toolbar_ready_for_draw(overlay_screen_rect) - ), - Some( - WindowRenderer::frozen_toolbar_reserved_rect( - &session.state, - monitor, - overlay_screen_rect, - session.config.toolbar_placement, - &session.toolbar_state, - ) - .expect("reserved rect after overlay viewport stabilization") - ) - ); - } - - #[test] - fn selection_size_badge_reserved_rect_prefers_upper_band_when_bottom_space_is_reserved() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(320.0, 220.0)); - let capture_rect = Rect::from_min_size(Pos2::new(40.0, 40.0), Vec2::new(200.0, 150.0)); - let reserved_rect = Rect::from_min_size(Pos2::new(80.0, 140.0), Vec2::new(120.0, 40.0)); - let badge_rect = WindowRenderer::selection_size_badge_rect_with_reserved_rect( - screen_rect, - capture_rect, - Vec2::new(92.0, 26.0), - Some(reserved_rect), - ); - - assert_eq!( - badge_rect.min.y, - reserved_rect.min.y - SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX - 26.0 - ); - assert!(!badge_rect.intersects(reserved_rect)); - } - - #[test] - fn selection_size_badge_reserved_rect_keeps_preferred_inside_when_top_space_is_clear() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(320.0, 200.0)); - let capture_rect = Rect::from_min_size(Pos2::new(40.0, 20.0), Vec2::new(200.0, 150.0)); - let reserved_rect = Rect::from_min_size(Pos2::new(80.0, 28.0), Vec2::new(120.0, 40.0)); - let badge_rect = WindowRenderer::selection_size_badge_rect_with_reserved_rect( - screen_rect, - capture_rect, - Vec2::new(92.0, 26.0), - Some(reserved_rect), - ); - - assert_eq!(badge_rect.max.y, capture_rect.max.y - SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX); - assert!(!badge_rect.intersects(reserved_rect)); - } - - #[test] - fn selection_size_badge_reserved_rect_falls_above_capture_when_inside_space_is_exhausted() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(320.0, 220.0)); - let capture_rect = Rect::from_min_size(Pos2::new(40.0, 170.0), Vec2::new(120.0, 50.0)); - let reserved_rect = Rect::from_min_size(Pos2::new(40.0, 178.0), Vec2::new(120.0, 40.0)); - let badge_rect = WindowRenderer::selection_size_badge_rect_with_reserved_rect( - screen_rect, - capture_rect, - Vec2::new(92.0, 26.0), - Some(reserved_rect), - ); - - assert_eq!(badge_rect.max.x, capture_rect.max.x); - assert_eq!(badge_rect.max.y, capture_rect.min.y - SELECTION_SIZE_BADGE_GAP_PX); - assert!(!badge_rect.intersects(reserved_rect)); - } - - #[test] - fn selection_size_badge_reserved_rect_uses_above_slot_at_top_edge_when_visible() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(320.0, 112.0)); - let capture_rect = Rect::from_min_size(Pos2::new(40.0, 34.0), Vec2::new(120.0, 50.0)); - let reserved_rect = Rect::from_min_size(Pos2::new(40.0, 42.0), Vec2::new(120.0, 40.0)); - let badge_rect = WindowRenderer::selection_size_badge_rect_with_reserved_rect( - screen_rect, - capture_rect, - Vec2::new(92.0, 26.0), - Some(reserved_rect), - ); - - assert_eq!(badge_rect.min.y, screen_rect.min.y); - assert_eq!(badge_rect.max.y, capture_rect.min.y - SELECTION_SIZE_BADGE_GAP_PX); - assert!(!badge_rect.intersects(reserved_rect)); - } - - #[test] - fn selection_size_badge_reserved_rect_accepts_overlap_when_no_non_overlapping_slot_exists() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(320.0, 52.0)); - let capture_rect = Rect::from_min_size(Pos2::new(40.0, 20.0), Vec2::new(120.0, 32.0)); - let reserved_rect = Rect::from_min_size(Pos2::new(40.0, 22.0), Vec2::new(120.0, 24.0)); - let badge_rect = WindowRenderer::selection_size_badge_rect_with_reserved_rect( - screen_rect, - capture_rect, - Vec2::new(92.0, 26.0), - Some(reserved_rect), - ); - - assert_eq!(badge_rect.max.x, capture_rect.max.x); - assert_eq!(badge_rect.min.y, capture_rect.min.y); - assert!(badge_rect.intersects(reserved_rect)); - } - - #[test] - fn selection_size_badge_text_uses_monitor_pixel_dimensions() { - let monitor = test_monitor_with_scale(1_000, 800, 2_000); - - assert_eq!( - WindowRenderer::selection_size_badge_text(monitor, RectPoints::new(10, 20, 120, 80)), - "240x160" - ); - } - - #[test] - fn selection_size_badge_layout_keeps_visual_bounds_within_right_edge_rect() { - let ctx = test_egui_context(); - let layout = - WindowRenderer::selection_size_badge_layout(&ctx, "240x160", HudTheme::Light, 1.0); - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); - let capture_rect = Rect::from_min_size(Pos2::new(760.0, 160.0), Vec2::new(40.0, 120.0)); - let badge_rect = - WindowRenderer::selection_size_badge_rect(screen_rect, capture_rect, layout.badge_size); - let text_anchor = WindowRenderer::selection_size_badge_text_anchor(badge_rect, layout, 1.0); - let visual_bounds = - WindowRenderer::selection_size_badge_visual_bounds(text_anchor, layout.text_size, 1.0); - - assert_eq!(badge_rect.max.x, capture_rect.max.x); - assert!(visual_bounds.min.x >= badge_rect.min.x); - assert!(visual_bounds.max.x <= badge_rect.max.x); - } - - #[test] - fn selection_size_badge_layout_keeps_visual_bounds_within_bottom_fallback_rect() { - let ctx = test_egui_context(); - let layout = - WindowRenderer::selection_size_badge_layout(&ctx, "240x160", HudTheme::Light, 1.0); - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); - let capture_rect = Rect::from_min_size(Pos2::new(120.0, 588.0), Vec2::new(140.0, 12.0)); - let badge_rect = - WindowRenderer::selection_size_badge_rect(screen_rect, capture_rect, layout.badge_size); - let text_anchor = WindowRenderer::selection_size_badge_text_anchor(badge_rect, layout, 1.0); - let visual_bounds = - WindowRenderer::selection_size_badge_visual_bounds(text_anchor, layout.text_size, 1.0); - - assert_eq!(badge_rect.max.y, screen_rect.max.y); - assert!(visual_bounds.min.y >= badge_rect.min.y); - assert!(visual_bounds.max.y <= badge_rect.max.y); - } - - #[test] - fn live_capture_size_badge_target_prefers_drag_then_hover_then_fullscreen() { - let monitor = test_monitor(); - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let mut state = OverlayState::new(); - - state.mode = OverlayMode::Live; - state.cursor = Some(GlobalPoint::new(320, 260)); - state.hovered_window_rect = Some(MonitorRectPoints { - monitor_id: monitor.id, - rect: RectPoints::new(120, 140, 300, 220), - }); - - assert_eq!( - WindowRenderer::live_capture_size_badge_target(&state, monitor, screen_rect, true), - Some(SelectionSizeBadgeTarget { - rect: Rect::from_min_size(Pos2::new(120.0, 140.0), Vec2::new(300.0, 220.0)), - size_points: RectPoints::new(120, 140, 300, 220), - }) - ); - - state.drag_rect = Some(MonitorRectPoints { - monitor_id: monitor.id, - rect: RectPoints::new(180, 200, 260, 180), - }); - - assert_eq!( - WindowRenderer::live_capture_size_badge_target(&state, monitor, screen_rect, true), - Some(SelectionSizeBadgeTarget { - rect: Rect::from_min_size(Pos2::new(180.0, 200.0), Vec2::new(260.0, 180.0)), - size_points: RectPoints::new(180, 200, 260, 180), - }) - ); - - state.drag_rect = None; - state.hovered_window_rect = None; - - assert_eq!( - WindowRenderer::live_capture_size_badge_target(&state, monitor, screen_rect, true), - Some(SelectionSizeBadgeTarget { - rect: screen_rect, - size_points: RectPoints::new(0, 0, monitor.width, monitor.height), - }) - ); - } - - #[test] - fn live_capture_size_badge_target_skips_fullscreen_fallback_while_primary_down() { - let monitor = test_monitor(); - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let mut state = OverlayState::new(); - - state.mode = OverlayMode::Live; - state.cursor = Some(GlobalPoint::new(320, 260)); - - assert_eq!( - WindowRenderer::live_capture_size_badge_target(&state, monitor, screen_rect, false), - None - ); - } - - #[test] - fn frozen_capture_size_badge_target_uses_frozen_rect() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(1_000.0, 800.0)); - let mut state = OverlayState::new(); - - state.mode = OverlayMode::Frozen; - state.frozen_capture_rect = Some(RectPoints::new(140, 180, 320, 240)); - - assert_eq!( - WindowRenderer::frozen_capture_size_badge_target(&state, screen_rect), - Some(SelectionSizeBadgeTarget { - rect: Rect::from_min_size(Pos2::new(140.0, 180.0), Vec2::new(320.0, 240.0)), - size_points: RectPoints::new(140, 180, 320, 240), - }) - ); - } - - #[test] - fn frozen_capture_size_badge_target_keeps_tiny_frozen_rect() { - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(1_000.0, 800.0)); - let mut state = OverlayState::new(); - - state.mode = OverlayMode::Frozen; - state.frozen_capture_rect = Some(RectPoints::new(140, 180, 2, 1)); - - assert_eq!( - WindowRenderer::frozen_capture_size_badge_target(&state, screen_rect), - Some(SelectionSizeBadgeTarget { - rect: Rect::from_min_size(Pos2::new(140.0, 180.0), Vec2::new(2.0, 1.0)), - size_points: RectPoints::new(140, 180, 2, 1), - }) - ); - } - - #[test] - fn render_frozen_capture_affordance_keeps_tiny_frozen_badge_path() { - let ctx = test_egui_context(); - let monitor = test_monitor(); - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let mut state = OverlayState::new(); - let mut selection_flow_geometry_cache = SelectionFlowGeometryCache::default(); - let mut selection_dashed_border_cache = SelectionDashedBorderCache::default(); - - state.mode = OverlayMode::Frozen; - state.monitor = Some(monitor); - state.frozen_capture_rect = Some(RectPoints::new(140, 180, 2, 1)); - - assert!(WindowRenderer::render_frozen_capture_affordance( - &ctx, - &state, - monitor, - screen_rect, - HudTheme::Dark, - None, - false, - true, - 1.0, - &mut selection_flow_geometry_cache, - &mut selection_dashed_border_cache, - )); - } - - #[test] - fn render_live_capture_affordances_keep_hover_scrim_when_flow_disabled() { - let ctx = test_egui_context(); - let layer = - egui::LayerId::new(egui::Order::Foreground, egui::Id::new("live-hover-flow-disabled")); - let painter = ctx.layer_painter(layer); - let monitor = test_monitor(); - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let selection_dashed_border_cache = SelectionDashedBorderCache::default(); - let mut state = OverlayState::new(); - let mut selection_flow_geometry_cache = SelectionFlowGeometryCache::default(); - - state.mode = OverlayMode::Live; - state.hovered_window_rect = Some(MonitorRectPoints { - monitor_id: monitor.id, - rect: RectPoints::new(100, 120, 240, 320), - }); - - assert!(WindowRenderer::render_live_capture_affordances( - &ctx, - &painter, - &state, - monitor, - screen_rect, - HudTheme::Light, - false, - 1.0, - &mut selection_flow_geometry_cache, - )); - assert_eq!(selection_dashed_border_cache.key, None); - } - - #[test] - fn live_capture_size_badge_target_keeps_tiny_drag_rect() { - let monitor = test_monitor(); - let screen_rect = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let mut state = OverlayState::new(); - - state.mode = OverlayMode::Live; - state.drag_rect = Some(MonitorRectPoints { - monitor_id: monitor.id, - rect: RectPoints::new(180, 200, 2, 1), - }); - - assert_eq!( - WindowRenderer::live_capture_size_badge_target(&state, monitor, screen_rect, false), - Some(SelectionSizeBadgeTarget { - rect: Rect::from_min_size(Pos2::new(180.0, 200.0), Vec2::new(2.0, 1.0)), - size_points: RectPoints::new(180, 200, 2, 1), - }) - ); - } - - #[test] - fn live_loupe_default_position_hangs_below_hud_strip_when_space_exists() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 800, - height: 600, - scale_factor_x1000: 1_000, - }; - let hud_outer = GlobalPoint::new(220, 120); - let pos = OverlaySession::live_loupe_default_position( - monitor, - Some(GlobalPoint::new(100, 100)), - Some(hud_outer), - Some(52), - 232, - 232, - ) - .unwrap(); - - assert_eq!(pos.x, hud_outer.x); - assert_eq!(pos.y, hud_outer.y + 52 + HUD_LOUPE_STRIP_GAP_POINTS); - } - - #[test] - fn live_loupe_default_position_falls_above_hud_strip_when_below_overflows() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 800, - height: 500, - scale_factor_x1000: 1_000, - }; - let hud_outer = GlobalPoint::new(220, 300); - let pos = OverlaySession::live_loupe_default_position( - monitor, - Some(GlobalPoint::new(100, 100)), - Some(hud_outer), - Some(52), - 232, - 232, - ) - .unwrap(); - - assert_eq!(pos.x, hud_outer.x); - assert_eq!(pos.y, hud_outer.y - HUD_LOUPE_STRIP_GAP_POINTS - 232); - } - - #[test] - fn scroll_toolbar_compacts_to_two_buttons() { - let frozen_toolbar_size = - WindowRenderer::frozen_toolbar_size(&FrozenToolbarState::default()); - let scroll_toolbar_size = WindowRenderer::frozen_toolbar_size(&FrozenToolbarState { - scroll_capture_active: true, - ..FrozenToolbarState::default() - }); - - assert!(scroll_toolbar_size.x < frozen_toolbar_size.x); - assert_eq!(scroll_toolbar_size.y, frozen_toolbar_size.y); - } - - #[cfg(target_os = "macos")] - #[test] - fn drag_region_toolbar_size_stays_stable_while_final_capture_readiness_changes() { - let monitor = test_monitor(); - let mut session = OverlaySession::new(); - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); - - session.state.frozen_capture_rect = Some(RectPoints::new(120, 160, 320, 240)); - session.frozen_capture_source = FrozenCaptureSource::DragRegion; - - session.sync_frozen_toolbar_state(); - - let pending_toolbar_size = WindowRenderer::frozen_toolbar_size(&session.toolbar_state); - let pending_tools = WindowRenderer::frozen_toolbar_tools(&session.toolbar_state); - - assert!(!session.toolbar_state.final_capture_ready); - assert!(pending_tools.contains(&FrozenToolbarTool::AutoCenter)); - assert!(pending_tools.contains(&FrozenToolbarTool::Scroll)); - - session.authoritative_frozen_capture_ready = true; - - session.sync_frozen_toolbar_state(); - - let ready_toolbar_size = WindowRenderer::frozen_toolbar_size(&session.toolbar_state); - let ready_tools = WindowRenderer::frozen_toolbar_tools(&session.toolbar_state); - - assert!(session.toolbar_state.final_capture_ready); - assert!(ready_tools.contains(&FrozenToolbarTool::AutoCenter)); - assert!(ready_tools.contains(&FrozenToolbarTool::Scroll)); - assert_eq!(pending_toolbar_size, ready_toolbar_size); - } - - #[cfg(target_os = "macos")] - #[test] - fn drag_region_toolbar_recenters_when_auto_center_appears_after_preview_commit() { - let monitor = test_monitor(); - let capture_rect = RectPoints::new(120, 160, 320, 240); - let mut session = OverlaySession::new(); - - session.begin_frozen_capture_with_rect(monitor, Some(capture_rect), None, None); - - let seeded_pos = session - .toolbar_state - .floating_position - .expect("toolbar should seed before frozen preview is ready"); - let seeded_size = WindowRenderer::frozen_toolbar_size(&session.toolbar_state); - let capture_midpoint_x = capture_rect.x as f32 + capture_rect.width as f32 * 0.5; - - assert!(!session.toolbar_state.auto_center_available); - assert_eq!(seeded_pos.x + seeded_size.x * 0.5, capture_midpoint_x); - - session.commit_frozen_preview(monitor, test_frozen_image(), None); - session.sync_frozen_toolbar_state(); - - let ready_size = WindowRenderer::frozen_toolbar_size(&session.toolbar_state); - - assert!(session.toolbar_state.auto_center_available); - assert!(ready_size.x > seeded_size.x); - assert!(session.maybe_recenter_frozen_toolbar_default_slot(monitor)); - - let recentered_pos = session - .toolbar_state - .floating_position - .expect("toolbar should keep a default position"); - - assert_eq!(recentered_pos.x + ready_size.x * 0.5, capture_midpoint_x); - assert_eq!(session.toolbar_state.default_slot_position, Some(recentered_pos)); - } - - #[cfg(target_os = "macos")] - #[test] - fn late_toolbar_width_change_preserves_manual_toolbar_move() { - let monitor = test_monitor(); - let capture_rect = RectPoints::new(120, 160, 320, 240); - let mut session = OverlaySession::new(); - - session.begin_frozen_capture_with_rect(monitor, Some(capture_rect), None, None); - - let seeded_default_pos = session - .toolbar_state - .floating_position - .expect("toolbar should seed before frozen preview is ready"); - let moved_pos = seeded_default_pos + Vec2::new(24.0, 0.0); - - session.toolbar_state.floating_position = Some(moved_pos); - - session.commit_frozen_preview(monitor, test_frozen_image(), None); - session.sync_frozen_toolbar_state(); - - assert!(!session.maybe_recenter_frozen_toolbar_default_slot(monitor)); - assert_eq!(session.toolbar_state.floating_position, Some(moved_pos)); - assert_eq!( - session.toolbar_state.default_slot_position, - Some(session.frozen_toolbar_default_position_for_capture_rect(monitor, capture_rect)) - ); - } - #[test] - fn auto_center_toolbar_tool_only_appears_when_available() { - let default_tools = WindowRenderer::frozen_toolbar_tools(&FrozenToolbarState::default()); - let auto_center_tools = WindowRenderer::frozen_toolbar_tools(&FrozenToolbarState { - auto_center_available: true, - ..FrozenToolbarState::default() - }); - - assert!(!default_tools.contains(&FrozenToolbarTool::AutoCenter)); - assert!(auto_center_tools.contains(&FrozenToolbarTool::AutoCenter)); - - #[cfg(target_os = "macos")] + if let Some(hud_window) = self.hud_window.as_mut() + && hud_window.window.id() == window_id { - assert!(default_tools.contains(&FrozenToolbarTool::Ocr)); - assert!(auto_center_tools.contains(&FrozenToolbarTool::Ocr)); - } - } - - #[test] - fn scroll_preview_prefers_right_side_when_space_exists() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_400, - height: 900, - scale_factor_x1000: 1_000, - }; - let mut session = OverlaySession::new(); - - session.state.frozen_capture_rect = Some(RectPoints::new(120, 160, 400, 320)); - - let preview = session.scroll_preview_local_rect(monitor); - - assert_eq!(preview.min.y, 160.0); - assert_eq!(preview.height(), 320.0); - assert!(preview.min.x >= 120.0 + 400.0); - } - - #[test] - fn scroll_preview_falls_back_to_left_when_right_side_is_tight() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 900, - scale_factor_x1000: 1_000, - }; - let mut session = OverlaySession::new(); - - session.state.frozen_capture_rect = Some(RectPoints::new(760, 180, 200, 260)); - - let preview = session.scroll_preview_local_rect(monitor); - - assert_eq!(preview.min.y, 180.0); - assert_eq!(preview.height(), 260.0); - assert!(preview.max.x <= 760.0); - } - - #[test] - fn scroll_preview_grows_with_render_height_until_monitor_limit() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_400, - height: 900, - scale_factor_x1000: 1_000, - }; - let mut session = OverlaySession::new(); - - session.state.frozen_capture_rect = Some(RectPoints::new(120, 160, 400, 320)); - session.scroll_capture.preview_display_image = Some(RgbaImage::new(320, 960)); - - let preview = session.scroll_preview_local_rect(monitor); - - assert_eq!(preview.min.y, 160.0); - assert_eq!(preview.height(), 724.0); - } - - #[test] - fn current_scroll_preview_render_image_prefers_committed_export_during_scroll_capture() { - let mut session = OverlaySession::new(); - let base = make_scroll_capture_test_image(3, &[[10, 0, 0, 255]; 8]); - let grown = make_scroll_capture_test_image(3, &[[20, 0, 0, 255]; 12]); - let mismatched_preview = RgbaImage::from_pixel(320, 40, Rgba([99, 0, 0, 255])); - let mut scroll_session = ScrollSession::new(base, 320).expect("scroll session"); - let _ = scroll_session.observe_downward_sample(grown).expect("observe"); - let expected_export = scroll_session.export_image().clone(); - - session.scroll_capture.active = true; - session.scroll_capture.session = Some(scroll_session); - session.scroll_capture.preview_display_image = Some(mismatched_preview.clone()); - - assert_eq!(session.current_scroll_preview_render_image().as_ref(), Some(&expected_export)); - } - - #[test] - fn current_scroll_preview_render_image_uses_preview_display_when_scroll_capture_is_inactive() { - let preview = RgbaImage::from_pixel(320, 64, Rgba([42, 0, 0, 255])); - let mut session = OverlaySession::new(); - - session.scroll_capture.preview_display_image = Some(preview.clone()); - - assert_eq!(session.current_scroll_preview_render_image().as_ref(), Some(&preview)); - } - - #[test] - fn scroll_capture_preview_dimensions_follow_render_authority_during_scroll_capture() { - let mut session = OverlaySession::new(); - let base = make_scroll_capture_test_image(3, &[[10, 0, 0, 255]; 8]); - let grown = make_scroll_capture_test_image(3, &[[20, 0, 0, 255]; 12]); - let mismatched_preview = RgbaImage::from_pixel(320, 40, Rgba([99, 0, 0, 255])); - let mut scroll_session = ScrollSession::new(base, 320).expect("scroll session"); - let _ = scroll_session.observe_downward_sample(grown).expect("observe"); - let expected_export = scroll_session.export_image().clone(); - - session.scroll_capture.active = true; - session.scroll_capture.session = Some(scroll_session); - session.scroll_capture.preview_display_image = Some(mismatched_preview.clone()); - - assert_eq!( - session.scroll_capture_preview_dimensions(), - Some([expected_export.width(), expected_export.height()]) - ); - } - - #[test] - fn refresh_scroll_preview_display_image_uses_export_sized_render_buffer_during_active_capture() - { - let mut session = OverlaySession::new(); - let base = make_scroll_capture_test_image(3, &[[10, 0, 0, 255]; 8]); - let grown = make_scroll_capture_test_image(3, &[[20, 0, 0, 255]; 12]); - let mut scroll_session = ScrollSession::new(base, 320).expect("scroll session"); - let _ = scroll_session.observe_downward_sample(grown).expect("observe"); - let expected_committed = scroll_session.export_image().clone(); - let expected_render = scroll_session.export_image().clone(); - - session.scroll_capture.active = true; - session.scroll_capture.session = Some(scroll_session); - - session.refresh_scroll_preview_committed_image(); - session.refresh_scroll_preview_display_image(); - - assert_eq!( - session.scroll_capture.preview_committed_image.as_ref(), - Some(&expected_committed) - ); - assert_eq!(session.scroll_capture.preview_display_image.as_ref(), Some(&expected_render)); - assert_eq!(session.scroll_capture.last_overlay_preview_provisional_motion_rows_hint, None); - assert_eq!(session.scroll_capture.last_overlay_preview_existing_candidate_height, None); - assert_eq!( - session.scroll_capture.last_overlay_preview_existing_candidate_motion_rows_hint, - None - ); - assert_eq!(session.scroll_capture.last_overlay_preview_ledger_candidate_height, None); - assert_eq!( - session.scroll_capture.last_overlay_preview_ledger_candidate_motion_rows_hint, - None - ); - assert_eq!(session.scroll_capture.last_overlay_preview_retained_candidate_height, None); - assert_eq!( - session.scroll_capture.last_overlay_preview_retained_candidate_motion_rows_hint, - None - ); - assert!(!session.scroll_capture.last_overlay_preview_retained_hint_matches_motion_rows); - assert!(!session.scroll_capture.last_overlay_preview_fresh_latest_frame_can_drive); - assert!(!session.scroll_capture.last_overlay_preview_strong_unresolved_registration); - assert!(!session.scroll_capture.last_overlay_preview_latest_frame_present); - assert!(!session.scroll_capture.last_overlay_preview_used_provisional); - assert_eq!( - session.scroll_capture_preview_dimensions(), - Some([expected_render.width(), expected_render.height()]) - ); - } - - #[test] - fn begin_png_action_copies_preview_render_image_during_active_scroll_capture() { - let mut session = OverlaySession::new(); - let base = make_scroll_capture_test_image(3, &[[10, 0, 0, 255]; 8]); - let grown = make_scroll_capture_test_image(3, &[[20, 0, 0, 255]; 12]); - let mut scroll_session = ScrollSession::new(base, 320).expect("scroll session"); - let _ = scroll_session.observe_downward_sample(grown).expect("observe"); - let expected_export = scroll_session.export_image().clone(); - let monitor = test_monitor(); - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); - - session.state.frozen_capture_rect = Some(RectPoints::new(100, 120, 220, 180)); - session.frozen_capture_source = FrozenCaptureSource::DragRegion; - session.authoritative_frozen_capture_ready = true; - session.scroll_capture.active = true; - session.scroll_capture.session = Some(scroll_session); - session.scroll_capture.preview_display_image = - Some(RgbaImage::from_pixel(320, 64, Rgba([77, 0, 0, 255]))); - - session.begin_png_action(PngAction::Copy); - - assert_eq!(session.pending_png_action, Some(PngAction::Copy)); - assert_eq!(session.pending_encode_png.as_ref(), Some(&expected_export)); - assert_eq!(session.state.error_message.as_deref(), Some("Copying...")); - } - - #[cfg(target_os = "macos")] - #[test] - fn begin_ocr_action_clears_stale_png_output_intent() { - let monitor = test_monitor(); - let expected_export = test_frozen_image(); - let mut session = OverlaySession::new(); - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, expected_export.clone()); - - session.state.frozen_capture_rect = Some(RectPoints::new(100, 120, 220, 180)); - session.frozen_capture_source = FrozenCaptureSource::DragRegion; - session.authoritative_frozen_capture_ready = true; - - session.begin_png_action(PngAction::Copy); - - assert_eq!(session.pending_png_action, Some(PngAction::Copy)); - assert_eq!(session.pending_encode_png.as_ref(), Some(&expected_export)); - - session.begin_ocr_action(); - - assert_eq!(session.pending_png_action, None); - assert!(session.pending_encode_png.is_none()); - assert_eq!( - session.pending_recognize_text.as_ref().map(|request| &request.image), - Some(&expected_export) - ); - assert_eq!(session.active_ocr_request_id, Some(0)); - assert_eq!(session.state.error_message.as_deref(), Some("Recognizing text...")); - } - - #[cfg(target_os = "macos")] - #[test] - fn begin_ocr_action_ticks_active_scroll_capture_before_queueing_recognition() { - let monitor = test_monitor(); - let rect = RectPoints::new(100, 120, 512, 640); - let base = make_browser_like_worker_capture_window(512, 640, 0); - let mut session = OverlaySession::new(); - - session.worker = Some(OverlayWorker::new( - Box::new(SequenceScrollCaptureBackend::new([Some( - make_browser_like_worker_capture_window(512, 640, 84), - )])), - None, - )); - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); - - session.state.frozen_capture_rect = Some(rect); - session.frozen_capture_source = FrozenCaptureSource::DragRegion; - session.authoritative_frozen_capture_ready = true; - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(rect); - session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); - - enable_test_worker_scroll_capture_path(&mut session); - set_scroll_capture_input(&mut session, ScrollDirection::Down); - - session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); - - session.begin_ocr_action(); - - assert!( - session.scroll_capture.inflight_request_id.is_some(), - "OCR should flush active scroll capture by kicking the same worker sample path as PNG export" - ); - assert!(session.pending_recognize_text.is_some()); - assert_eq!(session.state.error_message.as_deref(), Some("Recognizing text...")); - } - - #[cfg(target_os = "macos")] - #[test] - fn stale_png_response_is_ignored_after_ocr_supersedes_export() { - let monitor = test_monitor(); - let mut session = OverlaySession::new(); - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); - - session.state.frozen_capture_rect = Some(RectPoints::new(100, 120, 220, 180)); - session.frozen_capture_source = FrozenCaptureSource::DragRegion; - session.authoritative_frozen_capture_ready = true; - - session.begin_png_action(PngAction::Copy); - session.begin_ocr_action(); - - let control = session.handle_encoded_png_response(Vec::new()); - - assert!(matches!(control, OverlayControl::Continue)); - assert_eq!(session.pending_png_action, None); - assert_eq!(session.state.error_message.as_deref(), Some("Recognizing text...")); - } - - #[cfg(target_os = "macos")] - #[test] - fn stale_ocr_response_is_ignored_after_copy_supersedes_ocr() { - let monitor = test_monitor(); - let mut session = OverlaySession::new(); - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); - - session.state.frozen_capture_rect = Some(RectPoints::new(100, 120, 220, 180)); - session.frozen_capture_source = FrozenCaptureSource::DragRegion; - session.authoritative_frozen_capture_ready = true; - - session.begin_ocr_action(); - - let request_id = session.active_ocr_request_id.expect("ocr request id"); - - session.pending_recognize_text = None; - session.ocr_inflight = true; - - session.begin_png_action(PngAction::Copy); - - let control = session.maybe_tick_worker_response_limiter(WorkerResponse::RecognizedText { - request_id, - text: String::from("stale text"), - }); - - assert!(matches!(control, OverlayControl::Continue)); - assert_eq!(session.active_ocr_request_id, None); - assert!(!session.ocr_inflight); - assert_eq!(session.pending_png_action, Some(PngAction::Copy)); - assert_eq!(session.state.error_message.as_deref(), Some("Copying...")); - } - - #[cfg(target_os = "macos")] - #[test] - fn stale_ocr_error_is_ignored_while_newer_ocr_request_is_pending() { - let monitor = test_monitor(); - let mut session = OverlaySession::new(); - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); - - session.state.frozen_capture_rect = Some(RectPoints::new(100, 120, 220, 180)); - session.frozen_capture_source = FrozenCaptureSource::DragRegion; - session.authoritative_frozen_capture_ready = true; - - session.begin_ocr_action(); - - let first_request_id = session.active_ocr_request_id.expect("first ocr request id"); - - session.pending_recognize_text = None; - session.ocr_inflight = true; - - session.begin_ocr_action(); - - let second_request_id = - session.pending_recognize_text.as_ref().expect("newer pending ocr request").request_id; - - assert_ne!(first_request_id, second_request_id); - - let control = session.maybe_tick_worker_response_limiter(WorkerResponse::Error { - source: WorkerErrorSource::RecognizeText, - message: String::from("stale OCR failure"), - }); - - assert!(matches!(control, OverlayControl::Continue)); - assert_eq!(session.active_ocr_request_id, Some(second_request_id)); - assert_eq!( - session.pending_recognize_text.as_ref().map(|request| request.request_id), - Some(second_request_id) - ); - assert!(!session.ocr_inflight); - assert_eq!(session.state.error_message.as_deref(), Some("Recognizing text...")); - } - - #[cfg(target_os = "macos")] - #[test] - fn duplicate_live_frames_schedule_forced_refresh_when_downward_backlog_is_fresh() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let capture_rect = RectPoints::new(100, 120, 200, 240); - let observed_at = Instant::now(); - let frame = ScrollCaptureLiveFrame { - frame_seq: 7, - captured_at: observed_at, - image: RgbaImage::from_pixel(16, 16, Rgba([7, 8, 9, 255])), - }; - let mut session = OverlaySession::new(); - - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.live_stream = Some(MacLiveFrameStream::new()); - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(observed_at); - session.scroll_capture.input_gesture_active = true; - session.scroll_capture.downward_motion_rows_pending = 512.0; - - assert!(session.note_scroll_capture_live_stream_frame_activity(&frame)); - assert!(!session.note_scroll_capture_live_stream_frame_activity(&frame)); - assert!(!session.note_scroll_capture_live_stream_frame_activity(&frame)); - assert!(!session.note_scroll_capture_live_stream_frame_activity(&frame)); - assert_eq!(session.scroll_capture.consecutive_identical_stream_frames, 3); - - session.maybe_schedule_duplicate_stream_refresh(frame.frame_seq, observed_at); - - assert_eq!( - session - .scroll_capture - .live_stream - .as_ref() - .and_then(MacLiveFrameStream::debug_last_request_kind), - Some("refresh_monitor_nonblocking_if_stale") - ); - assert_eq!( - session.scroll_capture.pending_post_stall_burst_after_seq, - Some(frame.frame_seq) - ); - assert_eq!(session.scroll_capture.last_duplicate_stream_refresh_at, Some(observed_at)); - } - - #[cfg(not(target_os = "macos"))] - #[test] - fn scroll_capture_is_unavailable_on_non_macos_even_with_drag_selection() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let mut session = OverlaySession::new(); - - session.state.mode = OverlayMode::Frozen; - session.state.monitor = Some(monitor); - session.state.frozen_capture_rect = Some(RectPoints::new(100, 120, 200, 240)); - session.frozen_capture_source = FrozenCaptureSource::DragRegion; - - assert!(!session.scroll_capture_is_available()); - } - - #[cfg(target_os = "macos")] - #[test] - fn scroll_capture_guard_error_keeps_frozen_capture_available() { - let mut session = OverlaySession::new(); - - seed_ready_scroll_capture_selection(&mut session); - - session.set_scroll_capture_start_guard(Arc::new(|| { - Err(color_eyre::eyre::eyre!("Open System Settings and retry.")) - })); - - let control = session.start_scroll_capture(); - - assert!(matches!(control, OverlayControl::Continue)); - assert!(session.state.frozen_image.is_some()); - assert!( - session - .state - .error_message - .as_deref() - .is_some_and(|message| message.contains("Open System Settings and retry.")) - ); - } - - #[cfg(target_os = "macos")] - #[test] - fn scroll_capture_guard_silent_reject_keeps_frozen_capture_available_without_error() { - let mut session = OverlaySession::new(); - - seed_ready_scroll_capture_selection(&mut session); - - session.set_scroll_capture_start_guard(Arc::new(|| Ok(false))); - - let control = session.start_scroll_capture(); - - assert!(matches!(control, OverlayControl::Continue)); - assert!(session.state.frozen_image.is_some()); - assert!(session.state.error_message.is_none()); - } - - #[cfg(target_os = "macos")] - #[test] - fn scroll_capture_starting_hook_error_keeps_frozen_capture_available() { - let mut session = OverlaySession::new(); - - seed_ready_scroll_capture_selection(&mut session); - - session.set_scroll_capture_start_guard(Arc::new(|| Ok(true))); - session.set_scroll_capture_starting_hook(Arc::new(|| { - Err(color_eyre::eyre::eyre!("Observer was not ready.")) - })); - - let control = session.start_scroll_capture(); - - assert!(matches!(control, OverlayControl::Continue)); - assert!(session.state.frozen_image.is_some()); - assert!( - session - .state - .error_message - .as_deref() - .is_some_and(|message| message.contains("Observer was not ready.")) - ); - } - - #[cfg(target_os = "macos")] - #[test] - fn scroll_capture_preflight_runs_before_permission_guard() { - let guard_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let mut session = OverlaySession::new(); - - session.set_scroll_capture_start_guard(Arc::new({ - let guard_calls = Arc::clone(&guard_calls); + let size = hud_window.window.inner_size(); + let window = Arc::clone(&hud_window.window); - move || { - guard_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + match hud_window.renderer.resize(size) { + Ok(()) => { + self.configure_hud_window_common(window.as_ref(), None); - Ok(true) + return OverlayControl::Continue; + }, + Err(err) => return self.exit(OverlayExit::Error(format!("{err:#}"))), } - })); - - let control = session.start_scroll_capture(); - - assert!(matches!(control, OverlayControl::Continue)); - assert_eq!(guard_calls.load(std::sync::atomic::Ordering::SeqCst), 0); - assert_eq!( - session.state.error_message.as_deref(), - Some("Scroll capture requires a dragged region selection.") - ); - } - - #[cfg(target_os = "macos")] - #[test] - fn scroll_capture_starting_hook_runs_before_started_hook() { - let hook_order = Arc::new(std::sync::Mutex::new(Vec::<&'static str>::new())); - let mut session = OverlaySession::new(); - - seed_ready_scroll_capture_selection(&mut session); - - session.set_scroll_capture_start_guard(Arc::new(|| Ok(true))); - - session.set_scroll_capture_starting_hook(Arc::new({ - let hook_order = Arc::clone(&hook_order); - - move || { - let mut hook_order = match hook_order.lock() { - Ok(hook_order) => hook_order, - Err(poisoned) => poisoned.into_inner(), - }; - - hook_order.push("starting"); + } + if let Some(loupe_window) = self.loupe_window.as_mut() + && loupe_window.window.id() == window_id + { + let size = loupe_window.window.inner_size(); + let window = Arc::clone(&loupe_window.window); - Ok(()) - } - })); - session.set_scroll_capture_started_hook(Arc::new({ - let hook_order = Arc::clone(&hook_order); - - move || { - let mut hook_order = match hook_order.lock() { - Ok(hook_order) => hook_order, - Err(poisoned) => poisoned.into_inner(), - }; + match loupe_window.renderer.resize(size) { + Ok(()) => { + self.configure_hud_window_common( + window.as_ref(), + Some(LOUPE_TILE_CORNER_RADIUS_POINTS), + ); - hook_order.push("started"); + return OverlayControl::Continue; + }, + Err(err) => return self.exit(OverlayExit::Error(format!("{err:#}"))), } - })); - - let control = session.start_scroll_capture(); - - assert!(matches!(control, OverlayControl::Continue)); - assert!(session.scroll_capture.active); - - let hook_order = match hook_order.lock() { - Ok(hook_order) => hook_order, - Err(poisoned) => poisoned.into_inner(), - }; - - assert_eq!(*hook_order, vec!["starting", "started"]); - } - - #[cfg(target_os = "macos")] - #[test] - fn scroll_capture_start_preserves_existing_live_sample_stream() { - let mut session = OverlaySession::new(); - - seed_ready_scroll_capture_selection(&mut session); - - session.live_sample_stream = Some(MacLiveFrameStream::new()); - - let control = session.start_scroll_capture(); - - assert!(matches!(control, OverlayControl::Continue)); - assert!(session.scroll_capture.active); - assert!(session.live_sample_stream.is_some()); - assert!(session.scroll_capture.live_stream.is_some()); - } - - #[cfg(target_os = "macos")] - #[test] - fn scroll_capture_start_skips_scroll_live_stream_when_worker_sampling_is_forced() { - let mut session = OverlaySession::new(); - - seed_ready_scroll_capture_selection(&mut session); - enable_test_worker_scroll_capture_path(&mut session); - - let control = session.start_scroll_capture(); - - assert!(matches!(control, OverlayControl::Continue)); - assert!(session.scroll_capture.active); - assert!(session.scroll_capture.live_stream.is_none()); - assert!(session.scroll_capture.live_stream_backlog.is_empty()); - } - - #[cfg(target_os = "macos")] - #[test] - fn reset_for_start_preserves_external_scroll_input_drain_reader() { - let mut session = OverlaySession::default(); - - session.set_external_scroll_input_drain_reader(Arc::new(|_, _| { - vec![(1, Instant::now(), 10.0, 20.0, 4.0, true, false)] - })); - session.reset_for_start(); - - assert!(session.scroll_capture.external_scroll_input_drain_reader.is_some()); - } + } - #[cfg(target_os = "macos")] - #[test] - fn reset_for_start_clears_reused_session_transient_flags() { - let mut session = OverlaySession { - window_list_refresh_inflight: true, - drop_next_window_list_refresh_snapshot: true, - ocr_inflight: true, - png_encode_inflight: true, - pending_self_capture_exception_window_ids_worker_refresh: true, - authoritative_frozen_capture_ready: true, - capture_windows_hidden: true, - loupe_activation_key_down: true, - keyboard_modifiers: ModifiersState::SHIFT, - left_mouse_button_down: true, - left_mouse_button_down_monitor: Some(test_monitor()), - left_mouse_button_down_global: Some(GlobalPoint::new(12, 34)), - hud_window_visible: true, - toolbar_window_visible: true, - toolbar_window_warmup_redraws_remaining: 3, - ..OverlaySession::default() + let Some(overlay_window) = self.windows.get_mut(&window_id) else { + return OverlayControl::Continue; }; + let size = overlay_window.window.inner_size(); - session.reset_for_start(); - - assert!(!session.window_list_refresh_inflight); - assert!(!session.drop_next_window_list_refresh_snapshot); - assert!(!session.ocr_inflight); - assert!(!session.png_encode_inflight); - assert!(!session.pending_self_capture_exception_window_ids_worker_refresh); - assert!(!session.authoritative_frozen_capture_ready); - assert!(!session.capture_windows_hidden); - assert!(!session.loupe_activation_key_down); - assert_eq!(session.keyboard_modifiers, ModifiersState::default()); - assert!(!session.left_mouse_button_down); - assert!(session.left_mouse_button_down_monitor.is_none()); - assert!(session.left_mouse_button_down_global.is_none()); - assert!(!session.hud_window_visible); - assert!(!session.toolbar_window_visible); - assert_eq!(session.toolbar_window_warmup_redraws_remaining, 0); + match overlay_window.renderer.resize(size) { + Ok(()) => OverlayControl::Continue, + Err(err) => self.exit(OverlayExit::Error(format!("{err:#}"))), + } } - #[cfg(target_os = "macos")] - #[test] - fn apply_live_cursor_sample_updates_rgb_and_loupe_state() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, + fn handle_cursor_moved( + &mut self, + window_id: WindowId, + position: PhysicalPosition, + ) -> OverlayControl { + let old_monitor = self.active_cursor_monitor(); + let now = Instant::now(); + let Some(overlay_window) = self.windows.get(&window_id) else { + return self.handle_cursor_moved_without_overlay_window(window_id, old_monitor); }; - let cursor = GlobalPoint::new(120, 180); - let patch = image::RgbaImage::from_pixel(3, 3, Rgba([10, 20, 30, 255])); - let mut session = OverlaySession::new(); - - session.cursor_monitor = Some(monitor); - session.state.cursor = Some(cursor); - session.state.alt_held = true; - - assert!( - session - .apply_live_cursor_sample_detail( - monitor, - cursor, - LiveCursorSample { - rgb: Some(Rgb::new(10, 20, 30)), - patch: Some(patch.clone()), - }, - ) - .any_changed() - ); - assert_eq!(session.state.rgb, Some(Rgb::new(10, 20, 30))); - assert_eq!(session.state.loupe.as_ref().map(|loupe| loupe.center), Some(cursor)); - assert_eq!( - session.state.loupe.as_ref().map(|loupe| loupe.patch.dimensions()), - Some(patch.dimensions()) + let window_monitor = overlay_window.monitor; + let scale_factor = overlay_window.window.scale_factor(); + let window_size = overlay_window.window.inner_size(); + // Clamp to overlay window bounds and map to monitor coordinates. + let max_local_x = ((window_size.width as f64) / scale_factor).max(1.0) as i32 - 1; + let max_local_y = ((window_size.height as f64) / scale_factor).max(1.0) as i32 - 1; + let local_x = (position.x / scale_factor).round() as i32; + let local_y = (position.y / scale_factor).round() as i32; + let event_global = GlobalPoint::new( + window_monitor.origin.x + local_x.clamp(0, max_local_x), + window_monitor.origin.y + local_y.clamp(0, max_local_y), ); - } - - #[cfg(target_os = "macos")] - #[test] - fn apply_live_cursor_sample_detail_keeps_overlay_redraw_narrow_for_rgb_and_loupe_updates() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let cursor = GlobalPoint::new(120, 180); - let patch = image::RgbaImage::from_pixel(3, 3, Rgba([10, 20, 30, 255])); - let mut session = OverlaySession::new(); + let monitor = window_monitor; + let global = event_global; + let source = DeviceCursorPointSource::EventRecentFallback; + let device_cursor = event_global; - session.cursor_monitor = Some(monitor); - session.state.cursor = Some(cursor); - session.state.alt_held = true; + self.last_event_cursor = Some((monitor, event_global)); + self.last_event_cursor_at = Some(now); - let apply = session.apply_live_cursor_sample_detail( + let old_cursor = self.state.cursor; + let trace = CursorMoveTrace { + window_id, + position, + old_cursor, + device_cursor, + event_global, monitor, - cursor, - LiveCursorSample { rgb: Some(Rgb::new(10, 20, 30)), patch: Some(patch) }, - ); - - assert_eq!( - apply, - LiveSampleApplyResult { - overlay_changed: false, - hud_changed: true, - loupe_changed: true, - } - ); - } - - #[cfg(target_os = "macos")] - #[test] - fn live_sample_request_redraw_intent_only_redraws_immediate_hover_changes() { - let session = OverlaySession::new(); - - assert_eq!( - session.live_sample_request_redraw_intent(false, true, true), - LiveSampleApplyResult::default() - ); - assert_eq!( - session.live_sample_request_redraw_intent(true, true, true), - LiveSampleApplyResult { - overlay_changed: true, - hud_changed: true, - loupe_changed: false, - } - ); - } - - #[cfg(target_os = "macos")] - #[test] - fn apply_loupe_activation_input_toggle_ignores_release_and_repeat() { - let mut session = OverlaySession::new(); - - session.config.alt_activation = AltActivationMode::Toggle; - - assert!(session.apply_loupe_activation_input(true, false)); - assert!(session.state.alt_held); - assert!(!session.apply_loupe_activation_input(true, true)); - assert!(session.state.alt_held); - assert!(!session.apply_loupe_activation_input(false, false)); - assert!(session.state.alt_held); - assert!(session.apply_loupe_activation_input(true, false)); - assert!(!session.state.alt_held); - } - - #[cfg(target_os = "macos")] - #[test] - fn apply_loupe_activation_input_hold_tracks_pressed_state() { - let mut session = OverlaySession::new(); - - session.config.alt_activation = AltActivationMode::Hold; - - assert!(session.apply_loupe_activation_input(true, false)); - assert!(session.state.alt_held); - assert!(!session.apply_loupe_activation_input(true, false)); - assert!(session.state.alt_held); - assert!(session.apply_loupe_activation_input(false, false)); - assert!(!session.state.alt_held); - } + global, + source, + }; - #[cfg(target_os = "macos")] - #[test] - fn plain_character_shortcut_available_blocks_loupe_activation_key_while_pressed() { - let mut session = OverlaySession::new(); - - session.config.alt_activation = AltActivationMode::Toggle; - - assert!(session.plain_character_shortcut_available()); - assert!(session.apply_loupe_activation_key_event(true, false)); - assert!(!session.plain_character_shortcut_available()); - assert!(!session.apply_loupe_activation_key_event(false, false)); - assert!(session.state.alt_held); - assert!(session.plain_character_shortcut_available()); - } + self.trace_cursor_moved_with_mapping(trace); + self.update_cursor_for_live_move(old_monitor, old_cursor, monitor, global); - #[cfg(target_os = "macos")] - #[test] - fn clear_loupe_activation_on_focus_loss_releases_hold_mode_state() { - let mut session = OverlaySession::new(); + let previous_drag_rect = self.state.drag_rect; - session.config.alt_activation = AltActivationMode::Hold; + self.update_live_drag_rect(monitor, global); + self.update_frozen_selection_drag_rect(global); + self.request_cursor_move_samples(monitor, global); - assert!(session.apply_loupe_activation_key_event(true, false)); - assert!(session.state.alt_held); - assert!(session.loupe_activation_key_down); + if let Some(old_monitor) = old_monitor + && old_monitor != monitor + { + self.request_redraw_for_monitor(old_monitor); + } - session.clear_loupe_activation_on_focus_loss(); + if Self::live_overlay_redraw_needed_for_cursor_update( + old_monitor, + monitor, + previous_drag_rect, + self.state.drag_rect, + ) { + self.request_redraw_for_monitor(monitor); + } - assert!(!session.state.alt_held); - assert!(!session.loupe_activation_key_down); - assert!(session.plain_character_shortcut_available()); + OverlayControl::Continue } - #[cfg(target_os = "macos")] - #[test] - fn clear_loupe_activation_on_focus_loss_releases_toggle_press_without_toggling_off() { - let mut session = OverlaySession::new(); - - session.config.alt_activation = AltActivationMode::Toggle; + fn handle_cursor_moved_without_overlay_window( + &mut self, + window_id: WindowId, + old_monitor: Option, + ) -> OverlayControl { + if self.should_ignore_live_auxiliary_cursor_event(window_id) { + return OverlayControl::Continue; + } - assert!(session.apply_loupe_activation_key_event(true, false)); - assert!(session.state.alt_held); - assert!(session.loupe_activation_key_down); + let now = Instant::now(); + let raw = self.sample_mouse_location(); + let Some((monitor, global, source)) = self.resolve_device_cursor_point(raw) else { + return OverlayControl::Continue; + }; + let old_cursor = self.state.cursor; - session.clear_loupe_activation_on_focus_loss(); + self.last_event_cursor = Some((monitor, global)); + self.last_event_cursor_at = Some(now); - assert!(session.state.alt_held); - assert!(!session.loupe_activation_key_down); - assert!(session.plain_character_shortcut_available()); - } + if tracing::enabled!(tracing::Level::TRACE) { + tracing::trace!( + window_id = ?window_id, + window_known = false, + old_cursor = ?old_cursor, + device_cursor = ?global, + event_cursor = ?global, + source = source.as_str(), + "CursorMoved (no overlay window mapping)." + ); + } - #[cfg(target_os = "macos")] - #[test] - fn loupe_activation_shortcut_available_requires_plain_tab() { - let mut session = OverlaySession::new(); + self.update_cursor_for_live_move(old_monitor, old_cursor, monitor, global); - assert!(session.loupe_activation_shortcut_available()); + let previous_drag_rect = self.state.drag_rect; - session.keyboard_modifiers = ModifiersState::SHIFT; + self.update_live_drag_rect(monitor, global); + self.update_frozen_selection_drag_rect(global); + self.request_cursor_move_samples(monitor, global); - assert!(!session.loupe_activation_shortcut_available()); + if let Some(old_monitor) = old_monitor + && old_monitor != monitor + { + self.request_redraw_for_monitor(old_monitor); + } - session.keyboard_modifiers = ModifiersState::ALT; + if Self::live_overlay_redraw_needed_for_cursor_update( + old_monitor, + monitor, + previous_drag_rect, + self.state.drag_rect, + ) { + self.request_redraw_for_monitor(monitor); + } - assert!(!session.loupe_activation_shortcut_available()); + OverlayControl::Continue + } - session.keyboard_modifiers = ModifiersState::CONTROL; + fn should_ignore_live_auxiliary_cursor_event(&self, window_id: WindowId) -> bool { + Self::should_ignore_live_auxiliary_cursor_event_for_role( + self.state.mode, + self.is_auxiliary_capture_window(window_id), + ) + } - assert!(!session.loupe_activation_shortcut_available()); + fn is_auxiliary_capture_window(&self, window_id: WindowId) -> bool { + self.hud_window.as_ref().is_some_and(|window| window.window.id() == window_id) + || self.loupe_window.as_ref().is_some_and(|window| window.window.id() == window_id) + || self.toolbar_window.as_ref().is_some_and(|window| window.window.id() == window_id) + || self + .scroll_preview_window + .as_ref() + .is_some_and(|window| window.window.id() == window_id) + } - session.keyboard_modifiers = ModifiersState::SUPER; + fn should_ignore_live_auxiliary_cursor_event_for_role( + mode: OverlayMode, + is_auxiliary_window: bool, + ) -> bool { + matches!(mode, OverlayMode::Live) && is_auxiliary_window + } - assert!(!session.loupe_activation_shortcut_available()); + fn current_device_cursor(&mut self) -> GlobalPoint { + self.sample_mouse_location() } - #[cfg(target_os = "macos")] - #[test] - fn apply_loupe_activation_key_event_tracks_modified_tab_without_activating_loupe() { - let mut session = OverlaySession::new(); + fn trace_cursor_moved_with_mapping(&self, trace: CursorMoveTrace) { + if !tracing::enabled!(tracing::Level::TRACE) { + return; + } + + let delta_x = + trace.global.x.abs_diff(trace.old_cursor.map_or(trace.global.x, |point| point.x)); + let delta_y = + trace.global.y.abs_diff(trace.old_cursor.map_or(trace.global.y, |point| point.y)); - session.keyboard_modifiers = ModifiersState::SHIFT; + tracing::trace!( + window_id = ?trace.window_id, + window_known = true, + window_position = ?trace.position, + old_cursor = ?trace.old_cursor, + device_cursor = ?trace.device_cursor, + event_cursor = ?trace.event_global, + source = trace.source.as_str(), + monitor_id = trace.monitor.id, + cursor_delta_x = delta_x, + cursor_delta_y = delta_y, + "CursorMoved coordinate source: {}.", + trace.source.as_str() + ); + } - assert!(!session.apply_loupe_activation_key_event(true, false)); - assert!(session.loupe_activation_key_down); - assert!(!session.state.alt_held); - assert!(!session.plain_character_shortcut_available()); + fn update_cursor_for_live_move( + &mut self, + old_monitor: Option, + old_cursor: Option, + monitor: MonitorRect, + global: GlobalPoint, + ) { + self.update_cursor_state(monitor, global); + self.update_hud_window_position(monitor, global); - session.keyboard_modifiers = ModifiersState::default(); + if Self::live_hud_redraw_needed_for_cursor_update(old_cursor, global, old_monitor, monitor) + { + self.request_redraw_hud_window(); + } + if self.should_try_pending_follow_window_move_on_live_cursor_update() { + self.maybe_apply_pending_hud_and_loupe_moves(); + } + if matches!(self.state.mode, OverlayMode::Live) && self.use_fake_hud_blur() { + if self.state.live_bg_monitor != Some(monitor) { + self.state.live_bg_monitor = None; + self.state.live_bg_image = None; + } - assert!(!session.plain_character_shortcut_available()); - assert!(!session.apply_loupe_activation_key_event(false, false)); - assert!(!session.loupe_activation_key_down); - assert!(session.plain_character_shortcut_available()); + self.maybe_request_live_bg(monitor); + } } - #[cfg(target_os = "macos")] - #[test] - fn pending_focus_loss_cleanup_does_not_clear_loupe_during_internal_focus_transfer() { - let overlay_window_id = WindowId::from(1); - let toolbar_window_id = WindowId::from(2); - let mut session = OverlaySession::new(); - - session.config.alt_activation = AltActivationMode::Hold; + fn request_cursor_move_samples(&mut self, monitor: MonitorRect, global: GlobalPoint) { + if !matches!(self.state.mode, OverlayMode::Live) { + return; + } + if self.pending_click_hit_test_request_id.is_some() { + return; + } - session.note_window_focus_change(overlay_window_id, true); + let is_dragging_window = matches!(self.state.mode, OverlayMode::Live) + && self.left_mouse_button_down + && self.left_mouse_button_down_monitor == Some(monitor); + let had_snapshot_update = if is_dragging_window || self.state.alt_held { + false + } else { + self.apply_live_hover_cache_state(monitor, global) + }; + let sample_requested = + self.request_live_cursor_sample(monitor, global, self.state.alt_held); - assert!(session.apply_loupe_activation_key_event(true, false)); - assert!(session.state.alt_held); + if !is_dragging_window && !self.state.alt_held { + let _ = self.request_live_window_list_refresh_if_needed(); + } - session.note_window_focus_change(overlay_window_id, false); - session.note_window_focus_change(toolbar_window_id, true); - session.maybe_clear_loupe_activation_after_focus_loss(); + let apply = self.live_sample_request_redraw_intent( + had_snapshot_update, + sample_requested, + self.state.alt_held || self.loupe_window_visible, + ); - assert!(session.state.alt_held); - assert!(session.loupe_activation_key_down); + if apply.any_changed() { + self.request_redraw_live_sample_targets(monitor, apply); + } } - #[cfg(target_os = "macos")] - #[test] - fn pending_focus_loss_cleanup_clears_loupe_after_all_overlay_windows_blur() { - let overlay_window_id = WindowId::from(1); - let mut session = OverlaySession::new(); - - session.config.alt_activation = AltActivationMode::Hold; + fn handle_left_mouse_input( + &mut self, + window_id: WindowId, + state: ElementState, + ) -> OverlayControl { + let monitor = self + .windows + .get(&window_id) + .map(|w| w.monitor) + .or_else(|| self.active_cursor_monitor()) + .or(self.state.monitor); + let Some(monitor) = monitor else { + return OverlayControl::Continue; + }; - session.note_window_focus_change(overlay_window_id, true); + if matches!(self.state.mode, OverlayMode::Frozen) { + self.reset_toolbar_pointer_state(); - assert!(session.apply_loupe_activation_key_event(true, false)); - assert!(session.state.alt_held); + match state { + ElementState::Pressed => { + let cursor = self.current_device_cursor(); + let _ = self.begin_frozen_selection_drag(cursor); + }, + ElementState::Released => self.stop_frozen_selection_drag(), + } - session.note_window_focus_change(overlay_window_id, false); - session.maybe_clear_loupe_activation_after_focus_loss(); + self.request_redraw_for_monitor(monitor); - assert!(!session.state.alt_held); - assert!(!session.loupe_activation_key_down); - assert!(session.plain_character_shortcut_available()); - } + return OverlayControl::Continue; + } + if !matches!(self.state.mode, OverlayMode::Live) { + return OverlayControl::Continue; + } - #[cfg(target_os = "macos")] - #[test] - fn live_loupe_keeps_a_dedicated_window_during_live_alt() { - let mut session = OverlaySession::new(); + match state { + ElementState::Pressed => { + if self.left_mouse_button_down { + return OverlayControl::Continue; + } - session.state.mode = OverlayMode::Frozen; + let raw_cursor = self.current_device_cursor(); + let Some((press_monitor, press_global, _)) = + self.resolve_live_cursor_point(raw_cursor) + else { + self.left_mouse_button_down = true; + self.left_mouse_button_down_monitor = Some(monitor); + self.left_mouse_button_down_global = Some(raw_cursor); + self.state.drag_rect = None; + self.state.hovered_window_rect = None; - assert!(!session.live_loupe_uses_hud_window()); - assert!(!session.live_loupe_renders_in_hud_window()); + self.reset_toolbar_pointer_state(); + self.request_redraw_for_monitor(monitor); - session.state.mode = OverlayMode::Live; + return OverlayControl::Continue; + }; - assert!(!session.live_loupe_uses_hud_window()); - assert!(!session.live_loupe_renders_in_hud_window()); + self.left_mouse_button_down = true; + self.left_mouse_button_down_monitor = Some(press_monitor); + self.left_mouse_button_down_global = Some(press_global); + self.state.drag_rect = None; + self.state.hovered_window_rect = None; - session.state.alt_held = true; + self.reset_toolbar_pointer_state(); + self.update_cursor_state(press_monitor, press_global); + self.update_hud_window_position(press_monitor, press_global); + self.request_redraw_for_monitor(press_monitor); - assert!(!session.live_loupe_renders_in_hud_window()); + OverlayControl::Continue + }, + ElementState::Released => { + let Some(start_monitor) = self.left_mouse_button_down_monitor else { + return OverlayControl::Continue; + }; + let Some(start_global) = self.left_mouse_button_down_global else { + self.left_mouse_button_down = false; + self.left_mouse_button_down_monitor = None; - session.state.mode = OverlayMode::Frozen; + return OverlayControl::Continue; + }; + let raw_cursor = self.current_device_cursor(); + let (release_monitor, release_global) = + if let Some((release_monitor, release_global, _)) = + self.resolve_live_cursor_point(raw_cursor) + { + (release_monitor, release_global) + } else { + (start_monitor, start_global) + }; - assert!(!session.live_loupe_uses_hud_window()); - assert!(!session.live_loupe_renders_in_hud_window()); - } + self.left_mouse_button_down = false; + self.left_mouse_button_down_monitor = None; + self.left_mouse_button_down_global = None; - #[cfg(target_os = "macos")] - #[test] - fn hud_window_content_rect_stays_compact_for_live_alt() { - let hud_pill = HudPillGeometry { - rect: Rect::from_min_max(Pos2::new(14.0, 14.0), Pos2::new(200.0, 58.0)), - radius_points: f32::from(HUD_PILL_CORNER_RADIUS_POINTS), - }; - let loupe_tile = Rect::from_min_max(Pos2::new(14.0, 68.0), Pos2::new(246.0, 300.0)); - let live_rect = OverlaySession::hud_window_content_rect( - OverlayMode::Live, - true, - hud_pill, - Some(loupe_tile), - ); + let drag_rect = if start_monitor == release_monitor { + self.state.drag_rect.take() + } else { + None + }; - assert_eq!(live_rect, hud_pill.rect); + if let Some(rect) = drag_rect + && start_monitor == release_monitor + && rect.monitor_id == release_monitor.id + && rect.rect.width as f32 >= LIVE_DRAG_START_THRESHOLD_PX + && rect.rect.height as f32 >= LIVE_DRAG_START_THRESHOLD_PX + { + self.begin_frozen_capture_with_rect( + release_monitor, + Some(rect.rect), + None, + Some(release_global), + ); - let live_rect_without_hud_loupe = OverlaySession::hud_window_content_rect( - OverlayMode::Live, - false, - hud_pill, - Some(loupe_tile), - ); + return OverlayControl::Continue; + } - assert_eq!(live_rect_without_hud_loupe, hud_pill.rect); + self.state.drag_rect = None; - let frozen_rect = OverlaySession::hud_window_content_rect( - OverlayMode::Frozen, - true, - hud_pill, - Some(loupe_tile), - ); + self.request_click_capture_hit_test(release_monitor, release_global); - assert_eq!(frozen_rect, hud_pill.rect); + OverlayControl::Continue + }, + } } - #[cfg(target_os = "macos")] - #[test] - fn live_alt_loupe_window_redraw_is_not_skipped() { - let mut session = OverlaySession::new(); - - session.state.mode = OverlayMode::Live; - session.state.alt_held = true; - - assert!(!session.should_skip_loupe_redraw()); - - session.state.alt_held = false; - - assert!(session.should_skip_loupe_redraw()); - } + fn handle_scroll_mouse_wheel( + &mut self, + window_id: WindowId, + delta: &MouseScrollDelta, + ) -> OverlayControl { + if !self.scroll_capture.active || self.scroll_capture.paused { + return OverlayControl::Continue; + } - #[test] - fn live_overlay_selection_flow_repaint_active_only_for_hovered_window() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, + let Some(overlay_monitor) = self.windows.get(&window_id).map(|window| window.monitor) + else { + return OverlayControl::Continue; + }; + let Some(scroll_monitor) = self.scroll_capture.monitor else { + return OverlayControl::Continue; + }; + let Some(capture_rect) = self.scroll_capture.capture_rect_pixels else { + return OverlayControl::Continue; }; - let mut session = OverlaySession::new(); - - session.state.mode = OverlayMode::Live; - session.cursor_monitor = Some(monitor); - session.state.cursor = Some(GlobalPoint::new(120, 180)); - - assert!(!session.live_overlay_selection_flow_repaint_active()); - - session.state.hovered_window_rect = Some(MonitorRectPoints { - monitor_id: monitor.id, - rect: RectPoints::new(100, 120, 240, 320), - }); - - assert!(session.live_overlay_selection_flow_repaint_active()); - - session.config.selection_flow_enabled = false; - - assert!(!session.live_overlay_selection_flow_repaint_active()); - session.config.selection_flow_enabled = true; - session.state.hovered_window_rect = Some(MonitorRectPoints { - monitor_id: monitor.id + 1, - rect: RectPoints::new(100, 120, 240, 320), - }); + if overlay_monitor != scroll_monitor { + return OverlayControl::Continue; + } - assert!(!session.live_overlay_selection_flow_repaint_active()); + let cursor = self.current_device_cursor(); + let cursor_pixels = scroll_monitor.local_u32_pixels(cursor); + let Some(cursor_pixels) = cursor_pixels else { + return OverlayControl::Continue; + }; - session.state.drag_rect = Some(MonitorRectPoints { - monitor_id: monitor.id, - rect: RectPoints::new(100, 120, 240, 320), - }); + if !capture_rect.contains(cursor_pixels) { + return OverlayControl::Continue; + } - assert!(!session.live_overlay_selection_flow_repaint_active()); - } + self.record_scroll_capture_input_direction_from_overlay_wheel_at(delta, Instant::now()); - #[test] - fn live_drag_focus_rect_uses_large_drag_on_active_monitor() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(1_000.0, 800.0)); - let mut state = crate::state::OverlayState::new(); + #[cfg(target_os = "macos")] + { + let target_point = cursor; + let now = Instant::now(); - state.drag_rect = Some(MonitorRectPoints { - monitor_id: monitor.id, - rect: RectPoints::new(100, 120, 240, 320), - }); + self.arm_scroll_overlay_mouse_passthrough_window(now, "overlay_mouse_wheel"); - assert_eq!( - WindowRenderer::live_drag_focus_rect(&state, monitor, screen_rect), - Some(Rect::from_min_size(Pos2::new(100.0, 120.0), Vec2::new(240.0, 320.0))) - ); + let forwarded = self.forward_macos_scroll_wheel_event( + scroll_monitor, + cursor, + Some(cursor_pixels), + capture_rect, + target_point, + delta, + ); - state.drag_rect = Some(MonitorRectPoints { - monitor_id: monitor.id + 1, - rect: RectPoints::new(100, 120, 240, 320), - }); + if !forwarded { + self.disarm_scroll_overlay_mouse_passthrough(now, "wheel_forward_failed"); + } + } - assert_eq!(WindowRenderer::live_drag_focus_rect(&state, monitor, screen_rect), None); + OverlayControl::Continue } #[cfg(target_os = "macos")] - #[test] - fn sync_live_sample_attempt_does_not_leave_pending_request() { - let mut session = OverlaySession::new(); - - session.note_live_cursor_sample_request_started(7); + fn forward_macos_scroll_wheel_event( + &mut self, + scroll_monitor: MonitorRect, + cursor: GlobalPoint, + cursor_pixels: Option<(u32, u32)>, + capture_rect: RectPoints, + target_point: GlobalPoint, + delta: &MouseScrollDelta, + ) -> bool { + let normalized = Self::normalize_macos_scroll_wheel_delta( + delta, + &mut self.scroll_capture.pixel_delta_residual, + ); - assert!(session.live_sample_request_pending()); + if normalized.posted_x == 0 && normalized.posted_y == 0 { + return false; + } - session.finish_sync_live_cursor_sample_attempt(7); + if let Err(err) = macos_post_scroll_wheel_event(normalized, target_point) { + tracing::warn!( + op = "scroll_capture.wheel_forward_failed", + monitor_id = scroll_monitor.id, + cursor = ?cursor, + cursor_pixels = ?cursor_pixels, + capture_rect = ?capture_rect, + target_point = ?target_point, + raw_delta = ?delta, + normalized_delta_x = normalized.normalized_x, + normalized_delta_y = normalized.normalized_y, + posted_delta_x = normalized.posted_x, + posted_delta_y = normalized.posted_y, + pixel_residual_x = normalized.residual.x, + pixel_residual_y = normalized.residual.y, + error = %format!("{err:#}"), + "Failed to forward scroll wheel event." + ); - assert!(!session.live_sample_request_pending()); - assert_eq!(session.latest_live_cursor_sample_request_id, Some(7)); - assert_eq!(session.applied_live_cursor_sample_request_id, Some(7)); - } + self.state.set_error(format!("{err:#}")); + self.request_redraw_all(); - #[test] - fn monitor_for_cursor_in_rects_finds_matching_monitor_without_windows() { - let monitor_a = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let monitor_b = MonitorRect { - id: 2, - origin: GlobalPoint::new(1_000, 0), - width: 1_200, - height: 900, - scale_factor_x1000: 2_000, - }; + return false; + } - assert_eq!( - OverlaySession::monitor_for_cursor_in_rects( - &[monitor_a, monitor_b], - GlobalPoint::new(42, 88) - ), - Some(monitor_a) - ); - assert_eq!( - OverlaySession::monitor_for_cursor_in_rects( - &[monitor_a, monitor_b], - GlobalPoint::new(1_240, 120) - ), - Some(monitor_b) - ); - assert_eq!( - OverlaySession::monitor_for_cursor_in_rects( - &[monitor_a, monitor_b], - GlobalPoint::new(2_400, 1_200) - ), - None + tracing::info!( + op = "scroll_capture.wheel_forwarded", + monitor_id = scroll_monitor.id, + cursor = ?cursor, + cursor_pixels = ?cursor_pixels, + capture_rect = ?capture_rect, + target_point = ?target_point, + raw_delta = ?delta, + normalized_delta_x = normalized.normalized_x, + normalized_delta_y = normalized.normalized_y, + posted_delta_x = normalized.posted_x, + posted_delta_y = normalized.posted_y, + pixel_residual_x = normalized.residual.x, + pixel_residual_y = normalized.residual.y, + source_state_id = macos_hid_event_source_state_id(), + "Forwarded scroll wheel event." ); - } - - #[cfg(target_os = "macos")] - #[test] - fn startup_live_rgb_plan_keeps_focus_independent_from_seed_monitor() { - let monitor = MonitorRect { - id: 2, - origin: GlobalPoint::new(1_000, 0), - width: 1_200, - height: 900, - scale_factor_x1000: 2_000, - }; - assert_eq!( - OverlaySession::startup_live_rgb_plan(None), - StartupLiveRgbPlan { focus_window: true, seed_monitor: None } - ); - assert_eq!( - OverlaySession::startup_live_rgb_plan(Some(monitor)), - StartupLiveRgbPlan { focus_window: true, seed_monitor: Some(monitor) } - ); + true } - #[test] - fn initialize_cursor_state_for_cursor_preserves_preseeded_live_rgb() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let cursor = GlobalPoint::new(120, 180); - let mut session = OverlaySession::new(); - - session.state.mode = OverlayMode::Live; - session.state.rgb = Some(Rgb::new(10, 20, 30)); + #[cfg(target_os = "macos")] + fn normalize_macos_scroll_wheel_delta( + delta: &MouseScrollDelta, + residual: &mut MacOSScrollPixelResidual, + ) -> MacOSScrollWheelEvent { + match delta { + MouseScrollDelta::LineDelta(x, y) => MacOSScrollWheelEvent { + units: KCG_SCROLL_EVENT_UNIT_LINE, + normalized_x: f64::from(*x), + normalized_y: f64::from(*y), + posted_x: x.round() as i32, + posted_y: y.round() as i32, + residual: *residual, + }, + MouseScrollDelta::PixelDelta(delta) => { + let normalized_x = Self::normalize_macos_scroll_pixel_component(delta.x); + let normalized_y = Self::normalize_macos_scroll_pixel_component(delta.y); + let accumulated_x = residual.x + normalized_x; + let accumulated_y = residual.y + normalized_y; + let posted_x = accumulated_x.trunc() as i32; + let posted_y = accumulated_y.trunc() as i32; - session.initialize_cursor_state_for_cursor(cursor, Some(monitor)); + *residual = MacOSScrollPixelResidual { + x: accumulated_x - f64::from(posted_x), + y: accumulated_y - f64::from(posted_y), + }; - assert_eq!(session.state.cursor, Some(cursor)); - assert_eq!(session.cursor_monitor, Some(monitor)); - assert_eq!(session.state.rgb, Some(Rgb::new(10, 20, 30))); + MacOSScrollWheelEvent { + units: KCG_SCROLL_EVENT_UNIT_PIXEL, + normalized_x, + normalized_y, + posted_x, + posted_y, + residual: *residual, + } + }, + } } - #[test] - fn initialize_cursor_state_for_cursor_clears_rgb_when_no_monitor_matches() { - let cursor = GlobalPoint::new(2_400, 1_200); - let mut session = OverlaySession::new(); - - session.state.mode = OverlayMode::Live; - session.state.rgb = Some(Rgb::new(10, 20, 30)); - - session.initialize_cursor_state_for_cursor(cursor, None); - - assert_eq!(session.state.cursor, Some(cursor)); - assert_eq!(session.cursor_monitor, None); - assert_eq!(session.state.rgb, None); - } + #[cfg(target_os = "macos")] + fn normalize_macos_scroll_pixel_component(value: f64) -> f64 { + if !value.is_finite() { + return 0.0; + } - #[test] - fn live_overlay_redraw_needed_for_cursor_update_only_for_monitor_or_drag_changes() { - let monitor_a = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let monitor_b = MonitorRect { - id: 2, - origin: GlobalPoint::new(1_000, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, + let normalized = if value.abs() > MACOS_SCROLL_PIXEL_WRAP_THRESHOLD { + if value.is_sign_positive() { + value - MACOS_SCROLL_PIXEL_WRAP_MODULUS + } else { + value + MACOS_SCROLL_PIXEL_WRAP_MODULUS + } + } else { + value }; - let drag = Some(MonitorRectPoints { - monitor_id: monitor_a.id, - rect: RectPoints::new(100, 120, 240, 320), - }); - assert!(!OverlaySession::live_overlay_redraw_needed_for_cursor_update( - Some(monitor_a), - monitor_a, - None, - None, - )); - assert!(OverlaySession::live_overlay_redraw_needed_for_cursor_update( - Some(monitor_a), - monitor_a, - None, - drag, - )); - assert!(OverlaySession::live_overlay_redraw_needed_for_cursor_update( - Some(monitor_a), - monitor_b, - None, - None, - )); + normalized.clamp(-MACOS_SCROLL_PIXEL_DELTA_CLAMP, MACOS_SCROLL_PIXEL_DELTA_CLAMP) } - #[test] - fn live_hud_redraw_needed_for_cursor_update_tracks_cursor_or_monitor_changes() { - let monitor_a = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let monitor_b = MonitorRect { - id: 2, - origin: GlobalPoint::new(1_000, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, + fn scroll_capture_direction_from_wheel_delta( + delta: &MouseScrollDelta, + ) -> Option { + let vertical_delta = match delta { + MouseScrollDelta::LineDelta(_, y) => f64::from(*y), + MouseScrollDelta::PixelDelta(delta) => { + #[cfg(target_os = "macos")] + { + Self::normalize_macos_scroll_pixel_component(delta.y) + } + #[cfg(not(target_os = "macos"))] + { + delta.y + } + }, }; - let cursor_a = GlobalPoint::new(120, 180); - let cursor_b = GlobalPoint::new(140, 200); - - assert!(!OverlaySession::live_hud_redraw_needed_for_cursor_update( - Some(cursor_a), - cursor_a, - Some(monitor_a), - monitor_a, - )); - assert!(OverlaySession::live_hud_redraw_needed_for_cursor_update( - Some(cursor_a), - cursor_b, - Some(monitor_a), - monitor_a, - )); - assert!(OverlaySession::live_hud_redraw_needed_for_cursor_update( - Some(cursor_a), - cursor_a, - Some(monitor_a), - monitor_b, - )); - assert!(OverlaySession::live_hud_redraw_needed_for_cursor_update( - None, cursor_a, None, monitor_a, - )); - } - - #[test] - fn live_hud_redraw_consumes_pending_move_without_size_change() { - let mut session = OverlaySession::new(); - - session.state.mode = OverlayMode::Live; - session.pending_hud_outer_pos = Some(GlobalPoint::new(120, 180)); - assert!(session.should_try_pending_hud_window_move_on_redraw(&HudRedrawSummary::default())); + Self::scroll_capture_direction_from_delta_y(vertical_delta) } - #[test] - fn frozen_hud_redraw_does_not_consume_pending_move_without_size_change() { - let mut session = OverlaySession::new(); - - session.state.mode = OverlayMode::Frozen; - session.pending_hud_outer_pos = Some(GlobalPoint::new(120, 180)); - - assert!( - !session.should_try_pending_hud_window_move_on_redraw(&HudRedrawSummary::default()) - ); - assert!(session.should_try_pending_hud_window_move_on_redraw(&HudRedrawSummary { - position_update_elapsed: Some(Duration::from_micros(1)), - ..HudRedrawSummary::default() - })); + fn scroll_capture_direction_from_delta_y(vertical_delta: f64) -> Option { + if vertical_delta < 0.0 { + Some(ScrollDirection::Down) + } else if vertical_delta > 0.0 { + Some(ScrollDirection::Up) + } else { + None + } } - #[test] - fn live_cursor_update_tries_pending_follow_window_moves() { - let mut session = OverlaySession::new(); - - session.state.mode = OverlayMode::Live; - - assert!(!session.should_try_pending_follow_window_move_on_live_cursor_update()); - - session.pending_hud_outer_pos = Some(GlobalPoint::new(120, 180)); - - assert!(session.should_try_pending_follow_window_move_on_live_cursor_update()); - - session.pending_hud_outer_pos = None; - session.pending_loupe_outer_pos = Some(GlobalPoint::new(140, 220)); + fn scroll_capture_direction_from_external_input_delta_y( + delta_y: f64, + ) -> Option { + if delta_y == 0.0 { + return None; + } - assert!(session.should_try_pending_follow_window_move_on_live_cursor_update()); + Self::scroll_capture_direction_from_delta_y(delta_y) } - #[test] - fn frozen_cursor_update_does_not_try_pending_follow_window_moves() { - let mut session = OverlaySession::new(); + fn scroll_capture_motion_rows_from_wheel_delta(delta: &MouseScrollDelta) -> f64 { + match delta { + MouseScrollDelta::LineDelta(_, y) => f64::from(*y).abs(), + MouseScrollDelta::PixelDelta(delta) => { + #[cfg(target_os = "macos")] + { + Self::normalize_macos_scroll_pixel_component(delta.y).abs() + } + #[cfg(not(target_os = "macos"))] + { + delta.y.abs() + } + }, + } + } - session.state.mode = OverlayMode::Frozen; - session.pending_hud_outer_pos = Some(GlobalPoint::new(120, 180)); - session.pending_loupe_outer_pos = Some(GlobalPoint::new(140, 220)); + fn accumulate_scroll_capture_downward_motion_rows(&mut self, motion_rows: f64) { + if !motion_rows.is_finite() || motion_rows <= 0.0 { + return; + } - assert!(!session.should_try_pending_follow_window_move_on_live_cursor_update()); + self.scroll_capture.downward_motion_rows_pending = + (self.scroll_capture.downward_motion_rows_pending + motion_rows.abs()) + .clamp(0.0, SCROLL_CAPTURE_INPUT_MOTION_PRIOR_ROWS_MAX); } - #[cfg(target_os = "macos")] - #[test] - fn apply_live_cursor_sample_clears_existing_loupe_when_alt_is_released() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let cursor = GlobalPoint::new(120, 180); - let mut session = OverlaySession::new(); - - session.cursor_monitor = Some(monitor); - session.state.cursor = Some(cursor); - session.state.alt_held = true; + fn clear_scroll_capture_downward_motion_rows(&mut self) { + self.scroll_capture.downward_motion_rows_pending = 0.0; + } - let _ = session.apply_live_cursor_sample_detail( - monitor, - cursor, - LiveCursorSample { - rgb: Some(Rgb::new(10, 20, 30)), - patch: Some(image::RgbaImage::from_pixel(3, 3, Rgba([10, 20, 30, 255]))), - }, - ); + fn consume_scroll_capture_downward_motion_rows(&mut self, consumed_rows: u32) { + if consumed_rows == 0 { + return; + } - session.state.alt_held = false; + let remaining = self.scroll_capture.downward_motion_rows_pending - f64::from(consumed_rows); - assert!( - session - .apply_live_cursor_sample_detail( - monitor, - cursor, - LiveCursorSample { rgb: None, patch: None }, - ) - .any_changed() - ); - assert!(session.state.loupe.is_none()); + self.scroll_capture.downward_motion_rows_pending = remaining.max(0.0); } - #[cfg(target_os = "macos")] - #[test] - fn stabilized_live_hud_inner_size_keeps_live_width_from_shrinking() { - let mut session = OverlaySession::new(); - - session.state.mode = OverlayMode::Live; - session.hud_inner_size_points = Some((826, 44)); - - assert_eq!( - OverlaySession::stabilized_live_hud_inner_size( - OverlayMode::Live, - session.hud_inner_size_points, - (810, 44), - ), - (826, 44) - ); - assert_eq!( - OverlaySession::stabilized_live_hud_inner_size( - OverlayMode::Live, - session.hud_inner_size_points, - (780, 44), - ), - (826, 44) - ); - - session.state.mode = OverlayMode::Frozen; - - assert_eq!( - OverlaySession::stabilized_live_hud_inner_size( - OverlayMode::Frozen, - session.hud_inner_size_points, - (810, 44), - ), - (810, 44) - ); + fn consume_scroll_capture_downward_motion_rows_for_outcome( + &mut self, + outcome: &ScrollObserveOutcome, + ) { + if let ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows } = + outcome + { + self.consume_scroll_capture_downward_motion_rows(*growth_rows); + } } - #[test] - fn live_hud_position_text_uses_stable_monitor_width() { - let monitor = MonitorRect { - id: 5, - origin: GlobalPoint::new(0, 0), - width: 3_008, - height: 1_692, - scale_factor_x1000: 2_000, - }; - let short = hud_helpers::format_live_hud_position_text(monitor, GlobalPoint::new(842, 846)); - let long = - hud_helpers::format_live_hud_position_text(monitor, GlobalPoint::new(1_504, 1_320)); + fn record_scroll_capture_input_direction_at( + &mut self, + direction: ScrollDirection, + gesture_active: bool, + at: Instant, + ) { + self.scroll_capture.input_direction = Some(direction); + self.scroll_capture.input_direction_at = Some(at); + self.scroll_capture.input_gesture_active = gesture_active; - assert_eq!(short.len(), long.len()); - assert_eq!(short, "x= 842, y= 846"); - assert_eq!(long, "x=1504, y=1320"); + #[cfg(target_os = "macos")] + self.clear_incompatible_live_stream_stale_grace(); } - #[test] - fn live_hud_rgb_text_uses_fixed_width_placeholders() { - let (missing_hex, missing_rgb) = hud_helpers::format_live_hud_rgb_text(None); - let (hex, rgb) = hud_helpers::format_live_hud_rgb_text(Some(Rgb::new(7, 128, 255))); + fn record_scroll_capture_input_direction_from_overlay_wheel_at( + &mut self, + delta: &MouseScrollDelta, + at: Instant, + ) { + if let Some(direction) = Self::scroll_capture_direction_from_wheel_delta(delta) { + self.record_scroll_capture_input_direction_at(direction, false, at); - assert_eq!(missing_hex.len(), hex.len()); - assert_eq!(missing_rgb.len(), rgb.len()); - assert_eq!(missing_hex, "#??????"); - assert_eq!(missing_rgb, "RGB(???, ???, ???)"); - assert_eq!(rgb, "RGB( 7, 128, 255)"); + if matches!(direction, ScrollDirection::Down) { + self.accumulate_scroll_capture_downward_motion_rows( + Self::scroll_capture_motion_rows_from_wheel_delta(delta), + ); + } else { + self.clear_scroll_capture_downward_motion_rows(); + } + } } - #[test] - fn stable_live_loupe_side_prefers_configured_patch_side() { - let mut state = crate::state::OverlayState::new(); + fn finish_scroll_capture_input_direction_at(&mut self, at: Instant) { + if self.scroll_capture.input_direction.is_some() { + self.scroll_capture.input_direction_at = Some(at); + } else { + self.scroll_capture.input_direction_at = None; + } - state.loupe_patch_side_px = 21; - state.loupe = Some(LoupeSample { - center: GlobalPoint::new(100, 120), - patch: RgbaImage::from_pixel(17, 19, image::Rgba([0, 0, 0, 255])), - }); + self.scroll_capture.input_gesture_active = false; - assert_eq!(hud_helpers::stable_live_loupe_side_px(&state), 21); + #[cfg(target_os = "macos")] + self.clear_incompatible_live_stream_stale_grace(); } - #[test] - fn stable_live_loupe_side_ignores_larger_runtime_patch() { - let mut state = crate::state::OverlayState::new(); + fn apply_scroll_capture_input_delta_y( + &mut self, + delta_y: f64, + gesture_active: bool, + gesture_ended: bool, + at: Instant, + ) { + if let Some(direction) = Self::scroll_capture_direction_from_external_input_delta_y(delta_y) + { + if self.should_absorb_upward_external_input_into_active_downward_gesture( + direction, + gesture_active, + ) { + self.record_scroll_capture_input_direction_at( + ScrollDirection::Down, + gesture_active, + at, + ); + self.accumulate_scroll_capture_downward_motion_rows(delta_y.abs()); + } else { + self.record_scroll_capture_input_direction_at(direction, gesture_active, at); - state.loupe_patch_side_px = 21; - state.loupe = Some(LoupeSample { - center: GlobalPoint::new(100, 120), - patch: RgbaImage::from_pixel(25, 25, image::Rgba([0, 0, 0, 255])), - }); + if matches!(direction, ScrollDirection::Down) { + self.accumulate_scroll_capture_downward_motion_rows(delta_y.abs()); + } else { + self.clear_scroll_capture_downward_motion_rows(); + } + } + } - assert_eq!(hud_helpers::stable_live_loupe_side_px(&state), 21); + if gesture_ended { + self.finish_scroll_capture_input_direction_at(at); + } } - #[test] - fn stable_live_loupe_window_inner_size_matches_runtime_target() { - assert_eq!(hud_helpers::stable_live_loupe_window_inner_size_points(21), (232, 232)); - assert_eq!(hud_helpers::stable_live_loupe_window_inner_size_points(1), (32, 32)); + fn should_absorb_upward_external_input_into_active_downward_gesture( + &self, + direction: ScrollDirection, + gesture_active: bool, + ) -> bool { + gesture_active + && matches!(direction, ScrollDirection::Up) + && self.scroll_capture.input_direction == Some(ScrollDirection::Down) + && self.scroll_capture.downward_motion_rows_pending > 0.0 } - #[cfg(target_os = "macos")] - #[test] - fn drain_external_scroll_input_events_through_advances_last_seen_seq() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let start = Instant::now(); - let events = Arc::new([ - (1, start, 150.0, 160.0, -4.0, true, false), - (2, start + Duration::from_millis(2), 150.0, 160.0, -4.0, false, true), - ]); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); - session.set_external_scroll_input_drain_reader(Arc::new({ - let events = Arc::clone(&events); - - move |after_seq, through| { - events - .iter() - .copied() - .filter(|event| event.0 > after_seq && event.1 <= through) - .collect() - } - })); - - session.drain_external_scroll_input_events_through(start); + fn apply_external_scroll_input_delta_y( + &mut self, + global_x: f64, + global_y: f64, + delta_y: f64, + gesture_active: bool, + gesture_ended: bool, + at: Instant, + ) { + if !self.scroll_capture.active || self.scroll_capture.paused { + return; + } - assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); - assert!(session.scroll_capture.input_gesture_active); - assert_eq!(session.scroll_capture.last_external_scroll_input_seq, 1); + let Some(scroll_monitor) = self.scroll_capture.monitor else { + return; + }; + let Some(capture_rect) = self.scroll_capture.capture_rect_pixels else { + return; + }; + let cursor = GlobalPoint::new(global_x.round() as i32, global_y.round() as i32); + let Some(cursor_pixels) = scroll_monitor.local_u32_pixels(cursor) else { + return; + }; - session.drain_external_scroll_input_events_through(start); + #[cfg(not(target_os = "macos"))] + if !capture_rect.contains(cursor_pixels) { + return; + } - assert_eq!(session.scroll_capture.last_external_scroll_input_seq, 1); + #[cfg(target_os = "macos")] + let _cursor_inside_capture_rect = capture_rect.contains(cursor_pixels); - session.drain_external_scroll_input_events_through(start + Duration::from_millis(2)); + #[cfg(target_os = "macos")] + if delta_y != 0.0 + && !gesture_ended + && !self.scroll_capture.overlay_mouse_passthrough_persistent + { + self.arm_scroll_overlay_mouse_passthrough_window( + Instant::now(), + "external_scroll_input", + ); + } - assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); - assert!(!session.scroll_capture.input_gesture_active); - assert_eq!(session.scroll_capture.last_external_scroll_input_seq, 2); + self.apply_scroll_capture_input_delta_y(delta_y, gesture_active, gesture_ended, at); } - #[cfg(target_os = "macos")] - #[test] - fn drain_external_scroll_input_events_through_uses_pairing_time_for_freshness() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let through = Instant::now(); - let recorded_at = through - SCROLL_CAPTURE_INPUT_FRESHNESS - Duration::from_millis(50); - let events = Arc::new([(1, recorded_at, 150.0, 160.0, -4.0, false, false)]); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); - session.set_external_scroll_input_drain_reader(Arc::new({ - let events = Arc::clone(&events); - - move |after_seq, paired_through| { - events - .iter() - .copied() - .filter(|event| event.0 > after_seq && event.1 <= paired_through) - .collect() - } - })); - - session.drain_external_scroll_input_events_through(through); - - assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); - assert_eq!(session.scroll_capture.input_direction_at, Some(through)); - assert_eq!(session.scroll_capture_observation_block_reason(), None); + fn scroll_capture_trace_snapshot_at( + &self, + observation_at: Instant, + ) -> ScrollCaptureTraceSessionSnapshot { + ScrollCaptureTraceSessionSnapshot::capture( + self.scroll_capture.session.as_ref(), + self.scroll_capture_preview_dimensions(), + self.scroll_capture.input_direction, + self.scroll_capture.input_gesture_active, + self.scroll_capture.downward_motion_rows_pending, + self.scroll_capture_input_age_ms_at(observation_at), + ) } - #[cfg(target_os = "macos")] - #[test] - fn replayed_stream_input_uses_frame_time_for_stale_gate_without_global_relaxation() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - [80, 0, 0, 255], - ]; - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let capture_rect = RectPoints::new(100, 120, 200, 240); - let through = Instant::now() - SCROLL_CAPTURE_INPUT_FRESHNESS - Duration::from_millis(50); - let recorded_at = through - Duration::from_millis(12); - let events = Arc::new([(1, recorded_at, 150.0, 160.0, -4.0, false, false)]); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.session = - Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); - session.set_external_scroll_input_drain_reader(Arc::new({ - let events = Arc::clone(&events); - - move |after_seq, paired_through| { - events - .iter() - .copied() - .filter(|event| event.0 > after_seq && event.1 <= paired_through) - .collect() - } - })); - - session.drain_external_scroll_input_events_through(through); - - assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); - assert_eq!(session.scroll_capture.input_direction_at, Some(through)); - assert_eq!(session.scroll_capture_observation_block_reason(), Some("stale_input")); - assert_eq!(session.scroll_capture_observation_block_reason_at(through), None); - assert_eq!( - session - .observe_scroll_capture_frame_at( - make_scroll_capture_window(&document, 3, 1, 5), - through, - ) - .transpose() - .unwrap(), - Some(ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: 1, - }) - ); + #[cfg(test)] + fn scroll_capture_input_allows_observation(&self) -> bool { + self.scroll_capture_observation_block_reason().is_none() } - #[cfg(target_os = "macos")] - #[test] - fn replayed_downward_input_allows_bounded_stale_live_stream_frame() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - [80, 0, 0, 255], - ]; - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let capture_rect = RectPoints::new(100, 120, 200, 240); - let through = Instant::now(); - let events = - Arc::new([(7, through - Duration::from_millis(10), 150.0, 160.0, 4.0, false, false)]); - let stale_at = through + SCROLL_CAPTURE_INPUT_FRESHNESS + Duration::from_millis(1); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.session = - Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); - session.set_external_scroll_input_drain_reader(Arc::new({ - let events = Arc::clone(&events); - - move |after_seq, paired_through| { - events - .iter() - .copied() - .filter(|event| event.0 > after_seq && event.1 <= paired_through) - .collect() - } - })); - - session.drain_external_scroll_input_events_through(through); - - assert_eq!( - session.scroll_capture.live_stream_stale_grace, - Some(LiveStreamStaleGrace { - external_input_seq: 7, - remaining_stale_frames: SCROLL_CAPTURE_LIVE_STREAM_STALE_GRACE_FRAMES, - }) - ); - assert_eq!( - session - .observe_scroll_capture_frame_at( - make_scroll_capture_window(&document, 3, 1, 5), - stale_at, - ) - .transpose() - .unwrap(), - Some(ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: 1, - }) - ); - assert_eq!(scroll_capture_export_height(&session), 6); + #[cfg(test)] + fn scroll_capture_input_allows_growth(&self) -> bool { + self.scroll_capture_input_allows_observation() } - #[cfg(target_os = "macos")] - #[test] - fn stale_live_stream_frame_is_observed_even_without_direction_freshness() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - [80, 0, 0, 255], - ]; - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let capture_rect = RectPoints::new(100, 120, 200, 240); - let through = Instant::now(); - let wheel_at = through + Duration::from_millis(10); - let events = - Arc::new([(7, through - Duration::from_millis(10), 150.0, 160.0, 4.0, false, false)]); - let stale_at = wheel_at + SCROLL_CAPTURE_INPUT_FRESHNESS + Duration::from_millis(1); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.session = - Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); - session.set_external_scroll_input_drain_reader(Arc::new({ - let events = Arc::clone(&events); - - move |after_seq, paired_through| { - events - .iter() - .copied() - .filter(|event| event.0 > after_seq && event.1 <= paired_through) - .collect() - } - })); - - session.drain_external_scroll_input_events_through(through); - session.record_scroll_capture_input_direction_from_overlay_wheel_at( - &MouseScrollDelta::LineDelta(0.0, -1.0), - wheel_at, - ); - - assert_eq!(session.scroll_capture.input_direction_at, Some(wheel_at)); - assert_eq!( - session.scroll_capture.live_stream_stale_grace, - Some(LiveStreamStaleGrace { - external_input_seq: 7, - remaining_stale_frames: SCROLL_CAPTURE_LIVE_STREAM_STALE_GRACE_FRAMES, - }) - ); - assert_eq!( - session - .observe_scroll_capture_frame_at( - make_scroll_capture_window(&document, 3, 1, 5), - stale_at, - ) - .transpose() - .unwrap(), - Some(ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: 1, - }) - ); - assert_eq!(scroll_capture_export_height(&session), 6); + #[cfg(test)] + fn scroll_capture_observation_block_reason(&self) -> Option<&'static str> { + self.scroll_capture_observation_block_reason_at(Instant::now()) } - #[cfg(target_os = "macos")] - #[test] - fn handle_scroll_capture_frame_passes_allow_stale_input_into_live_stream_gate() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - [80, 0, 0, 255], - ]; - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let capture_rect = RectPoints::new(100, 120, 200, 240); - let observed_at = Instant::now(); - let input_at = observed_at - SCROLL_CAPTURE_INPUT_FRESHNESS - Duration::from_millis(1); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(input_at); - session.scroll_capture.session = - Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); - - assert_eq!( - session - .handle_scroll_capture_frame( - make_scroll_capture_window(&document, 3, 1, 5), - ScrollCaptureFrameSource::LiveStream { frame_seq: 143 }, - true, - observed_at, - ) - .transpose() - .unwrap(), - Some(ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: 1, - }) - ); - assert_eq!(scroll_capture_export_height(&session), 6); - } + fn scroll_capture_observation_block_reason_at( + &self, + observation_at: Instant, + ) -> Option<&'static str> { + if self.scroll_capture.input_direction.is_none() { + return Some("missing_direction"); + } + if self.scroll_capture.input_gesture_active { + return None; + } - #[cfg(target_os = "macos")] - #[test] - fn fresh_live_stream_frame_without_direction_metadata_fails_closed_as_no_change() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - ]; - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, + let Some(input_direction_at) = self.scroll_capture.input_direction_at else { + return Some("missing_input_timestamp"); }; - let capture_rect = RectPoints::new(100, 120, 200, 240); - let observed_at = Instant::now(); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.session = - Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); - - session.handle_scroll_capture_frame( - make_scroll_capture_window(&document, 3, 1, 5), - ScrollCaptureFrameSource::LiveStream { frame_seq: 143 }, - false, - observed_at, - ); - assert_eq!(scroll_capture_export_height(&session), 5); + if observation_at.saturating_duration_since(input_direction_at) + > SCROLL_CAPTURE_INPUT_FRESHNESS + { + return Some("stale_input"); + } + + None } #[cfg(target_os = "macos")] - #[test] - fn wrapped_pixel_delta_normalizes_back_to_signed_values() { - assert_eq!(OverlaySession::normalize_macos_scroll_pixel_component(4_294_967_294.0), -2.0); - assert_eq!(OverlaySession::normalize_macos_scroll_pixel_component(4_294_967_290.0), -6.0); + fn scroll_capture_input_age_ms(&self) -> Option { + self.scroll_capture_input_age_ms_at(Instant::now()) } - #[test] - fn positive_vertical_wheel_delta_maps_to_upward_scroll_capture() { - assert_eq!( - OverlaySession::scroll_capture_direction_from_wheel_delta( - &MouseScrollDelta::LineDelta(0.0, 1.0) - ), - Some(ScrollDirection::Up) - ); + fn scroll_capture_input_age_ms_at(&self, observation_at: Instant) -> Option { + self.scroll_capture.input_direction_at.map(|input_direction_at| { + u64::try_from(observation_at.saturating_duration_since(input_direction_at).as_millis()) + .unwrap_or(u64::MAX) + }) } - #[test] - fn negative_vertical_wheel_delta_maps_to_downward_scroll_capture() { - assert_eq!( - OverlaySession::scroll_capture_direction_from_wheel_delta( - &MouseScrollDelta::LineDelta(0.0, -1.0) - ), - Some(ScrollDirection::Down) - ); - } + #[cfg(target_os = "macos")] + fn scroll_capture_should_force_stream_refresh_at(&self, now: Instant) -> bool { + if !self.scroll_capture_has_fresh_downward_backlog_at(now) { + return false; + } + if self.scroll_capture.input_gesture_active { + return false; + } - #[test] - fn external_scroll_input_inside_capture_rect_uses_upward_observation_for_positive_delta() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, + let Some(input_direction_at) = self.scroll_capture.input_direction_at else { + return false; }; - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); - - session.handle_external_scroll_input_delta_y(150.0, 160.0, 4.0, true, false); - assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Up)); - assert!(session.scroll_capture.input_direction_at.is_some()); - assert!(session.scroll_capture.input_gesture_active); - assert_eq!(session.scroll_capture.downward_motion_rows_pending, 0.0); + now.saturating_duration_since(input_direction_at) <= SCROLL_CAPTURE_INPUT_FRESHNESS } - #[test] - fn external_scroll_input_inside_capture_rect_uses_downward_observation_for_negative_delta() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); - - session.handle_external_scroll_input_delta_y(150.0, 160.0, -4.0, true, false); - - assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); - assert!(session.scroll_capture.input_direction_at.is_some()); - assert!(session.scroll_capture.input_gesture_active); - } + fn scroll_capture_has_fresh_downward_backlog_at(&self, now: Instant) -> bool { + if self.scroll_capture.input_direction != Some(ScrollDirection::Down) + || self.scroll_capture.downward_motion_rows_pending <= 0.0 + { + return false; + } - #[test] - fn upward_external_scroll_input_clears_existing_downward_motion_backlog() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, + let Some(input_direction_at) = self.scroll_capture.input_direction_at else { + return false; }; - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); - session.scroll_capture.downward_motion_rows_pending = 128.0; - - session.handle_external_scroll_input_delta_y(150.0, 160.0, 12.0, true, false); - assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Up)); - assert_eq!(session.scroll_capture.downward_motion_rows_pending, 0.0); + now.saturating_duration_since(input_direction_at) <= SCROLL_CAPTURE_INPUT_FRESHNESS } - #[test] #[cfg(target_os = "macos")] - fn external_scroll_input_outside_capture_rect_on_same_monitor_is_still_consumed() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); - - session.handle_external_scroll_input_delta_y(50.0, 50.0, -4.0, true, false); + fn scroll_capture_should_schedule_stale_stream_refresh_at(&self, now: Instant) -> bool { + if !self.scroll_capture.input_gesture_active { + return true; + } - assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); - assert!(session.scroll_capture.input_direction_at.is_some()); - assert!(session.scroll_capture.input_gesture_active); - assert_eq!(session.scroll_capture.downward_motion_rows_pending, 4.0); + self.scroll_capture.last_stream_event_at.is_none_or(|last_stream_event_at| { + now.saturating_duration_since(last_stream_event_at) + >= SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW + }) } - #[test] - #[cfg(not(target_os = "macos"))] - fn external_scroll_input_outside_capture_rect_is_ignored() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); - - session.handle_external_scroll_input_delta_y(50.0, 50.0, 4.0, true, false); - - assert_eq!(session.scroll_capture.input_direction, None); - assert!(session.scroll_capture.input_direction_at.is_none()); + fn scroll_capture_should_allow_post_stall_burst_search_at( + &self, + frame_seq: u64, + now: Instant, + ) -> bool { + self.scroll_capture.pending_post_stall_burst_after_seq.is_some_and(|after_seq| { + frame_seq > after_seq && self.scroll_capture_has_fresh_downward_backlog_at(now) + }) } - #[test] - fn external_scroll_input_outside_scroll_monitor_is_ignored() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(1_000, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, + #[cfg(target_os = "macos")] + fn scroll_capture_should_arm_post_stall_burst_for_time_gap_at( + &self, + frame_captured_at: Instant, + ) -> bool { + let Some(previous_captured_at) = self.scroll_capture.last_consumed_stream_frame_captured_at + else { + return false; }; - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); - session.handle_external_scroll_input_delta_y(50.0, 50.0, 4.0, true, false); - - assert_eq!(session.scroll_capture.input_direction, None); - assert!(session.scroll_capture.input_direction_at.is_none()); - assert_eq!(session.scroll_capture.downward_motion_rows_pending, 0.0); + self.scroll_capture_has_fresh_downward_backlog_at(frame_captured_at) + && frame_captured_at.saturating_duration_since(previous_captured_at) + >= SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW } - #[test] - fn external_scroll_input_terminal_event_preserves_last_direction_for_freshness() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let mut session = OverlaySession::new(); + fn toolbar_pointer_state( + &mut self, + monitor: MonitorRect, + toolbar_cursor_local_override: Option, + ) -> Option { + if !matches!(self.state.mode, OverlayMode::Frozen) { + return None; + } + if !self.toolbar_state.visible { + return None; + } + if self.state.monitor != Some(monitor) { + return None; + } + if toolbar_cursor_local_override.is_none() && self.active_cursor_monitor() != Some(monitor) + { + return None; + } + + let left_button_went_down = self.toolbar_left_button_went_down; + let left_button_went_up = self.toolbar_left_button_went_up; - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; + self.toolbar_left_button_went_down = false; + self.toolbar_left_button_went_up = false; - session.handle_external_scroll_input_delta_y(150.0, 160.0, 0.0, false, true); + let cursor_local = toolbar_cursor_local_override + .or_else(|| self.state.cursor.and_then(|cursor| global_to_local(cursor, monitor)))?; + let left_button_down = self.toolbar_left_button_down; - assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); - assert!(session.scroll_capture.input_direction_at.is_some()); - assert!(!session.scroll_capture.input_gesture_active); - assert!(session.scroll_capture_input_allows_growth()); + Some(FrozenToolbarPointerState { + cursor_local, + left_button_down, + left_button_went_down, + left_button_went_up, + }) } - #[cfg(target_os = "macos")] - #[test] - fn scroll_overlay_mouse_passthrough_window_arms_and_expires() { - let now = Instant::now(); - let mut session = OverlaySession::new(); + fn handle_key_event(&mut self, event: &KeyEvent) -> OverlayControl { + if matches!(event.logical_key, Key::Named(NamedKey::Tab)) { + let pressed = event.state == ElementState::Pressed; - session.scroll_capture.active = true; + if self.apply_loupe_activation_key_event(pressed, event.repeat) { + return self.request_redraw_for_alt_state_change(); + } - session.arm_scroll_overlay_mouse_passthrough_window(now, "test"); + return OverlayControl::Continue; + } + if event.state != ElementState::Pressed { + return OverlayControl::Continue; + } + if event.repeat { + return OverlayControl::Continue; + } + if self.scroll_capture.active { + return self.handle_scroll_capture_key_event(event); + } - assert!(session.scroll_capture.overlay_mouse_passthrough_active); - assert_eq!( - session.scroll_capture.overlay_mouse_passthrough_until, - Some(now + SCROLL_CAPTURE_MOUSE_PASSTHROUGH_IDLE_GRACE) - ); + match &event.logical_key { + Key::Named(NamedKey::Escape) => self.cancel_overlay("escape_key"), + Key::Character(key_text) + if (key_text == "h" || key_text == "H") + && self.plain_character_shortcut_available() => + { + self.toolbar_state.visible = !self.toolbar_state.visible; - session.sync_scroll_overlay_mouse_passthrough_window( - now + SCROLL_CAPTURE_MOUSE_PASSTHROUGH_IDLE_GRACE / 2, - ); + self.request_redraw_all(); - assert!(session.scroll_capture.overlay_mouse_passthrough_active); + OverlayControl::Continue + }, + Key::Character(key_text) + if key_text.as_str().eq_ignore_ascii_case("c") + && self.plain_character_shortcut_available() => + { + self.auto_center_frozen_capture_rect(); - session.sync_scroll_overlay_mouse_passthrough_window( - now + SCROLL_CAPTURE_MOUSE_PASSTHROUGH_IDLE_GRACE + Duration::from_millis(1), - ); + OverlayControl::Continue + }, + Key::Character(key_text) + if key_text.as_str().eq_ignore_ascii_case("s") + && self.is_save_shortcut_pressed() => + { + self.begin_png_action(PngAction::Save); - assert!(!session.scroll_capture.overlay_mouse_passthrough_active); - assert!(session.scroll_capture.overlay_mouse_passthrough_until.is_none()); - } + OverlayControl::Continue + }, + Key::Character(key_text) + if key_text.as_str().eq_ignore_ascii_case("s") + && self.plain_character_shortcut_available() => + { + let available = self.scroll_capture_is_available(); + let selection_ready = self.scroll_capture_selection_is_ready(); - #[cfg(target_os = "macos")] - #[test] - fn scroll_capture_start_enables_persistent_passthrough() { - let mut session = OverlaySession::new(); + tracing::info!( + op = "scroll_capture.frozen_s_pressed", + available, + scroll_capture_active = self.scroll_capture.active, + selection_ready, + frozen_capture_source = ?self.frozen_capture_source, + state_mode = ?self.state.mode, + "Received `s` while frozen." + ); - seed_ready_scroll_capture_selection(&mut session); + if selection_ready { + return self.start_scroll_capture(); + } - let control = session.start_scroll_capture(); + OverlayControl::Continue + }, + Key::Named(NamedKey::Space) => { + self.begin_png_action(PngAction::Copy); - assert!(matches!(control, OverlayControl::Continue)); - assert!(session.scroll_capture.active); - assert!(session.scroll_capture.overlay_mouse_passthrough_active); - assert!(session.scroll_capture.overlay_mouse_passthrough_persistent); - assert!(session.scroll_capture.overlay_mouse_passthrough_until.is_none()); + OverlayControl::Continue + }, + _ => OverlayControl::Continue, + } } - #[cfg(target_os = "macos")] - #[test] - fn scroll_capture_pause_and_resume_toggle_persistent_passthrough() { - let mut session = OverlaySession::new(); - - seed_ready_scroll_capture_selection(&mut session); - - let _ = session.start_scroll_capture(); - - session.toggle_scroll_capture_paused(); - - assert!(session.scroll_capture.paused); - assert!(!session.scroll_capture.overlay_mouse_passthrough_active); - assert!(!session.scroll_capture.overlay_mouse_passthrough_persistent); - - session.toggle_scroll_capture_paused(); - - assert!(!session.scroll_capture.paused); - assert!(session.scroll_capture.overlay_mouse_passthrough_active); - assert!(session.scroll_capture.overlay_mouse_passthrough_persistent); - assert!(session.scroll_capture.overlay_mouse_passthrough_until.is_none()); + fn is_save_shortcut_pressed(&self) -> bool { + #[cfg(target_os = "macos")] + { + self.keyboard_modifiers.super_key() + } + #[cfg(not(target_os = "macos"))] + { + self.keyboard_modifiers.control_key() + } } - #[cfg(target_os = "macos")] - #[test] - fn external_scroll_input_extends_passthrough_window_inside_capture_rect() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let earlier = Instant::now() - Duration::from_millis(20); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); - - session.arm_scroll_overlay_mouse_passthrough_window(earlier, "test"); - - let first_deadline = session.scroll_capture.overlay_mouse_passthrough_until; - - session.handle_external_scroll_input_delta_y(150.0, 160.0, 4.0, true, false); - - assert!(session.scroll_capture.overlay_mouse_passthrough_active); - assert!(session.scroll_capture.overlay_mouse_passthrough_until > first_deadline); + fn loupe_activation_shortcut_available(&self) -> bool { + !self.keyboard_modifiers.shift_key() + && !self.keyboard_modifiers.alt_key() + && !self.keyboard_modifiers.control_key() + && !self.keyboard_modifiers.super_key() } - #[test] - fn terminal_positive_scroll_event_sets_upward_observation_before_finishing() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); - - session.handle_external_scroll_input_delta_y(150.0, 160.0, 4.0, false, true); - - assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Up)); - assert!(session.scroll_capture.input_direction_at.is_some()); - assert!(!session.scroll_capture.input_gesture_active); - assert!(session.scroll_capture_input_allows_growth()); + fn plain_character_shortcut_available(&self) -> bool { + !self.loupe_activation_key_down + && !self.keyboard_modifiers.alt_key() + && !self.keyboard_modifiers.control_key() + && !self.keyboard_modifiers.super_key() } - #[test] - fn terminal_negative_scroll_event_still_allows_downward_growth() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); - - session.handle_external_scroll_input_delta_y(150.0, 160.0, -4.0, false, true); - - assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); - assert!(session.scroll_capture.input_direction_at.is_some()); - assert!(!session.scroll_capture.input_gesture_active); - assert!(session.scroll_capture_input_allows_growth()); - } + fn handle_scroll_capture_key_event(&mut self, event: &KeyEvent) -> OverlayControl { + match &event.logical_key { + Key::Named(NamedKey::Escape) => self.cancel_overlay("scroll_capture_escape_key"), + Key::Named(NamedKey::Space) => { + self.begin_png_action(PngAction::Copy); - #[cfg(target_os = "macos")] - #[test] - fn overlay_wheel_fallback_records_direction_with_drain_reader_present() { - let observed_at = Instant::now(); - let mut session = OverlaySession::new(); + OverlayControl::Continue + }, + Key::Character(key_text) + if key_text.as_str().eq_ignore_ascii_case("s") + && self.is_save_shortcut_pressed() => + { + self.begin_png_action(PngAction::Save); - session.scroll_capture.active = true; + OverlayControl::Continue + }, + Key::Character(key_text) if key_text.as_str().eq_ignore_ascii_case("u") => { + self.undo_scroll_capture_append(); - session.set_external_scroll_input_drain_reader(Arc::new(|_, _| Vec::new())); - session.record_scroll_capture_input_direction_from_overlay_wheel_at( - &MouseScrollDelta::LineDelta(0.0, 1.0), - observed_at, - ); + OverlayControl::Continue + }, + Key::Character(key_text) if key_text.as_str().eq_ignore_ascii_case("p") => { + self.toggle_scroll_capture_paused(); - assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Up)); - assert_eq!(session.scroll_capture.input_direction_at, Some(observed_at)); - assert!(!session.scroll_capture.input_gesture_active); + OverlayControl::Continue + }, + _ => OverlayControl::Continue, + } } - #[test] - fn missing_scroll_direction_does_not_allow_growth() { - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; + fn current_export_image(&self) -> Option { + if self.scroll_capture.active { + return self + .scroll_capture + .session + .as_ref() + .map(|session| session.export_image().clone()); + } - assert!(!session.scroll_capture_input_allows_growth()); + self.cropped_frozen_capture_image().or_else(|| self.state.frozen_image.clone()) } - #[test] - fn fresh_upward_direction_still_allows_observation() { - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.input_direction = Some(ScrollDirection::Up); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; - - assert!(session.scroll_capture_input_allows_observation()); - assert!(session.scroll_capture_input_allows_growth()); + fn scroll_capture_selection_is_ready(&self) -> bool { + matches!(self.state.mode, OverlayMode::Frozen) + && self.state.monitor.is_some() + && self.state.frozen_capture_rect.is_some() + && self.frozen_capture_source == FrozenCaptureSource::DragRegion + && self.frozen_final_capture_ready() } - #[test] - fn fresh_downward_direction_allows_growth_without_active_gesture() { - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = false; + fn scroll_capture_is_available(&mut self) -> bool { + if !self.scroll_capture_selection_is_ready() { + return false; + } - assert!(session.scroll_capture_input_allows_growth()); + #[cfg(target_os = "macos")] + { + true + } + #[cfg(not(target_os = "macos"))] + { + false + } } - #[test] - fn upward_direction_still_allows_growth_gate() { - let mut session = OverlaySession::new(); + fn toolbar_scroll_capture_slot_available(&self) -> bool { + if self.scroll_capture.active { + return true; + } - session.scroll_capture.active = true; - session.scroll_capture.input_direction = Some(ScrollDirection::Up); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; + #[cfg(target_os = "macos")] + { + matches!(self.state.mode, OverlayMode::Frozen) + && self.state.monitor.is_some() + && self.state.frozen_capture_rect.is_some() + && self.frozen_capture_source == FrozenCaptureSource::DragRegion + } - assert!(session.scroll_capture_input_allows_growth()); + #[cfg(not(target_os = "macos"))] + { + false + } } - #[test] - fn upward_input_does_not_dirty_later_downward_growth() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - [80, 0, 0, 255], - ]; - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.session = - Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); - - set_scroll_capture_input(&mut session, ScrollDirection::Down); - - assert_eq!( - observe_scroll_capture_frame( - &mut session, - make_scroll_capture_window(&document, 3, 1, 5), - ), - Some(ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: 1, - }) - ); - assert_eq!( - observe_scroll_capture_frame( - &mut session, - make_scroll_capture_window(&document, 3, 2, 5), - ), - Some(ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: 1, - }) - ); - - let height_after_second_append = scroll_capture_export_height(&session); - - set_scroll_capture_input(&mut session, ScrollDirection::Up); - - assert!(matches!( - observe_scroll_capture_frame( - &mut session, - make_scroll_capture_window(&document, 3, 0, 5), - ), - Some( - ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } - | ScrollObserveOutcome::PreviewUpdated - ) - )); - assert_eq!(scroll_capture_export_height(&session), height_after_second_append); - - set_scroll_capture_input(&mut session, ScrollDirection::Down); + #[cfg(target_os = "macos")] + fn try_prepare_scroll_capture_start( + &mut self, + ) -> Option<(MonitorRect, RectPoints, RectPoints, RgbaImage)> { + if !self.scroll_capture_selection_is_ready() { + tracing::info!( + op = "scroll_capture.start_rejected", + reason = "selection_not_ready", + frozen_capture_source = ?self.frozen_capture_source, + state_mode = ?self.state.mode, + "Skipped starting scroll capture because the current frozen selection was not eligible." + ); - assert_eq!( - observe_scroll_capture_frame( - &mut session, - make_scroll_capture_window(&document, 3, 2, 5), - ), - Some(ScrollObserveOutcome::NoChange) - ); - assert_eq!(scroll_capture_export_height(&session), height_after_second_append); - - set_scroll_capture_input(&mut session, ScrollDirection::Up); - - assert!(matches!( - observe_scroll_capture_frame( - &mut session, - make_scroll_capture_window(&document, 3, 1, 5), - ), - Some( - ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } - | ScrollObserveOutcome::PreviewUpdated - | ScrollObserveOutcome::NoChange - ) - )); - assert_eq!(scroll_capture_export_height(&session), height_after_second_append); + self.state + .set_error(String::from("Scroll capture requires a dragged region selection.")); + self.request_redraw_all(); - set_scroll_capture_input(&mut session, ScrollDirection::Down); + return None; + } - assert_eq!( - observe_scroll_capture_frame( - &mut session, - make_scroll_capture_window(&document, 3, 2, 5), - ), - Some(ScrollObserveOutcome::NoChange) - ); - assert_eq!(scroll_capture_export_height(&session), height_after_second_append); - assert_eq!( - observe_scroll_capture_frame( - &mut session, - make_scroll_capture_window(&document, 3, 3, 5), - ), - Some(ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: 1, - }) - ); - } + let Some(monitor) = self.state.monitor else { + tracing::info!( + op = "scroll_capture.start_rejected", + reason = "missing_monitor", + "Skipped starting scroll capture because the frozen monitor was unavailable." + ); - #[cfg(target_os = "macos")] - #[test] - fn stale_latched_worker_input_fails_closed_without_appending_growth() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - [80, 0, 0, 255], - ]; - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, + return None; }; - let capture_rect = RectPoints::new(100, 120, 200, 240); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.session = - Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; - - assert_eq!( - session - .observe_scroll_capture_frame(make_scroll_capture_window(&document, 3, 1, 5)) - .transpose() - .unwrap(), - Some(ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: 1, - }) - ); - assert_eq!( - session - .observe_scroll_capture_frame(make_scroll_capture_window(&document, 3, 2, 5)) - .transpose() - .unwrap(), - Some(ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: 1, - }) - ); - - let height_after_second_append = - session.scroll_capture.session.as_ref().unwrap().export_image().height(); - - session.scroll_capture.input_direction = Some(ScrollDirection::Up); - session.scroll_capture.input_direction_at = - Some(Instant::now() - SCROLL_CAPTURE_INPUT_FRESHNESS - Duration::from_millis(50)); - session.scroll_capture.input_gesture_active = false; - session.scroll_capture.last_external_scroll_input_seq = 7; - session.scroll_capture.inflight_request_id = Some(41); - session.scroll_capture.inflight_request_observation = - Some(InflightScrollCaptureObservation { - was_observable: true, - external_input_seq: 7, - input_direction: Some(ScrollDirection::Down), - }); - - session.handle_captured_scroll_region( - monitor, - capture_rect, - 41, - make_scroll_capture_window(&document, 3, 1, 5), - ); - - assert_eq!(session.scroll_capture.inflight_request_id, None); - assert_eq!(session.scroll_capture.inflight_request_observation, None); + let Some(capture_rect_points) = self.state.frozen_capture_rect else { + tracing::info!( + op = "scroll_capture.start_rejected", + reason = "missing_capture_rect", + monitor_id = monitor.id, + "Skipped starting scroll capture because the frozen capture rect was unavailable." + ); - let scroll_session_debug = - format!("{:?}", session.scroll_capture.session.as_ref().unwrap()); + return None; + }; + let capture_rect_pixels = monitor.local_rect_to_pixels(capture_rect_points); + let Some(base_frame) = + self.cropped_monitor_frozen_region_image(monitor, capture_rect_pixels) + else { + tracing::info!( + op = "scroll_capture.start_rejected", + reason = "base_frame_unavailable", + monitor_id = monitor.id, + capture_rect_points = ?capture_rect_points, + capture_rect_pixels = ?capture_rect_pixels, + "Skipped starting scroll capture because the selected frozen region could not be read." + ); - assert!( - scroll_session_debug.contains("resume_frontier_top_y: None"), - "{scroll_session_debug}" - ); - assert!( - scroll_session_debug.contains("observed_viewport_top_y: 2"), - "{scroll_session_debug}" - ); - assert_eq!( - session.scroll_capture.session.as_ref().unwrap().export_image().height(), - height_after_second_append - ); - } + self.state + .set_error(String::from("Scroll capture could not read the selected region.")); + self.request_redraw_all(); - #[cfg(target_os = "macos")] - #[test] - fn newer_same_direction_input_keeps_latched_worker_observation_context() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, + return None; }; - let capture_rect = RectPoints::new(100, 120, 200, 240); - let base = make_sparse_worker_capture_window(512, 640, 0); - let next = make_sparse_worker_capture_window(512, 640, 90); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = false; - - let height_before_worker_frame = - session.scroll_capture.session.as_ref().unwrap().export_image().height(); - - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; - session.scroll_capture.last_external_scroll_input_seq = 8; - session.scroll_capture.inflight_request_id = Some(41); - session.scroll_capture.inflight_request_observation = - Some(InflightScrollCaptureObservation { - was_observable: true, - external_input_seq: 7, - input_direction: Some(ScrollDirection::Down), - }); - - session.handle_captured_scroll_region(monitor, capture_rect, 41, next); - - assert_eq!(session.scroll_capture.inflight_request_id, None); - assert_eq!(session.scroll_capture.inflight_request_observation, None); - assert_eq!( - session.scroll_capture.session.as_ref().unwrap().export_image().height(), - height_before_worker_frame + 90 - ); - assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 90); - } - #[cfg(target_os = "macos")] - #[test] - fn stale_same_direction_worker_frame_keeps_latched_worker_observation_context() { - let monitor = test_monitor(); - let capture_rect = RectPoints::new(100, 120, 512, 640); - let base = make_sparse_worker_capture_window(512, 640, 0); - let next = make_sparse_worker_capture_window(512, 640, 90); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = - Some(Instant::now() - SCROLL_CAPTURE_INPUT_FRESHNESS - Duration::from_millis(50)); - session.scroll_capture.input_gesture_active = false; - session.scroll_capture.last_external_scroll_input_seq = 8; - session.scroll_capture.inflight_request_id = Some(41); - session.scroll_capture.inflight_request_observation = - Some(InflightScrollCaptureObservation { - was_observable: true, - external_input_seq: 7, - input_direction: Some(ScrollDirection::Down), - }); - - session.handle_captured_scroll_region(monitor, capture_rect, 41, next); - - assert_eq!(session.scroll_capture.inflight_request_id, None); - assert_eq!(session.scroll_capture.inflight_request_observation, None); - assert_eq!(session.scroll_capture.session.as_ref().unwrap().export_image().height(), 730); - assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 90); - } - - #[cfg(target_os = "macos")] - #[test] - fn worker_frame_without_fresh_or_latched_input_fails_closed_without_appending_growth() { - let monitor = test_monitor(); - let capture_rect = RectPoints::new(100, 120, 512, 640); - let base = make_sparse_worker_capture_window(512, 640, 0); - let next = make_sparse_worker_capture_window(512, 640, 90); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); - session.scroll_capture.inflight_request_id = Some(41); - session.scroll_capture.inflight_request_observation = - Some(InflightScrollCaptureObservation { - was_observable: false, - external_input_seq: 7, - input_direction: Some(ScrollDirection::Down), - }); - - let export_height_before = - session.scroll_capture.session.as_ref().unwrap().export_image().height(); - let viewport_top_before = - session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(); - - session.handle_captured_scroll_region(monitor, capture_rect, 41, next); - - assert_eq!(session.scroll_capture.inflight_request_id, None); - assert_eq!(session.scroll_capture.inflight_request_observation, None); - assert_eq!( - session.scroll_capture.session.as_ref().unwrap().export_image().height(), - export_height_before - ); - assert_eq!( - session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), - viewport_top_before - ); + Some((monitor, capture_rect_points, capture_rect_pixels, base_frame)) } #[cfg(target_os = "macos")] - #[test] - fn newer_opposite_direction_supersedes_latched_worker_observation_context() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - [80, 0, 0, 255], - ]; - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let capture_rect = RectPoints::new(100, 120, 200, 240); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.session = - Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; - - assert_eq!( - session - .observe_scroll_capture_frame(make_scroll_capture_window(&document, 3, 1, 5)) - .transpose() - .unwrap(), - Some(ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: 1, - }) - ); - assert_eq!( - session - .observe_scroll_capture_frame(make_scroll_capture_window(&document, 3, 2, 5)) - .transpose() - .unwrap(), - Some(ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: 1, - }) - ); - - let height_after_second_append = - session.scroll_capture.session.as_ref().unwrap().export_image().height(); - - session.scroll_capture.input_direction = Some(ScrollDirection::Up); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; - session.scroll_capture.last_external_scroll_input_seq = 8; - session.scroll_capture.inflight_request_id = Some(41); - session.scroll_capture.inflight_request_observation = - Some(InflightScrollCaptureObservation { - was_observable: true, - external_input_seq: 7, - input_direction: Some(ScrollDirection::Down), - }); - - session.handle_captured_scroll_region( + fn build_scroll_capture_state( + &self, + monitor: MonitorRect, + capture_rect_points: RectPoints, + capture_rect_pixels: RectPoints, + base_frame: RgbaImage, + ) -> Result { + let use_worker_sampling = self.should_use_scroll_capture_worker_sampling(); + let trace_recorder = ScrollCaptureTraceRecorder::from_env( monitor, - capture_rect, - 41, - make_scroll_capture_window(&document, 3, 3, 5), + capture_rect_pixels, + SCROLL_CAPTURE_PREVIEW_WIDTH_PX, + &base_frame, ); + let preview_latest_frame = Some(base_frame.clone()); + let session = ScrollSession::new(base_frame, SCROLL_CAPTURE_PREVIEW_WIDTH_PX)?; + let preview_committed_image = Some(session.preview_image().clone()); + let preview_display_image = preview_committed_image.clone(); - assert_eq!(session.scroll_capture.inflight_request_id, None); - assert_eq!(session.scroll_capture.inflight_request_observation, None); - assert_eq!( - session.scroll_capture.session.as_ref().unwrap().export_image().height(), - height_after_second_append - ); - assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 2); + Ok(ScrollCaptureState { + active: true, + paused: false, + monitor: Some(monitor), + #[cfg(target_os = "macos")] + capture_rect_points: Some(capture_rect_points), + capture_rect_pixels: Some(capture_rect_pixels), + input_direction: None, + input_direction_at: None, + input_gesture_active: false, + downward_motion_rows_pending: 0.0, + #[cfg(target_os = "macos")] + overlay_mouse_passthrough_active: false, + #[cfg(target_os = "macos")] + overlay_mouse_passthrough_persistent: false, + #[cfg(target_os = "macos")] + overlay_mouse_passthrough_until: None, + #[cfg(target_os = "macos")] + external_scroll_input_drain_reader: self + .scroll_capture + .external_scroll_input_drain_reader + .clone(), + last_external_scroll_input_seq: 0, + #[cfg(target_os = "macos")] + pixel_delta_residual: MacOSScrollPixelResidual::default(), + #[cfg(target_os = "macos")] + live_stream: (!use_worker_sampling).then(|| { + MacLiveFrameStream::with_scroll_capture_region_and_waker( + self.config.self_capture_exception_window_ids.clone(), + capture_rect_points, + capture_rect_pixels, + self.scroll_frame_waker.clone(), + ) + }), + #[cfg(target_os = "macos")] + live_stream_backlog: VecDeque::new(), + last_stream_frame_seq: 0, + #[cfg(target_os = "macos")] + last_stream_frame_fingerprint: None, + #[cfg(target_os = "macos")] + consecutive_identical_stream_frames: 0, + #[cfg(target_os = "macos")] + last_consumed_stream_frame_captured_at: None, + #[cfg(target_os = "macos")] + last_stream_event_at: None, + #[cfg(target_os = "macos")] + last_stream_poll_at: None, + #[cfg(target_os = "macos")] + last_duplicate_stream_refresh_at: None, + pending_post_stall_burst_after_seq: None, + #[cfg(target_os = "macos")] + live_stream_stale_grace: None, + next_sample_at: Some(Instant::now() + SCROLL_CAPTURE_SAMPLE_INTERVAL), + next_request_id: 0, + inflight_request_id: None, + #[cfg(target_os = "macos")] + inflight_request_observation: None, + #[cfg(all(test, target_os = "macos"))] + force_worker_sampling_in_tests: false, + session: Some(session), + preview_committed_image, + preview_latest_frame, + preview_display_image, + retained_overlay_preview_image: None, + retained_overlay_preview_motion_rows_hint: None, + last_overlay_preview_motion_rows_hint: None, + last_overlay_preview_provisional_motion_rows_hint: None, + last_overlay_preview_existing_candidate_height: None, + last_overlay_preview_existing_candidate_motion_rows_hint: None, + last_overlay_preview_ledger_candidate_height: None, + last_overlay_preview_ledger_candidate_motion_rows_hint: None, + last_overlay_preview_retained_candidate_height: None, + last_overlay_preview_retained_candidate_motion_rows_hint: None, + last_overlay_preview_retained_hint_matches_motion_rows: false, + last_overlay_preview_fresh_latest_frame_can_drive: false, + last_overlay_preview_strong_unresolved_registration: false, + last_overlay_preview_latest_frame_present: false, + last_overlay_preview_used_provisional: false, + trace_recorder, + }) + } + + fn sync_frozen_toolbar_state(&mut self) { + self.toolbar_state.auto_center_available = self.frozen_auto_center_available(); + self.toolbar_state.scroll_capture_active = self.scroll_capture.active; + // Keep drag-region toolbar geometry stable across the authoritative frozen-capture handoff: + // show the Scroll slot immediately, but keep it disabled until final_capture_ready flips. + self.toolbar_state.scroll_capture_available = self.toolbar_scroll_capture_slot_available(); + self.toolbar_state.final_capture_ready = self.frozen_final_capture_ready(); } - #[cfg(target_os = "macos")] - #[test] - fn successive_same_direction_worker_frames_do_not_stall_after_newer_input() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let capture_rect = RectPoints::new(100, 120, 200, 240); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.session = - Some(ScrollSession::new(make_sparse_worker_capture_window(512, 640, 0), 320).unwrap()); - - for (step, start_row) in [90_u32, 180, 270].into_iter().enumerate() { - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; - session.scroll_capture.last_external_scroll_input_seq = (step as u64) + 2; - session.scroll_capture.inflight_request_id = Some(41 + step as u64); - session.scroll_capture.inflight_request_observation = - Some(InflightScrollCaptureObservation { - was_observable: true, - external_input_seq: (step as u64) + 1, - input_direction: Some(ScrollDirection::Down), - }); - - session.handle_captured_scroll_region( - monitor, - capture_rect, - 41 + step as u64, - make_sparse_worker_capture_window(512, 640, start_row), + fn start_scroll_capture(&mut self) -> OverlayControl { + if self.scroll_capture.active { + tracing::info!( + op = "scroll_capture.start_rejected", + reason = "already_active", + "Skipped starting scroll capture because a session is already active." ); - assert_eq!(session.scroll_capture.inflight_request_id, None); - assert_eq!(session.scroll_capture.inflight_request_observation, None); - assert_eq!( - session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), - start_row as i32 - ); - assert_eq!( - session.scroll_capture.session.as_ref().unwrap().export_image().height(), - 640 + start_row - ); + return OverlayControl::Continue; } - } - #[cfg(target_os = "macos")] - #[test] - fn successive_browser_like_worker_frames_do_not_stall_after_newer_input() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let capture_rect = RectPoints::new(100, 120, 200, 240); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.session = Some( - ScrollSession::new(make_browser_like_worker_capture_window(512, 640, 0), 320).unwrap(), - ); - - for (step, start_row) in [84_u32, 168, 252].into_iter().enumerate() { - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; - session.scroll_capture.last_external_scroll_input_seq = (step as u64) + 12; - session.scroll_capture.inflight_request_id = Some(81 + step as u64); - session.scroll_capture.inflight_request_observation = - Some(InflightScrollCaptureObservation { - was_observable: true, - external_input_seq: (step as u64) + 11, - input_direction: Some(ScrollDirection::Down), - }); - - session.handle_captured_scroll_region( - monitor, - capture_rect, - 81 + step as u64, - make_browser_like_worker_capture_window(512, 640, start_row), + #[cfg(not(target_os = "macos"))] + { + tracing::info!( + op = "scroll_capture.start_rejected", + reason = "unsupported_platform", + "Skipped starting scroll capture because the current platform is unsupported." ); - assert_eq!(session.scroll_capture.inflight_request_id, None); - assert_eq!(session.scroll_capture.inflight_request_observation, None); - assert_eq!( - session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), - start_row as i32 - ); - assert_eq!( - session.scroll_capture.session.as_ref().unwrap().export_image().height(), - 640 + start_row - ); + OverlayControl::Continue } - } + #[cfg(target_os = "macos")] + { + let Some((monitor, capture_rect_points, capture_rect_pixels, base_frame)) = + self.try_prepare_scroll_capture_start() + else { + return OverlayControl::Continue; + }; - #[cfg(target_os = "macos")] - #[test] - fn missing_worker_scroll_frame_clears_inflight_without_mutating_session() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - [80, 0, 0, 255], - ]; - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let capture_rect = RectPoints::new(100, 120, 200, 240); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.session = - Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; - session.scroll_capture.last_external_scroll_input_seq = 11; - session.scroll_capture.inflight_request_id = Some(41); - session.scroll_capture.inflight_request_observation = - Some(InflightScrollCaptureObservation { - was_observable: true, - external_input_seq: 11, - input_direction: Some(ScrollDirection::Down), - }); - - let scroll_session_before = - format!("{:?}", session.scroll_capture.session.as_ref().unwrap()); - - session.handle_missing_scroll_region(monitor, capture_rect, 41); - - assert_eq!(session.scroll_capture.inflight_request_id, None); - assert_eq!(session.scroll_capture.inflight_request_observation, None); - assert_eq!( - format!("{:?}", session.scroll_capture.session.as_ref().unwrap()), - scroll_session_before - ); - } + if let Some(guard) = self.scroll_capture_start_guard.clone() { + match guard() { + Ok(true) => {}, + Ok(false) => return OverlayControl::Continue, + Err(err) => { + self.state.set_error(format!("{err:#}")); + self.request_redraw_all(); - #[cfg(target_os = "macos")] - #[test] - fn maybe_tick_scroll_capture_stays_on_stream_path_without_worker_fallback() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); - session.scroll_capture.live_stream = Some(MacLiveFrameStream::new()); - - session.maybe_tick_scroll_capture(); - - assert!(!session.scroll_capture.paused); - assert!(session.state.error_message.is_none()); - assert_eq!(session.scroll_capture.inflight_request_id, None); - assert!(matches!( - session.scroll_capture.live_stream.as_ref().unwrap().debug_last_request_kind(), - Some("ordered_rgba_regions_after_seq_nonblocking") - | Some("refresh_monitor_nonblocking_if_stale") - )); - } + return OverlayControl::Continue; + }, + } + } + if let Some(hook) = self.scroll_capture_starting_hook.clone() + && let Err(err) = hook() + { + self.state.set_error(format!("{err:#}")); + self.request_redraw_all(); - #[cfg(target_os = "macos")] - #[test] - fn maybe_tick_scroll_capture_drains_external_input_without_a_new_stream_frame() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let rect = RectPoints::new(100, 120, 200, 240); - let tick_at = Instant::now(); - let event_at = tick_at - Duration::from_millis(1); - let events = Arc::new([(1, event_at, 150.0, 160.0, -4.0, true, false)]); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(rect); - session.scroll_capture.live_stream = Some(MacLiveFrameStream::new()); - session.set_external_scroll_input_drain_reader(Arc::new({ - let events = Arc::clone(&events); - - move |after_seq, through| { - events - .iter() - .copied() - .filter(|event| event.0 > after_seq && event.1 <= through) - .collect() + return OverlayControl::Continue; } - })); - session.maybe_tick_scroll_capture(); + let base_frame_dimensions = base_frame.dimensions(); - assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); - assert!(session.scroll_capture.input_gesture_active); - assert_eq!(session.scroll_capture.last_external_scroll_input_seq, 1); - assert!(session.state.error_message.is_none()); - } + self.scroll_capture = match self.build_scroll_capture_state( + monitor, + capture_rect_points, + capture_rect_pixels, + base_frame, + ) { + Ok(scroll_capture) => scroll_capture, + Err(err) => { + self.state.set_error(format!("{err:#}")); + self.request_redraw_all(); - #[cfg(target_os = "macos")] - #[test] - fn maybe_tick_scroll_capture_does_not_synthesize_preview_growth_from_input_without_semantic_sample() - { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - [80, 0, 0, 255], - ]; - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let rect = RectPoints::new(100, 120, 200, 240); - let tick_at = Instant::now(); - let event_at = tick_at - Duration::from_millis(1); - let events = Arc::new([(1, event_at, 150.0, 160.0, -4.0, true, false)]); - let base_frame = make_scroll_capture_window(&document, 3, 0, 5); - let latest_frame = make_scroll_capture_window(&document, 3, 1, 5); - let scroll_session = ScrollSession::new(base_frame.clone(), 320).unwrap(); - let committed_preview = scroll_session.preview_image().clone(); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(rect); - session.scroll_capture.live_stream = Some(MacLiveFrameStream::new()); - session.scroll_capture.session = Some(scroll_session); - session.scroll_capture.preview_committed_image = Some(committed_preview.clone()); - session.scroll_capture.preview_display_image = Some(committed_preview.clone()); - session.scroll_capture.preview_latest_frame = Some(latest_frame); - session.set_external_scroll_input_drain_reader(Arc::new({ - let events = Arc::clone(&events); - - move |after_seq, through| { - events - .iter() - .copied() - .filter(|event| event.0 > after_seq && event.1 <= through) - .collect() + return OverlayControl::Continue; + }, + }; + + if let Some(hook) = self.scroll_capture_started_hook.clone() { + hook(); + } + if let Some(trace_recorder) = self.scroll_capture.trace_recorder.as_ref() { + tracing::info!( + op = "scroll_capture.trace_recording_enabled", + manifest_path = %trace_recorder.manifest_path().display(), + "Enabled scroll-capture live trace recording for this session." + ); } - })); - session.maybe_tick_scroll_capture(); + tracing::info!( + op = "scroll_capture.start", + frozen_capture_source = ?self.frozen_capture_source, + monitor_id = monitor.id, + monitor_origin = ?monitor.origin, + monitor_size_points = ?(monitor.width, monitor.height), + monitor_scale_factor = monitor.scale_factor(), + capture_rect_points = ?capture_rect_points, + capture_rect_pixels = ?capture_rect_pixels, + base_frame_px = ?base_frame_dimensions, + "Entered scroll-capture mode." + ); - assert_eq!(session.scroll_capture.preview_display_image.as_ref(), Some(&committed_preview)); - assert_eq!(scroll_capture_export_height(&session), base_frame.height()); - } + self.sync_frozen_toolbar_state(); + self.refresh_scroll_preview_committed_image(); + self.refresh_scroll_preview_display_image(); + self.sync_scroll_preview_segments(); + self.position_scroll_preview_window(monitor); + self.update_scroll_toolbar_default_position(monitor); + self.set_scroll_overlay_mouse_passthrough_persistent(true, "scroll_capture_started"); + self.focus_scroll_keyboard_window(); - #[cfg(target_os = "macos")] - #[test] - fn maybe_tick_scroll_capture_does_not_double_count_preview_growth_from_same_latest_frame() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - ]; - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let rect = RectPoints::new(100, 120, 200, 240); - let tick_at = Instant::now(); - let event_at = tick_at - Duration::from_millis(1); - let events = Arc::new([(1, event_at, 150.0, 160.0, -4.0, true, false)]); - let base_frame = make_scroll_capture_window(&document, 3, 0, 5); - let moved_frame = make_scroll_capture_window(&document, 3, 1, 5); - let mut session = OverlaySession::new(); - let mut scroll_session = ScrollSession::new(base_frame, 320).unwrap(); - - assert!(matches!( - scroll_session.observe_downward_sample(moved_frame.clone()).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - )); - - let committed_preview = scroll_session.preview_image().clone(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(rect); - session.scroll_capture.live_stream = Some(MacLiveFrameStream::new()); - session.scroll_capture.session = Some(scroll_session); - session.scroll_capture.preview_committed_image = Some(committed_preview.clone()); - session.scroll_capture.preview_display_image = Some(committed_preview.clone()); - session.scroll_capture.preview_latest_frame = Some(moved_frame); - session.set_external_scroll_input_drain_reader(Arc::new({ - let events = Arc::clone(&events); - - move |after_seq, through| { - events - .iter() - .copied() - .filter(|event| event.0 > after_seq && event.1 <= through) - .collect() + if let Some(preview) = self.scroll_preview_window.as_ref() { + preview.window.set_visible(true); + preview.window.request_redraw(); + } + if let (Some(monitor), Some(live_stream)) = + (self.scroll_capture.monitor, self.scroll_capture.live_stream.as_ref()) + { + live_stream.prime_monitor_nonblocking(monitor); } - })); - session.maybe_tick_scroll_capture(); + self.request_redraw_for_monitor(monitor); - assert_eq!(session.scroll_capture.preview_display_image.as_ref(), Some(&committed_preview)); - assert_eq!(scroll_capture_export_height(&session), committed_preview.height()); + OverlayControl::Continue + } } - #[cfg(target_os = "macos")] - #[test] - fn maybe_tick_scroll_capture_worker_path_recovers_after_blocked_overshot_frame() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let rect = RectPoints::new(100, 120, 512, 640); - let base = make_browser_like_worker_capture_window(512, 640, 0); - let blocked = make_browser_like_worker_capture_window(512, 640, 760); - let followup = make_browser_like_worker_capture_window(512, 640, 844); - let mut session = OverlaySession::new(); - - session.worker = Some(OverlayWorker::new( - Box::new(SequenceScrollCaptureBackend::new([Some(blocked), Some(followup)])), - None, - )); - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(rect); - session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); - - enable_test_worker_scroll_capture_path(&mut session); - set_scroll_capture_input(&mut session, ScrollDirection::Down); - - session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); - - session.maybe_tick_scroll_capture(); - - assert!(session.scroll_capture.inflight_request_id.is_some()); - - drain_scroll_capture_worker_until_idle(&mut session); - - assert_eq!(scroll_capture_export_height(&session), 640); - assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 0); - - set_scroll_capture_input(&mut session, ScrollDirection::Down); - - session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + fn toggle_scroll_capture_paused(&mut self) { + if !self.scroll_capture.active { + return; + } - session.maybe_tick_scroll_capture(); + self.scroll_capture.paused = !self.scroll_capture.paused; - assert!(session.scroll_capture.inflight_request_id.is_some()); + #[cfg(target_os = "macos")] + if self.scroll_capture.paused { + self.set_scroll_overlay_mouse_passthrough_persistent(false, "paused"); + } + if !self.scroll_capture.paused { + #[cfg(target_os = "macos")] + { + self.set_scroll_overlay_mouse_passthrough_persistent(true, "resumed"); - drain_scroll_capture_worker_until_idle(&mut session); + if let (Some(monitor), Some(live_stream)) = + (self.scroll_capture.monitor, self.scroll_capture.live_stream.as_ref()) + { + live_stream.prime_monitor_nonblocking(monitor); + } + } + #[cfg(not(target_os = "macos"))] + { + self.scroll_capture.next_sample_at = + Some(Instant::now() + SCROLL_CAPTURE_SAMPLE_INTERVAL); + } + } - assert_eq!(scroll_capture_export_height(&session), 724); - assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 84); + self.request_redraw_scroll_preview_window(); } - #[cfg(target_os = "macos")] - #[test] - fn maybe_tick_scroll_capture_worker_path_retries_immediately_after_blocked_overshot_frame_during_fresh_downward_input() - { - let monitor = test_monitor(); - let rect = RectPoints::new(100, 120, 512, 640); - let base = make_browser_like_worker_capture_window(512, 640, 0); - let blocked = make_browser_like_worker_capture_window(512, 640, 760); - let followup = make_browser_like_worker_capture_window(512, 640, 844); - let mut session = OverlaySession::new(); - - session.worker = Some(OverlayWorker::new( - Box::new(SequenceScrollCaptureBackend::new([Some(blocked), Some(followup)])), - None, - )); - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(rect); - session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); - - enable_test_worker_scroll_capture_path(&mut session); - set_scroll_capture_input(&mut session, ScrollDirection::Down); - - session.scroll_capture.last_external_scroll_input_seq = 1; - session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); - - session.maybe_tick_scroll_capture(); - - assert!(session.scroll_capture.inflight_request_id.is_some()); - - drain_scroll_capture_worker_until_idle(&mut session); - - assert_eq!(scroll_capture_export_height(&session), 640); - - session.scroll_capture.last_external_scroll_input_seq = 2; - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; - - session.maybe_tick_scroll_capture(); - - assert!( - session.scroll_capture.inflight_request_id.is_some(), - "fresh downward input after a blocked worker frame should retry immediately" - ); - - drain_scroll_capture_worker_until_idle(&mut session); + fn prepare_active_scroll_capture_output(&mut self) { + if !self.scroll_capture.active { + return; + } - assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 84); - assert_eq!(scroll_capture_export_height(&session), 724); + self.maybe_tick_scroll_capture(); + self.refresh_scroll_preview_committed_image(); + self.refresh_scroll_preview_display_image(); + self.sync_scroll_preview_segments(); } - #[cfg(target_os = "macos")] - #[test] - fn maybe_tick_scroll_capture_worker_path_recovers_across_interleaved_no_frame_and_blocked_browser_steps() - { - let monitor = test_monitor(); - let rect = RectPoints::new(100, 120, 512, 640); - let mut session = OverlaySession::new(); - - session.worker = Some(OverlayWorker::new( - Box::new(SequenceScrollCaptureBackend::new([ - None, - Some(make_browser_like_worker_capture_window(512, 640, 84)), - Some(make_browser_like_worker_capture_window(512, 640, 700)), - Some(make_browser_like_worker_capture_window(512, 640, 784)), - None, - Some(make_browser_like_worker_capture_window(512, 640, 868)), - ])), - None, - )); - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(rect); - session.scroll_capture.session = Some( - ScrollSession::new(make_browser_like_worker_capture_window(512, 640, 0), 320).unwrap(), - ); - - enable_test_worker_scroll_capture_path(&mut session); - - for expected_top_y in [84_i32, 168, 252] { - let mut attempts = 0_u8; - - while session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y() - < expected_top_y - { - attempts = attempts.saturating_add(1); - - assert!( - attempts <= 4, - "worker path failed to recover to expected_top_y={expected_top_y}" - ); - - set_scroll_capture_input(&mut session, ScrollDirection::Down); - - session.scroll_capture.last_external_scroll_input_seq = - session.scroll_capture.last_external_scroll_input_seq.saturating_add(1); - session.scroll_capture.next_sample_at = - Some(Instant::now() - Duration::from_millis(1)); + fn undo_scroll_capture_append(&mut self) { + if !self.scroll_capture.active { + return; + } - session.maybe_tick_scroll_capture(); + let Some(session) = self.scroll_capture.session.as_mut() else { + return; + }; - assert!(session.scroll_capture.inflight_request_id.is_some()); + if !session.undo_last_append() { + return; + } - session.scroll_capture.last_external_scroll_input_seq = - session.scroll_capture.last_external_scroll_input_seq.saturating_add(1); - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; + self.refresh_scroll_preview_committed_image(); + self.clear_scroll_capture_inflight_request(); - drain_scroll_capture_worker_until_idle(&mut session); + #[cfg(target_os = "macos")] + { + if let (Some(monitor), Some(live_stream)) = + (self.scroll_capture.monitor, self.scroll_capture.live_stream.as_ref()) + { + live_stream.prime_monitor_nonblocking(monitor); } - - assert_eq!( - session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), - expected_top_y - ); - assert_eq!(scroll_capture_export_height(&session), 640 + expected_top_y as u32); } - } - - #[cfg(target_os = "macos")] - #[test] - fn maybe_tick_scroll_capture_worker_path_keeps_same_direction_superseded_response() { - let monitor = test_monitor(); - let rect = RectPoints::new(100, 120, 512, 640); - let base = make_sparse_worker_capture_window(512, 640, 0); - let moved = make_sparse_worker_capture_window(512, 640, 180); - let mut session = OverlaySession::new(); - - session.worker = Some(OverlayWorker::new( - Box::new(SequenceScrollCaptureBackend::new([Some(moved)])), - None, - )); - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(rect); - session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); - - enable_test_worker_scroll_capture_path(&mut session); - set_scroll_capture_input(&mut session, ScrollDirection::Down); - - session.scroll_capture.last_external_scroll_input_seq = 1; - session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); - - session.maybe_tick_scroll_capture(); - - assert!(session.scroll_capture.inflight_request_id.is_some()); - - session.scroll_capture.last_external_scroll_input_seq = 2; - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - - drain_scroll_capture_worker_until_idle(&mut session); + #[cfg(not(target_os = "macos"))] + { + self.scroll_capture.next_sample_at = + Some(Instant::now() + SCROLL_CAPTURE_SAMPLE_INTERVAL); + } - assert_eq!(scroll_capture_export_height(&session), 820); - assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 180); + self.refresh_scroll_preview_display_image(); + self.sync_scroll_preview_segments(); } - #[cfg(target_os = "macos")] - #[test] - fn maybe_tick_scroll_capture_worker_path_commits_successive_browser_like_frames_after_newer_same_direction_input() - { - let monitor = test_monitor(); - let rect = RectPoints::new(100, 120, 512, 640); - let mut session = OverlaySession::new(); - - session.worker = Some(OverlayWorker::new( - Box::new(SequenceScrollCaptureBackend::new([ - Some(make_browser_like_worker_capture_window(512, 640, 84)), - Some(make_browser_like_worker_capture_window(512, 640, 168)), - Some(make_browser_like_worker_capture_window(512, 640, 252)), - ])), - None, - )); - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(rect); - session.scroll_capture.session = Some( - ScrollSession::new(make_browser_like_worker_capture_window(512, 640, 0), 320).unwrap(), - ); + fn begin_png_action(&mut self, action: PngAction) { + if !matches!(self.state.mode, OverlayMode::Frozen) { + return; + } + if !self.frozen_final_capture_ready() { + self.state.set_error("Preparing capture..."); + self.request_redraw_all(); - enable_test_worker_scroll_capture_path(&mut session); + return; + } - for (step, expected_top_y) in [84_i32, 168, 252].into_iter().enumerate() { - set_scroll_capture_input(&mut session, ScrollDirection::Down); + self.prepare_active_scroll_capture_output(); - session.scroll_capture.last_external_scroll_input_seq = (step as u64) + 1; - session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + let image = if self.scroll_capture.active { + self.current_scroll_preview_render_image() + } else { + self.current_export_image() + }; + let Some(export_image) = image else { + return; + }; - session.maybe_tick_scroll_capture(); + #[cfg(target_os = "macos")] + self.cancel_ocr_output_intent(); - assert!(session.scroll_capture.inflight_request_id.is_some()); + self.pending_png_action = Some(action); - session.scroll_capture.last_external_scroll_input_seq = (step as u64) + 2; - session.scroll_capture.input_direction = Some(ScrollDirection::Down); + match action { + PngAction::Copy => self.state.set_error("Copying..."), + PngAction::Save => self.state.set_error("Saving..."), + } - drain_scroll_capture_worker_until_idle(&mut session); + self.pending_encode_png = Some(export_image); - assert_eq!(session.scroll_capture.inflight_request_id, None); - assert_eq!( - session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), - expected_top_y - ); - assert_eq!( - session.scroll_capture.session.as_ref().unwrap().export_image().height(), - 640 + expected_top_y as u32 - ); - } + self.request_redraw_all(); } #[cfg(target_os = "macos")] - #[test] - fn maybe_tick_scroll_capture_worker_path_drops_opposite_direction_superseded_response() { - let monitor = test_monitor(); - let rect = RectPoints::new(100, 120, 512, 640); - let base = make_sparse_worker_capture_window(512, 640, 0); - let moved = make_sparse_worker_capture_window(512, 640, 180); - let mut session = OverlaySession::new(); - - session.worker = Some(OverlayWorker::new( - Box::new(SequenceScrollCaptureBackend::new([Some(moved)])), - None, - )); - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(rect); - session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); + fn begin_ocr_action(&mut self) { + if !matches!(self.state.mode, OverlayMode::Frozen) { + return; + } + if !self.frozen_final_capture_ready() { + self.state.set_error("Preparing capture..."); + self.request_redraw_all(); - enable_test_worker_scroll_capture_path(&mut session); - set_scroll_capture_input(&mut session, ScrollDirection::Down); + return; + } - session.scroll_capture.last_external_scroll_input_seq = 1; - session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + self.prepare_active_scroll_capture_output(); - session.maybe_tick_scroll_capture(); + let Some(export_image) = self.current_export_image() else { + return; + }; + let request_id = self.next_ocr_request_id(); - assert!(session.scroll_capture.inflight_request_id.is_some()); + self.pending_png_action = None; + self.pending_encode_png = None; + self.active_ocr_request_id = Some(request_id); - session.scroll_capture.last_external_scroll_input_seq = 2; - session.scroll_capture.input_direction = Some(ScrollDirection::Up); + self.state.set_error("Recognizing text..."); - drain_scroll_capture_worker_until_idle(&mut session); + self.pending_recognize_text = + Some(PendingRecognizeTextRequest { request_id, image: export_image }); - assert_eq!(scroll_capture_export_height(&session), 640); - assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 0); + self.request_redraw_all(); } - #[cfg(target_os = "macos")] - #[test] - fn maybe_tick_scroll_capture_worker_path_retries_immediately_after_no_new_frame_during_fresh_downward_input() - { - let monitor = test_monitor(); - let rect = RectPoints::new(100, 120, 512, 640); - let base = make_browser_like_worker_capture_window(512, 640, 0); - let moved = make_browser_like_worker_capture_window(512, 640, 84); - let mut session = OverlaySession::new(); + fn handle_redraw_requested(&mut self, window_id: WindowId) -> OverlayControl { + let now = Instant::now(); - session.worker = Some(OverlayWorker::new( - Box::new(SequenceScrollCaptureBackend::new([None, Some(moved)])), - None, - )); - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(rect); - session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); + self.event_loop_last_progress_window_id = Some(window_id); + self.event_loop_last_progress_monitor_id = + self.windows.get(&window_id).map(|window| window.monitor.id); - enable_test_worker_scroll_capture_path(&mut session); - set_scroll_capture_input(&mut session, ScrollDirection::Down); + self.maybe_log_event_loop_stall(now); + self.mark_progress(OverlayEventLoopPhase::RedrawDispatch); - session.scroll_capture.last_external_scroll_input_seq = 1; - session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + let control = self.drain_worker_responses(); - session.maybe_tick_scroll_capture(); + if !matches!(control, OverlayControl::Continue) { + return control; + } + if self.hud_window.as_ref().is_some_and(|hud_window| hud_window.window.id() == window_id) { + return self.handle_hud_redraw_requested(); + } + if self + .loupe_window + .as_ref() + .is_some_and(|loupe_window| loupe_window.window.id() == window_id) + { + return self.handle_loupe_redraw_requested(); + } + if self + .scroll_preview_window + .as_ref() + .is_some_and(|preview_window| preview_window.window.id() == window_id) + { + return self.handle_scroll_preview_redraw_requested(); + } - assert!(session.scroll_capture.inflight_request_id.is_some()); + self.handle_overlay_window_redraw(window_id) + } - drain_scroll_capture_worker_until_idle(&mut session); + #[cfg(target_os = "macos")] + #[cfg(target_os = "macos")] + fn set_scroll_overlay_mouse_passthrough(&self, passthrough: bool) { + for overlay_window in self.windows.values() { + let _ = overlay_window.window.set_cursor_hittest(!passthrough); + } + } - assert_eq!(session.scroll_capture.inflight_request_id, None); - assert_eq!(scroll_capture_export_height(&session), 640); + #[cfg(target_os = "macos")] + fn set_scroll_overlay_mouse_passthrough_state( + &mut self, + now: Instant, + passthrough: bool, + reason: &'static str, + ) { + if self.scroll_capture.overlay_mouse_passthrough_active == passthrough { + return; + } - session.scroll_capture.last_external_scroll_input_seq = 2; - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; + self.set_scroll_overlay_mouse_passthrough(passthrough); - session.maybe_tick_scroll_capture(); + self.scroll_capture.overlay_mouse_passthrough_active = passthrough; - assert!( - session.scroll_capture.inflight_request_id.is_some(), - "fresh downward input after a worker no-frame response should retry immediately" + tracing::info!( + op = if passthrough { + "scroll_capture.mouse_passthrough_armed" + } else { + "scroll_capture.mouse_passthrough_disarmed" + }, + reason, + passthrough, + deadline_in_ms = self.scroll_capture.overlay_mouse_passthrough_until.map(|deadline| { + u64::try_from(deadline.saturating_duration_since(now).as_millis()) + .unwrap_or(u64::MAX) + }), + "Updated scroll-capture mouse passthrough state." ); + } - drain_scroll_capture_worker_until_idle(&mut session); + #[cfg(target_os = "macos")] + fn set_scroll_overlay_mouse_passthrough_persistent( + &mut self, + passthrough: bool, + reason: &'static str, + ) { + let now = Instant::now(); - assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 84); - assert_eq!(scroll_capture_export_height(&session), 724); - } + self.scroll_capture.overlay_mouse_passthrough_persistent = passthrough; + self.scroll_capture.overlay_mouse_passthrough_until = None; - #[test] - fn scroll_capture_sample_interval_matches_platform_worker_sampling_strategy() { - #[cfg(target_os = "macos")] - assert_eq!(SCROLL_CAPTURE_SAMPLE_INTERVAL, Duration::from_millis(250)); - #[cfg(not(target_os = "macos"))] - assert_eq!(SCROLL_CAPTURE_SAMPLE_INTERVAL, Duration::from_millis(50)); + self.set_scroll_overlay_mouse_passthrough_state(now, passthrough, reason); } #[cfg(target_os = "macos")] - #[test] - fn maybe_tick_scroll_capture_worker_path_backs_off_after_duplicate_committed_frame() { - let monitor = test_monitor(); - let rect = RectPoints::new(100, 120, 512, 640); - let base = make_browser_like_worker_capture_window(512, 640, 0); - let step_one = make_browser_like_worker_capture_window(512, 640, 84); - let step_two = make_browser_like_worker_capture_window(512, 640, 168); - let mut session = OverlaySession::new(); + fn arm_scroll_overlay_mouse_passthrough_window(&mut self, now: Instant, reason: &'static str) { + if self.scroll_capture.overlay_mouse_passthrough_persistent { + return; + } - session.worker = Some(OverlayWorker::new( - Box::new(SequenceScrollCaptureBackend::new([ - Some(step_one.clone()), - Some(step_one), - Some(step_two), - ])), - None, - )); - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(rect); - session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); + let deadline = now + SCROLL_CAPTURE_MOUSE_PASSTHROUGH_IDLE_GRACE; + let was_active = self.scroll_capture.overlay_mouse_passthrough_active; - enable_test_worker_scroll_capture_path(&mut session); - set_scroll_capture_input(&mut session, ScrollDirection::Down); + self.scroll_capture.overlay_mouse_passthrough_until = Some(deadline); - session.scroll_capture.last_external_scroll_input_seq = 1; - session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + self.set_scroll_overlay_mouse_passthrough_state(now, true, reason); - session.maybe_tick_scroll_capture(); + if was_active { + tracing::info!( + op = "scroll_capture.mouse_passthrough_extended", + reason, + deadline_in_ms = u64::try_from(deadline.saturating_duration_since(now).as_millis()) + .unwrap_or(u64::MAX), + "Extended scroll-capture mouse passthrough window." + ); + } + } - assert!(session.scroll_capture.inflight_request_id.is_some()); + #[cfg(target_os = "macos")] + fn disarm_scroll_overlay_mouse_passthrough(&mut self, now: Instant, reason: &'static str) { + self.scroll_capture.overlay_mouse_passthrough_persistent = false; + self.scroll_capture.overlay_mouse_passthrough_until = None; - drain_scroll_capture_worker_until_idle(&mut session); + self.set_scroll_overlay_mouse_passthrough_state(now, false, reason); + } - assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 84); - assert_eq!(scroll_capture_export_height(&session), 724); + #[cfg(target_os = "macos")] + fn sync_scroll_overlay_mouse_passthrough_window(&mut self, now: Instant) { + if self.scroll_capture.overlay_mouse_passthrough_persistent { + return; + } + if !self.scroll_capture.overlay_mouse_passthrough_active { + return; + } - session.scroll_capture.last_external_scroll_input_seq = 2; - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; - session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + let Some(deadline) = self.scroll_capture.overlay_mouse_passthrough_until else { + self.set_scroll_overlay_mouse_passthrough_state(now, false, "missing_deadline"); - session.maybe_tick_scroll_capture(); + return; + }; - assert!(session.scroll_capture.inflight_request_id.is_some()); + if deadline <= now { + self.disarm_scroll_overlay_mouse_passthrough(now, "idle_timeout"); + } + } - drain_scroll_capture_worker_until_idle(&mut session); + #[cfg(target_os = "macos")] + fn focus_frozen_keyboard_window(&self) { + macos_activate_app(); - assert_eq!(session.scroll_capture.inflight_request_id, None); - assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 84); - assert_eq!(scroll_capture_export_height(&session), 724); + let target_window = if let Some(toolbar_window) = self.toolbar_window.as_ref() { + Some(toolbar_window.window.as_ref()) + } else { + self.windows + .values() + .find(|overlay_window| Some(overlay_window.monitor) == self.state.monitor) + .map(|overlay_window| overlay_window.window.as_ref()) + }; + let Some(target_window) = target_window else { + tracing::info!( + op = "scroll_capture.frozen_focus_requested", + target = "missing_window", + state_mode = ?self.state.mode, + toolbar_window_present = self.toolbar_window.is_some(), + monitor_id = ?self.state.monitor.map(|monitor| monitor.id), + "Requested frozen keyboard focus, but no target window was available." + ); - session.maybe_tick_scroll_capture(); + return; + }; - assert!( - session.scroll_capture.inflight_request_id.is_none(), - "duplicate committed worker frame should back off instead of immediately re-requesting" + tracing::info!( + op = "scroll_capture.frozen_focus_requested", + target = if self.toolbar_window.is_some() { "toolbar_window" } else { "overlay_window" }, + state_mode = ?self.state.mode, + toolbar_window_visible = self.toolbar_window_visible, + monitor_id = ?self.state.monitor.map(|monitor| monitor.id), + "Requested frozen keyboard focus." ); - session.scroll_capture.last_external_scroll_input_seq = 3; - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; - session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + macos_make_window_key(target_window); + } - session.maybe_tick_scroll_capture(); + #[cfg(target_os = "macos")] + fn focus_live_capture_window(&self) { + macos_activate_app(); - assert!(session.scroll_capture.inflight_request_id.is_some()); + let target_window = self + .active_cursor_monitor() + .and_then(|monitor| { + self.windows.values().find(|overlay_window| overlay_window.monitor == monitor) + }) + .or_else(|| self.windows.values().next()) + .map(|overlay_window| overlay_window.window.as_ref()); + let Some(target_window) = target_window else { + tracing::info!( + op = "overlay.live_focus_requested", + target = "missing_window", + window_count = self.windows.len(), + "Requested live capture focus, but no overlay window was available." + ); - drain_scroll_capture_worker_until_idle(&mut session); + return; + }; - assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 168); - assert_eq!(scroll_capture_export_height(&session), 808); - } + tracing::info!( + op = "overlay.live_focus_requested", + target = "overlay_window", + window_count = self.windows.len(), + cursor_monitor_id = ?self.active_cursor_monitor().map(|monitor| monitor.id), + "Requested live capture focus." + ); - #[cfg(target_os = "macos")] - #[test] - fn handle_scroll_input_ready_drains_input_and_polls_stream_fallback() { - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let rect = RectPoints::new(100, 120, 200, 240); - let handled_at = Instant::now(); - let event_at = handled_at - Duration::from_millis(1); - let events = Arc::new([(1, event_at, 150.0, 160.0, -4.0, true, false)]); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(rect); - session.scroll_capture.live_stream = Some(MacLiveFrameStream::new()); - session.set_external_scroll_input_drain_reader(Arc::new({ - let events = Arc::clone(&events); - - move |after_seq, through| { - events - .iter() - .copied() - .filter(|event| event.0 > after_seq && event.1 <= through) - .collect() - } - })); - - assert!(matches!(session.handle_scroll_input_ready(), OverlayControl::Continue)); - assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); - assert!(session.scroll_capture.input_gesture_active); - assert_eq!(session.scroll_capture.last_external_scroll_input_seq, 1); - assert!(matches!( - session.scroll_capture.live_stream.as_ref().unwrap().debug_last_request_kind(), - Some("ordered_rgba_regions_after_seq_nonblocking") - | Some("refresh_monitor_nonblocking_if_stale") - )); + macos_make_window_key(target_window); } #[cfg(target_os = "macos")] - #[test] - fn drain_external_scroll_input_worker_path_does_not_arm_live_stream_stale_grace() { - let monitor = test_monitor(); - let rect = RectPoints::new(100, 120, 512, 640); - let through = Instant::now(); - let recorded_at = through - Duration::from_millis(1); - let events = Arc::new([(1, recorded_at, 150.0, 160.0, -4.0, false, false)]); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(rect); - session.scroll_capture.live_stream = Some(MacLiveFrameStream::new()); - - enable_test_worker_scroll_capture_path(&mut session); - - session.set_external_scroll_input_drain_reader(Arc::new({ - let events = Arc::clone(&events); - - move |after_seq, through| { - events - .iter() - .copied() - .filter(|event| event.0 > after_seq && event.1 <= through) - .collect() - } - })); + fn focus_scroll_keyboard_window(&self) { + macos_activate_app(); - session.drain_external_scroll_input_events_through(through); + let target_window = if let Some(toolbar_window) = self.toolbar_window.as_ref() { + Some(toolbar_window.window.as_ref()) + } else if let Some(preview_window) = self.scroll_preview_window.as_ref() { + Some(preview_window.window.as_ref()) + } else { + self.windows + .values() + .find(|overlay_window| Some(overlay_window.monitor) == self.scroll_capture.monitor) + .map(|overlay_window| overlay_window.window.as_ref()) + }; + let Some(target_window) = target_window else { + return; + }; - assert_eq!(session.scroll_capture.last_external_scroll_input_seq, 1); - assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); - assert!(!session.scroll_capture.input_gesture_active); - assert_eq!(session.scroll_capture.live_stream_stale_grace, None); + macos_make_window_key(target_window); } - #[cfg(target_os = "macos")] - #[test] - fn force_stream_refresh_stays_disabled_while_downward_gesture_is_still_active() { - let now = Instant::now(); - let mut session = OverlaySession::new(); + fn update_scroll_toolbar_default_position(&mut self, monitor: MonitorRect) { + if !self.scroll_capture.active || self.toolbar_state.dragging { + return; + } + + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let preview_rect = self.scroll_preview_local_rect(monitor); + let toolbar_size = WindowRenderer::frozen_toolbar_size(&self.toolbar_state); + let toolbar_pos = WindowRenderer::frozen_toolbar_default_pos( + screen_rect, + preview_rect, + toolbar_size, + self.config.toolbar_placement, + ); - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(now); - session.scroll_capture.input_gesture_active = true; - session.scroll_capture.downward_motion_rows_pending = 512.0; + self.toolbar_state.default_slot_position = Some(toolbar_pos); + self.toolbar_state.floating_position = Some(toolbar_pos); - assert!(!session.scroll_capture_should_force_stream_refresh_at(now)); + let _ = self.update_toolbar_outer_position(monitor, toolbar_pos); } - #[cfg(target_os = "macos")] - #[test] - fn stale_stream_refresh_stays_disabled_while_gesture_is_still_active() { - let now = Instant::now(); - let mut session = OverlaySession::new(); + fn maybe_recenter_frozen_toolbar_default_slot(&mut self, monitor: MonitorRect) -> bool { + if !matches!(self.state.mode, OverlayMode::Frozen) || self.state.monitor != Some(monitor) { + return false; + } + if self.scroll_capture.active || self.toolbar_state.dragging { + return false; + } - session.scroll_capture.input_gesture_active = true; - session.scroll_capture.last_stream_event_at = Some( - now - SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW - + Duration::from_millis(1), - ); + let Some(capture_rect) = self.state.frozen_capture_rect else { + return false; + }; + let Some(toolbar_pos) = self.toolbar_state.floating_position else { + return false; + }; + let Some(previous_default_pos) = self.toolbar_state.default_slot_position else { + return false; + }; + let current_default_pos = + self.frozen_toolbar_default_position_for_capture_rect(monitor, capture_rect); - assert!(!session.scroll_capture_should_schedule_stale_stream_refresh_at(now)); - } + self.toolbar_state.default_slot_position = Some(current_default_pos); - #[cfg(target_os = "macos")] - #[test] - fn stale_stream_refresh_reenables_after_gesture_ends() { - let now = Instant::now(); - let mut session = OverlaySession::new(); + if frozen_toolbar_matches_default_slot(toolbar_pos, previous_default_pos) { + self.toolbar_state.floating_position = Some(current_default_pos); - session.scroll_capture.input_gesture_active = false; + return !frozen_toolbar_matches_default_slot(toolbar_pos, current_default_pos); + } - assert!(session.scroll_capture_should_schedule_stale_stream_refresh_at(now)); + false } - #[cfg(target_os = "macos")] - #[test] - fn stale_stream_refresh_reenables_during_gesture_after_stream_goes_dead() { - let now = Instant::now(); - let mut session = OverlaySession::new(); + fn handle_overlay_window_redraw(&mut self, window_id: WindowId) -> OverlayControl { + let Some(overlay_monitor) = self.windows.get(&window_id).map(|overlay| overlay.monitor) + else { + return OverlayControl::Continue; + }; - session.scroll_capture.input_gesture_active = true; - session.scroll_capture.last_stream_event_at = Some( - now - SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW - - Duration::from_millis(1), - ); + self.sync_frozen_toolbar_state(); - assert!(session.scroll_capture_should_schedule_stale_stream_refresh_at(now)); - } + self.event_loop_last_progress_window_id = Some(window_id); + self.event_loop_last_progress_monitor_id = Some(overlay_monitor.id); - #[cfg(target_os = "macos")] - #[test] - fn post_stall_burst_search_stays_enabled_during_active_gesture_when_downward_backlog_is_fresh() - { - let now = Instant::now(); - let mut session = OverlaySession::new(); + self.maybe_log_event_loop_stall(Instant::now()); + self.mark_progress(OverlayEventLoopPhase::OverlayRedraw); - session.scroll_capture.pending_post_stall_burst_after_seq = Some(80); - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(now); - session.scroll_capture.input_gesture_active = true; - session.scroll_capture.downward_motion_rows_pending = 512.0; + // On macOS the frozen toolbar is now rendered in its own native HUD window; keep this + // fullscreen overlay free of toolbar UI so shader-backed blur and monitor-aligned offsets + // do not conflict with native-window positioning. + let draw_toolbar = !cfg!(target_os = "macos") + && matches!(self.state.mode, OverlayMode::Frozen) + && self.toolbar_state.visible + && self.state.monitor == Some(overlay_monitor) + && self.frozen_final_capture_ready(); + let toolbar_input = + if draw_toolbar { self.toolbar_pointer_state(overlay_monitor, None) } else { None }; - assert!(session.scroll_capture_should_allow_post_stall_burst_search_at(81, now)); - } + if matches!(self.state.mode, OverlayMode::Frozen) + && self.state.monitor == Some(overlay_monitor) + { + tracing::trace!( + window_id = ?window_id, + monitor_id = overlay_monitor.id, + frozen_generation = self.state.frozen_generation, + final_capture_ready = self.authoritative_frozen_capture_ready, + frozen_image_ready = self.state.frozen_image.is_some(), + pending_freeze_capture = self.pending_freeze_capture.map(|m| m.id), + draw_toolbar, + toolbar_visible = self.toolbar_state.visible, + toolbar_floating_position = ?self.toolbar_state.floating_position, + toolbar_stable_frames = self.toolbar_state.layout_stable_frames, + toolbar_last_screen_size_points = ?self.toolbar_state.layout_last_screen_size_points, + "Overlay redraw (Frozen)." + ); + } - #[cfg(target_os = "macos")] - #[test] - fn force_stream_refresh_stays_enabled_for_fresh_pending_downward_motion_after_gesture_end() { - let now = Instant::now(); - let mut session = OverlaySession::new(); + let overlay_screen_rect = self.overlay_window_screen_rect(window_id, overlay_monitor); + let toolbar_visible_for_badge = if cfg!(target_os = "macos") { + !self.should_hide_toolbar_window(overlay_monitor) + } else { + draw_toolbar + }; + #[cfg(target_os = "macos")] + let toolbar_ready_for_badge = if toolbar_visible_for_badge { + let ready = self.advance_frozen_toolbar_readiness_sample(overlay_screen_rect); - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = - Some(now - SCROLL_CAPTURE_INPUT_FRESHNESS + Duration::from_millis(50)); - session.scroll_capture.input_gesture_active = false; - session.scroll_capture.downward_motion_rows_pending = 512.0; + if !ready { + self.request_redraw_for_monitor(overlay_monitor); + } - assert!(session.scroll_capture_should_force_stream_refresh_at(now)); - } + ready + } else { + false + }; + #[cfg(not(target_os = "macos"))] + let toolbar_ready_for_badge = + toolbar_visible_for_badge && self.frozen_toolbar_ready_for_draw(overlay_screen_rect); + let frozen_toolbar_reserved_rect = self.frozen_size_badge_toolbar_reserved_rect( + overlay_monitor, + overlay_screen_rect, + toolbar_ready_for_badge, + ); + let Some(gpu) = self.gpu.as_ref() else { + return self.exit(OverlayExit::Error(String::from("Missing GPU context"))); + }; + let toolbar_state = if draw_toolbar { Some(&mut self.toolbar_state) } else { None }; - #[cfg(target_os = "macos")] - #[test] - fn force_stream_refresh_stops_after_downward_input_becomes_stale() { - let now = Instant::now(); - let mut session = OverlaySession::new(); + { + let Some(overlay_window) = self.windows.get_mut(&window_id) else { + return OverlayControl::Continue; + }; - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = - Some(now - SCROLL_CAPTURE_INPUT_FRESHNESS - Duration::from_millis(1)); - session.scroll_capture.input_gesture_active = false; - session.scroll_capture.downward_motion_rows_pending = 512.0; + if let Err(err) = overlay_window.renderer.draw( + gpu, + &self.state, + overlay_monitor, + false, + None, + false, + self.config.hud_anchor, + self.config.toolbar_placement, + self.config.show_alt_hint_keycap, + self.config.show_hud_blur, + self.config.hud_opaque, + self.config.hud_opacity, + self.config.hud_fog_amount, + self.config.hud_milk_amount, + self.config.hud_tint_hue, + self.config.theme_mode, + self.config.selection_flow_enabled, + self.config.selection_flow_stroke_width_px, + !self.scroll_capture.active, + self.scroll_capture.active, + self.frozen_capture_source == FrozenCaptureSource::FullscreenFallback, + frozen_toolbar_reserved_rect, + toolbar_state, + toolbar_input, + ) { + return self.exit(OverlayExit::Error(format!("{err:#}"))); + } + } + self.last_present_at = Instant::now(); - assert!(!session.scroll_capture_should_force_stream_refresh_at(now)); + self.handle_capture_and_toolbar_redraw_post(overlay_monitor, draw_toolbar) } - #[cfg(target_os = "macos")] - #[test] - fn post_stall_burst_search_stays_enabled_while_fresh_downward_backlog_remains() { - let now = Instant::now(); - let mut session = OverlaySession::new(); - - session.scroll_capture.pending_post_stall_burst_after_seq = Some(80); - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(now); - session.scroll_capture.input_gesture_active = false; - session.scroll_capture.downward_motion_rows_pending = 512.0; - - assert!(session.scroll_capture_should_allow_post_stall_burst_search_at(81, now)); - assert!(session.scroll_capture_should_allow_post_stall_burst_search_at( - 82, - now + Duration::from_millis(50) - )); - } + fn overlay_window_screen_rect(&self, window_id: WindowId, monitor: MonitorRect) -> Rect { + let fallback_size = Vec2::new(monitor.width as f32, monitor.height as f32); - #[cfg(target_os = "macos")] - #[test] - fn post_stall_burst_search_arms_for_large_capture_time_gap_even_when_frame_seq_is_contiguous() { - let now = Instant::now(); - let mut session = OverlaySession::new(); - - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(now); - session.scroll_capture.input_gesture_active = true; - session.scroll_capture.downward_motion_rows_pending = 512.0; - session.scroll_capture.last_consumed_stream_frame_captured_at = Some( - now - SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW - - Duration::from_millis(1), - ); + self.windows + .get(&window_id) + .map(|overlay_window| { + let scale_factor = overlay_window.window.scale_factor().max(1.0) as f32; + let size = overlay_window.window.inner_size(); + let size_points = if size.width == 0 || size.height == 0 { + fallback_size + } else { + Vec2::new( + (size.width as f32 / scale_factor).max(1.0), + (size.height as f32 / scale_factor).max(1.0), + ) + }; - assert!(session.scroll_capture_should_arm_post_stall_burst_for_time_gap_at(now)); + Rect::from_min_size(Pos2::ZERO, size_points) + }) + .unwrap_or_else(|| Rect::from_min_size(Pos2::ZERO, fallback_size)) } - #[cfg(target_os = "macos")] - #[test] - fn consuming_live_frame_backlog_arms_time_gap_burst_after_draining_fresh_input() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - ]; - let monitor = MonitorRect { - id: 1, - origin: GlobalPoint::new(0, 0), - width: 1_000, - height: 800, - scale_factor_x1000: 1_000, - }; - let rect = RectPoints::new(100, 120, 200, 240); - let captured_at = Instant::now(); - let event_at = captured_at - Duration::from_millis(1); - let events = Arc::new([(1, event_at, 150.0, 160.0, -74.0, true, false)]); - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.monitor = Some(monitor); - session.scroll_capture.capture_rect_pixels = Some(rect); - session.scroll_capture.session = - Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); - session.scroll_capture.last_consumed_stream_frame_captured_at = Some( - captured_at - - SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW - - Duration::from_millis(1), - ); - session.set_external_scroll_input_drain_reader(Arc::new({ - let events = Arc::clone(&events); - - move |after_seq, through| { - events - .iter() - .copied() - .filter(|event| event.0 > after_seq && event.1 <= through) - .collect() - } - })); - - session.test_push_scroll_capture_live_frame(ScrollCaptureLiveFrame { - frame_seq: 9, - captured_at, - image: make_scroll_capture_window(&document, 3, 0, 5), - }); - session.test_consume_scroll_capture_backlog(1); - - assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); - assert_eq!(session.scroll_capture.last_external_scroll_input_seq, 1); - assert_eq!(session.scroll_capture.pending_post_stall_burst_after_seq, Some(8)); + #[cfg(any(target_os = "macos", test))] + fn advance_frozen_toolbar_readiness_sample(&mut self, screen_rect: Rect) -> bool { + advance_frozen_toolbar_readiness_sample_state(&mut self.toolbar_state, screen_rect) } - #[cfg(target_os = "macos")] - #[test] - fn post_stall_burst_search_does_not_arm_for_small_capture_time_gap() { - let now = Instant::now(); - let mut session = OverlaySession::new(); - - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(now); - session.scroll_capture.input_gesture_active = true; - session.scroll_capture.downward_motion_rows_pending = 512.0; - session.scroll_capture.last_consumed_stream_frame_captured_at = Some( - now - SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW - + Duration::from_millis(10), + #[cfg(any(not(target_os = "macos"), test))] + fn frozen_toolbar_ready_for_draw(&self, screen_rect: Rect) -> bool { + let screen_size_points = screen_rect.size(); + let needs_new_sample = frozen_toolbar_needs_new_sample( + self.toolbar_state.layout_last_screen_size_points, + screen_size_points, ); - assert!(!session.scroll_capture_should_arm_post_stall_burst_for_time_gap_at(now)); + !needs_new_sample && self.toolbar_state.layout_stable_frames >= 1 } - #[cfg(target_os = "macos")] - #[test] - fn post_stall_burst_search_stops_after_downward_backlog_goes_stale() { - let now = Instant::now(); - let mut session = OverlaySession::new(); - - session.scroll_capture.pending_post_stall_burst_after_seq = Some(80); - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = - Some(now - SCROLL_CAPTURE_INPUT_FRESHNESS - Duration::from_millis(1)); - session.scroll_capture.input_gesture_active = false; - session.scroll_capture.downward_motion_rows_pending = 512.0; - - assert!(!session.scroll_capture_should_allow_post_stall_burst_search_at(81, now)); - } + fn frozen_size_badge_toolbar_reserved_rect( + &self, + monitor: MonitorRect, + screen_rect: Rect, + toolbar_ready: bool, + ) -> Option { + if !toolbar_ready + || !matches!(self.state.mode, OverlayMode::Frozen) + || self.state.monitor != Some(monitor) + { + return None; + } - #[cfg(target_os = "macos")] - #[test] - fn apply_self_capture_exception_window_ids_to_active_streams_updates_live_stream_filters() { - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); - - session.window_list_snapshot = Some(Arc::new(WindowListSnapshot { - captured_at: Instant::now(), - windows: Arc::new(vec![WindowRect { - window_id: Some(9), - x: 10, - y: 12, - width: 30, - height: 40, - }]), - })); - - session.apply_self_capture_exception_window_ids_to_active_streams(); - - assert_eq!( - session.live_sample_stream.as_ref().unwrap().debug_self_capture_exception_window_ids(), - &[17] - ); - assert_eq!( - session - .scroll_capture - .live_stream - .as_ref() - .unwrap() - .debug_self_capture_exception_window_ids(), - &[17] - ); - assert_ne!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); - assert!(!session.pending_self_capture_exception_window_ids_worker_refresh); - assert!(session.window_list_snapshot.is_none()); - assert!( - session.last_window_list_refresh_request_at.elapsed() - >= session.window_list_refresh_interval - ); - assert_eq!(session.scroll_capture.last_stream_frame_seq, 0); - assert_eq!(session.scroll_capture.live_stream_stale_grace, None); + WindowRenderer::frozen_toolbar_reserved_rect( + &self.state, + monitor, + screen_rect, + self.config.toolbar_placement, + &self.toolbar_state, + ) } - #[cfg(target_os = "macos")] - #[test] - fn apply_self_capture_exception_window_ids_to_active_streams_keeps_scroll_live_stream_disabled_in_worker_mode() - { - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + fn handle_capture_and_toolbar_redraw_post( + &mut self, + overlay_monitor: MonitorRect, + draw_toolbar: bool, + ) -> OverlayControl { + if self.should_dispatch_pending_freeze_capture(overlay_monitor) + && let Some(worker) = &self.worker + { + let pending_window_target = self + .pending_window_freeze_capture + .filter(|target| target.monitor == overlay_monitor); + let freeze_target = pending_window_target + .map_or(FreezeCaptureTarget::Monitor, |target| FreezeCaptureTarget::Window { + window_id: target.window_id, + }); - enable_test_worker_scroll_capture_path(&mut session); + #[cfg(target_os = "macos")] + { + if worker.request_freeze_capture(overlay_monitor, freeze_target) { + self.pending_freeze_capture = None; + self.pending_freeze_capture_armed = false; + self.inflight_freeze_capture = Some(overlay_monitor); + self.inflight_window_freeze_capture = pending_window_target; + self.pending_window_freeze_capture = None; + } else { + self.request_redraw_for_monitor(overlay_monitor); + } + } + #[cfg(not(target_os = "macos"))] + { + // Capture must happen on a post-hide redraw so the HUD/loupe are not included. + if self.pending_freeze_capture_armed { + if worker.request_freeze_capture(overlay_monitor, freeze_target) { + self.pending_freeze_capture = None; + self.pending_freeze_capture_armed = false; + self.inflight_freeze_capture = Some(overlay_monitor); + self.inflight_window_freeze_capture = pending_window_target; + self.pending_window_freeze_capture = None; + } else { + self.request_redraw_for_monitor(overlay_monitor); + } + } else { + self.pending_freeze_capture_armed = true; - session.test_push_scroll_capture_live_frame(ScrollCaptureLiveFrame { - frame_seq: 9, - captured_at: Instant::now(), - image: test_frozen_image(), - }); + #[cfg(not(target_os = "macos"))] + self.hide_capture_windows(); + self.request_redraw_for_monitor(overlay_monitor); + } + } + } + if draw_toolbar && let Some(action) = self.toolbar_state.pending_action.take() { + let control = self.handle_toolbar_action(action); - session.scroll_capture.last_stream_event_at = Some(Instant::now()); - session.scroll_capture.last_stream_poll_at = Some(Instant::now()); + if !matches!(control, OverlayControl::Continue) { + return control; + } + } + if draw_toolbar && self.toolbar_state.needs_redraw { + self.toolbar_state.needs_redraw = false; - session.apply_self_capture_exception_window_ids_to_active_streams(); + self.request_redraw_for_monitor(overlay_monitor); + } - assert_eq!( - session.live_sample_stream.as_ref().unwrap().debug_self_capture_exception_window_ids(), - &[17] - ); - assert!(session.scroll_capture.live_stream.is_none()); - assert!(session.scroll_capture.live_stream_backlog.is_empty()); - assert!(session.scroll_capture.last_stream_event_at.is_none()); - assert!(session.scroll_capture.last_stream_poll_at.is_none()); - assert_ne!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); - assert!(!session.pending_self_capture_exception_window_ids_worker_refresh); + OverlayControl::Continue } - #[cfg(target_os = "macos")] - #[test] - fn apply_self_capture_exception_window_ids_to_active_streams_defers_worker_refresh_while_freeze_is_inflight() - { - let monitor = test_monitor(); - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); - - session.inflight_freeze_capture = Some(monitor); + fn handle_toolbar_action(&mut self, action: FrozenToolbarTool) -> OverlayControl { + match action { + FrozenToolbarTool::AutoCenter => { + self.auto_center_frozen_capture_rect(); - session.apply_self_capture_exception_window_ids_to_active_streams(); + OverlayControl::Continue + }, + FrozenToolbarTool::Copy => { + self.begin_png_action(PngAction::Copy); - assert_eq!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); - assert!(session.pending_self_capture_exception_window_ids_worker_refresh); - assert_eq!( - session.live_sample_stream.as_ref().unwrap().debug_self_capture_exception_window_ids(), - &[17] - ); - } + OverlayControl::Continue + }, + FrozenToolbarTool::Save => { + self.begin_png_action(PngAction::Save); - #[cfg(target_os = "macos")] - #[test] - fn apply_self_capture_exception_window_ids_to_active_streams_defers_worker_refresh_while_hit_test_is_inflight() - { - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + OverlayControl::Continue + }, + FrozenToolbarTool::Scroll => self.start_scroll_capture(), + #[cfg(target_os = "macos")] + FrozenToolbarTool::Ocr => { + self.begin_ocr_action(); - session.pending_click_hit_test_request_id = Some(7); + OverlayControl::Continue + }, + _ => OverlayControl::Continue, + } + } - session.apply_self_capture_exception_window_ids_to_active_streams(); + fn cancel_overlay(&mut self, reason: &'static str) -> OverlayControl { + tracing::info!( + op = "overlay.cancel_requested", + reason, + mode = ?self.state.mode, + scroll_capture_active = self.scroll_capture.active, + last_event_phase = %self.event_loop_phase.as_str(), + last_event_window_id = ?self.event_loop_last_progress_window_id, + last_event_monitor_id = ?self.event_loop_last_progress_monitor_id, + last_event_detail = ?self.event_loop_last_progress_detail, + "Overlay cancellation was requested." + ); - assert_eq!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); - assert!(session.pending_self_capture_exception_window_ids_worker_refresh); + self.exit(OverlayExit::Cancelled) } - #[cfg(target_os = "macos")] - #[test] - fn apply_self_capture_exception_window_ids_to_active_streams_defers_worker_refresh_while_window_list_refresh_is_inflight() - { - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); - - session.window_list_refresh_inflight = true; + fn exit(&mut self, exit: OverlayExit) -> OverlayControl { + let (exit_kind, png_bytes_len, saved_path, error_message) = match &exit { + OverlayExit::Cancelled => ("cancelled", None, None, None), + OverlayExit::PngBytes(png_bytes) => ("png_bytes", Some(png_bytes.len()), None, None), + OverlayExit::TextCopied(_) => ("text_copied", None, None, None), + OverlayExit::Saved(path) => ("saved", None, Some(path.display().to_string()), None), + OverlayExit::Error(message) => ("error", None, None, Some(message.as_str())), + }; + #[cfg(target_os = "macos")] + let scroll_capture_has_live_stream = self.scroll_capture.live_stream.is_some(); + #[cfg(not(target_os = "macos"))] + let scroll_capture_has_live_stream = false; + #[cfg(target_os = "macos")] + let live_sample_stream_present = self.live_sample_stream.is_some(); + #[cfg(not(target_os = "macos"))] + let live_sample_stream_present = false; - session.apply_self_capture_exception_window_ids_to_active_streams(); + tracing::info!( + op = "overlay.exit_begin", + exit_kind, + png_bytes_len, + saved_path, + error_message, + scroll_capture_active = self.scroll_capture.active, + scroll_capture_has_live_stream, + live_sample_stream_present, + last_event_phase = %self.event_loop_phase.as_str(), + last_event_window_id = ?self.event_loop_last_progress_window_id, + last_event_monitor_id = ?self.event_loop_last_progress_monitor_id, + last_event_detail = ?self.event_loop_last_progress_detail, + "Beginning overlay exit cleanup." + ); - assert_eq!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); - assert!(session.pending_self_capture_exception_window_ids_worker_refresh); - } + if self.scroll_capture.active { + self.maybe_tick_scroll_capture(); + self.refresh_scroll_preview_committed_image(); + self.refresh_scroll_preview_display_image(); + self.sync_scroll_preview_segments(); + } - #[cfg(target_os = "macos")] - #[test] - fn apply_self_capture_exception_window_ids_to_active_streams_defers_worker_refresh_while_png_encode_is_inflight() - { - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + let scroll_capture_final_snapshot = self.scroll_capture_trace_snapshot_at(Instant::now()); + let final_preview_image = self.current_scroll_preview_render_image(); - session.png_encode_inflight = true; + if let (Some(trace_recorder), Some(session)) = + (self.scroll_capture.trace_recorder.as_mut(), self.scroll_capture.session.as_ref()) + { + let final_preview_image = + final_preview_image.unwrap_or_else(|| session.preview_image().clone()); - session.apply_self_capture_exception_window_ids_to_active_streams(); + trace_recorder.finalize_session( + session, + &final_preview_image, + scroll_capture_final_snapshot, + ); + } - assert_eq!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); - assert!(session.pending_self_capture_exception_window_ids_worker_refresh); - } + #[cfg(target_os = "macos")] + self.set_scroll_overlay_mouse_passthrough(false); + self.windows.clear(); - #[cfg(target_os = "macos")] - #[test] - fn captured_freeze_response_applies_deferred_worker_refresh() { - let monitor = test_monitor(); - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + self.hud_window = None; + self.hud_inner_size_points = None; + self.hud_outer_pos = None; + self.pending_hud_outer_pos = None; + self.loupe_window = None; + self.loupe_inner_size_points = None; + self.loupe_outer_pos = None; + self.pending_loupe_outer_pos = None; + self.toolbar_window = None; + self.scroll_preview_window = None; + self.toolbar_inner_size_points = None; + self.toolbar_outer_pos = None; + self.hud_window_visible = false; + self.toolbar_window_visible = false; + self.toolbar_window_warmup_redraws_remaining = 0; + self.loupe_window_visible = false; + self.loupe_window_warmup_redraws_remaining = 0; + self.scroll_capture = ScrollCaptureState::default(); + self.frozen_capture_source = FrozenCaptureSource::None; + self.cursor_monitor = None; + self.gpu = None; + self.worker = None; + #[cfg(target_os = "macos")] + { + self.live_sample_worker = None; + self.live_sample_stream = None; + } + self.event_loop_phase = OverlayEventLoopPhase::Idle; + self.event_loop_progress_seq = 0; + self.event_loop_last_progress_at = Instant::now(); + self.event_loop_last_progress_window_id = None; + self.event_loop_last_progress_monitor_id = None; + self.event_loop_last_progress_detail = None; + self.event_loop_last_stall_warn_at = None; + self.toolbar_left_button_down = false; + self.toolbar_left_button_went_down = false; + self.toolbar_left_button_went_up = false; + self.toolbar_pointer_local = None; - session.inflight_freeze_capture = Some(monitor); - session.pending_self_capture_exception_window_ids_worker_refresh = true; + self.stop_frozen_selection_drag(); + self.clear_pending_output_actions(); - let control = session.maybe_tick_worker_response_limiter(WorkerResponse::CapturedFreeze { - monitor, - image: test_frozen_image(), - window_image: None, - captured_window_id: None, - }); + tracing::info!( + op = "overlay.exit_end", + exit_kind, + png_bytes_len, + saved_path, + error_message, + "Finished overlay exit cleanup." + ); - assert!(matches!(control, super::OverlayControl::Continue)); - assert_ne!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); - assert!(!session.pending_self_capture_exception_window_ids_worker_refresh); + OverlayControl::Exit(exit) } - #[cfg(target_os = "macos")] - #[test] - fn hit_test_response_applies_deferred_worker_refresh() { - let monitor = test_monitor(); - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); - - session.pending_click_hit_test_request_id = Some(11); - session.pending_self_capture_exception_window_ids_worker_refresh = true; - - let control = session.maybe_tick_worker_response_limiter(WorkerResponse::HitTestWindow { - monitor, - point: GlobalPoint::new(24, 36), - request_id: 11, - hit: None, - }); + fn clear_pending_output_actions(&mut self) { + #[cfg(target_os = "macos")] + { + self.active_ocr_request_id = None; + self.pending_recognize_text = None; + } + self.pending_encode_png = None; + self.pending_png_action = None; + #[cfg(target_os = "macos")] + { + self.ocr_inflight = false; + self.png_encode_inflight = false; + } - assert!(matches!(control, super::OverlayControl::Continue)); - assert_ne!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); - assert!(!session.pending_self_capture_exception_window_ids_worker_refresh); - } + self.focused_window_ids.clear(); - #[cfg(target_os = "macos")] - #[test] - fn window_list_refresh_response_applies_deferred_worker_refresh() { - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); - - session.window_list_refresh_inflight = true; - session.pending_self_capture_exception_window_ids_worker_refresh = true; - - let control = - session.maybe_tick_worker_response_limiter(WorkerResponse::RefreshedWindowList { - snapshot: Arc::new(WindowListSnapshot { - captured_at: Instant::now(), - windows: Arc::new(vec![WindowRect { - window_id: Some(9), - x: 10, - y: 12, - width: 30, - height: 40, - }]), - }), - }); - - assert!(matches!(control, super::OverlayControl::Continue)); - assert_ne!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); - assert!(!session.pending_self_capture_exception_window_ids_worker_refresh); + self.pending_focus_loss_cleanup = false; + self.loupe_activation_key_down = false; + self.keyboard_modifiers = ModifiersState::default(); } +} - #[cfg(target_os = "macos")] - #[test] - fn stale_window_list_refresh_response_is_dropped_after_self_capture_filter_change() { - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); - - session.window_list_snapshot = Some(Arc::new(WindowListSnapshot { - captured_at: Instant::now(), - windows: Arc::new(vec![WindowRect { - window_id: Some(4), - x: 1, - y: 2, - width: 3, - height: 4, - }]), - })); - session.window_list_refresh_inflight = true; - - session.apply_self_capture_exception_window_ids_to_active_streams(); - - assert!(session.window_list_snapshot.is_none()); - assert!(session.drop_next_window_list_refresh_snapshot); - assert!(session.pending_self_capture_exception_window_ids_worker_refresh); - - let control = - session.maybe_tick_worker_response_limiter(WorkerResponse::RefreshedWindowList { - snapshot: Arc::new(WindowListSnapshot { - captured_at: Instant::now(), - windows: Arc::new(vec![WindowRect { - window_id: Some(9), - x: 10, - y: 12, - width: 30, - height: 40, - }]), - }), - }); - - assert!(matches!(control, super::OverlayControl::Continue)); - assert!(session.window_list_snapshot.is_none()); - assert!(!session.window_list_refresh_inflight); - assert!(!session.drop_next_window_list_refresh_snapshot); - assert_ne!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); +impl Default for OverlaySession { + fn default() -> Self { + Self::new() } +} - #[cfg(target_os = "macos")] - #[test] - fn png_error_response_applies_deferred_worker_refresh() { - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); - - session.png_encode_inflight = true; - session.pending_self_capture_exception_window_ids_worker_refresh = true; - - let control = session.maybe_tick_worker_response_limiter(WorkerResponse::Error { - source: WorkerErrorSource::EncodePng, - message: String::from("encode failed"), - }); +#[cfg(target_os = "macos")] +#[derive(Clone, Debug, Eq, PartialEq)] +struct PendingRecognizeTextRequest { + request_id: u64, + image: RgbaImage, +} - assert!(matches!(control, super::OverlayControl::Continue)); - assert_ne!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); - assert!(!session.pending_self_capture_exception_window_ids_worker_refresh); - } +struct InitialSessionRuntime { + live_bg_request_interval: Duration, + window_list_refresh_interval: Duration, + now: Instant, + loupe_sample_side_px: u32, + state: OverlayState, +} - #[cfg(target_os = "macos")] - #[test] - fn capture_monitor_region_error_clears_scroll_capture_inflight_and_pauses_session() { - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.inflight_request_id = Some(41); - session.scroll_capture.inflight_request_observation = - Some(InflightScrollCaptureObservation { - was_observable: true, - external_input_seq: 9, - input_direction: Some(ScrollDirection::Down), - }); - - let control = session.maybe_tick_worker_response_limiter(WorkerResponse::Error { - source: WorkerErrorSource::CaptureMonitorRegion, - message: String::from("capture timed out"), - }); +#[cfg(target_os = "macos")] +#[repr(C)] +struct MacOSCGPoint { + x: f64, + y: f64, +} - assert!(matches!(control, super::OverlayControl::Continue)); - assert_eq!(session.scroll_capture.inflight_request_id, None); - assert_eq!(session.scroll_capture.inflight_request_observation, None); - assert!(session.scroll_capture.paused); - assert_eq!(session.state.error_message.as_deref(), Some("capture timed out")); - } +fn should_request_overlay_redraw_after_surface_skip( + reason: SurfaceFrameSkipReason, + now: Instant, + occluded_redraw_retry_until: &mut Option, +) -> bool { + match reason { + SurfaceFrameSkipReason::Timeout => true, + SurfaceFrameSkipReason::Occluded => match occluded_redraw_retry_until { + Some(deadline) if now >= *deadline => { + *occluded_redraw_retry_until = None; - #[test] - fn downward_frame_motion_commits_even_with_legacy_upward_input_direction() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - ]; - let mut session = OverlaySession::new(); - - session.scroll_capture.active = true; - session.scroll_capture.session = - Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); - session.scroll_capture.input_direction = Some(ScrollDirection::Down); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; - - assert_eq!( - session - .observe_scroll_capture_frame(make_scroll_capture_window(&document, 3, 1, 5)) - .transpose() - .unwrap(), - Some(ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: 1, - }) - ); + false + }, + Some(_) => true, + None => { + *occluded_redraw_retry_until = Some(now + OCCLUDED_FRAME_REDRAW_RETRY_WINDOW); - let height_after_first_append = - session.scroll_capture.session.as_ref().unwrap().export_image().height(); - - session.scroll_capture.input_direction = Some(ScrollDirection::Up); - session.scroll_capture.input_direction_at = Some(Instant::now()); - session.scroll_capture.input_gesture_active = true; - - assert_eq!( - session - .observe_scroll_capture_frame(make_scroll_capture_window(&document, 3, 2, 5)) - .transpose() - .unwrap(), - Some(ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: 1, - }) - ); - assert_eq!( - session.scroll_capture.session.as_ref().unwrap().export_image().height(), - height_after_first_append + 1 - ); + true + }, + }, } +} - #[cfg(target_os = "macos")] - #[test] - fn positive_pixel_delta_maps_to_upward_scroll_capture() { - assert_eq!( - OverlaySession::scroll_capture_direction_from_wheel_delta( - &MouseScrollDelta::PixelDelta(PhysicalPosition::new(0.0, 2.0)) - ), - Some(ScrollDirection::Up) - ); - } +fn frozen_toolbar_needs_new_sample( + last_screen_size_points: Option, + screen_size_points: Vec2, +) -> bool { + match last_screen_size_points { + None => true, + Some(last) => { + let dx = (last.x - screen_size_points.x).abs(); + let dy = (last.y - screen_size_points.y).abs(); - #[cfg(target_os = "macos")] - #[test] - fn macos_scroll_wheel_events_use_hid_system_source_state() { - assert_eq!( - super::macos_hid_event_source_state_id(), - super::KCG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE - ); + dx > 0.5 || dy > 0.5 + }, } +} - #[cfg(target_os = "macos")] - #[test] - fn pixel_delta_residuals_accumulate_until_whole_pixels_emit() { - let mut residual = MacOSScrollPixelResidual::default(); - let first = OverlaySession::normalize_macos_scroll_wheel_delta( - &MouseScrollDelta::PixelDelta(PhysicalPosition::new(0.4, -0.4)), - &mut residual, - ); - let second = OverlaySession::normalize_macos_scroll_wheel_delta( - &MouseScrollDelta::PixelDelta(PhysicalPosition::new(0.7, -0.8)), - &mut residual, - ); - - assert_eq!(first.units, KCG_SCROLL_EVENT_UNIT_PIXEL); - assert_eq!(first.posted_x, 0); - assert_eq!(first.posted_y, 0); - assert!((first.residual.x - 0.4).abs() < f64::EPSILON); - assert!((first.residual.y + 0.4).abs() < f64::EPSILON); - assert_eq!(second.posted_x, 1); - assert_eq!(second.posted_y, -1); - assert!((second.residual.x - 0.1).abs() < 1e-9); - assert!((second.residual.y + 0.2).abs() < 1e-9); - } +fn advance_frozen_toolbar_readiness_sample_state( + toolbar_state: &mut FrozenToolbarState, + screen_rect: Rect, +) -> bool { + let screen_size_points = screen_rect.size(); - #[test] - fn frozen_toolbar_mode_tools_are_identifiable() { - assert!(FrozenToolbarTool::Pointer.is_mode_tool()); - assert!(FrozenToolbarTool::Pen.is_mode_tool()); - assert!(FrozenToolbarTool::Text.is_mode_tool()); - assert!(FrozenToolbarTool::Mosaic.is_mode_tool()); - } + if frozen_toolbar_needs_new_sample( + toolbar_state.layout_last_screen_size_points, + screen_size_points, + ) { + toolbar_state.layout_last_screen_size_points = Some(screen_size_points); + toolbar_state.layout_stable_frames = 0; - #[test] - fn frozen_toolbar_action_tools_are_not_mode_tools() { - assert!(!FrozenToolbarTool::Undo.is_mode_tool()); - assert!(!FrozenToolbarTool::Redo.is_mode_tool()); - assert!(!FrozenToolbarTool::AutoCenter.is_mode_tool()); - assert!(!FrozenToolbarTool::Scroll.is_mode_tool()); - assert!(!FrozenToolbarTool::Copy.is_mode_tool()); - assert!(!FrozenToolbarTool::Save.is_mode_tool()); - #[cfg(target_os = "macos")] - assert!(!FrozenToolbarTool::Ocr.is_mode_tool()); + return false; } + if toolbar_state.layout_stable_frames < 1 { + toolbar_state.layout_stable_frames = toolbar_state.layout_stable_frames.saturating_add(1); - #[test] - fn frozen_toolbar_scroll_tool_uses_scroll_specific_iconography() { - assert_eq!(FrozenToolbarTool::Scroll.label(), "Scroll Capture"); - assert_eq!(FrozenToolbarTool::Scroll.icon(), regular::MOUSE_SCROLL); + return false; } - #[test] - fn frozen_toolbar_export_tools_require_final_capture() { - assert!(!FrozenToolbarTool::Pointer.requires_final_capture()); - assert!(!FrozenToolbarTool::Pen.requires_final_capture()); - assert!(!FrozenToolbarTool::Text.requires_final_capture()); - assert!(!FrozenToolbarTool::Mosaic.requires_final_capture()); - assert!(!FrozenToolbarTool::Undo.requires_final_capture()); - assert!(!FrozenToolbarTool::Redo.requires_final_capture()); - assert!(!FrozenToolbarTool::AutoCenter.requires_final_capture()); - assert!(FrozenToolbarTool::Scroll.requires_final_capture()); - assert!(FrozenToolbarTool::Copy.requires_final_capture()); - assert!(FrozenToolbarTool::Save.requires_final_capture()); - #[cfg(target_os = "macos")] - assert!(FrozenToolbarTool::Ocr.requires_final_capture()); - } + true +} - #[test] - fn frozen_toolbar_selected_mode_uses_fill_without_border() { - for theme in [HudTheme::Dark, HudTheme::Light] { - let style = WindowRenderer::frozen_toolbar_button_style(theme, true, false, true); +fn frozen_toolbar_matches_default_slot(toolbar_pos: Pos2, default_pos: Pos2) -> bool { + let dx = (toolbar_pos.x - default_pos.x).abs(); + let dy = (toolbar_pos.y - default_pos.y).abs(); - assert!(style.bg_color.a() > 0); - assert_eq!(style.border_color, None); - } - } + dx <= TOOLBAR_DEFAULT_SLOT_POSITION_EPSILON_POINTS + && dy <= TOOLBAR_DEFAULT_SLOT_POSITION_EPSILON_POINTS +} - #[test] - fn toolbar_window_hides_until_frozen_pixels_exist() { - let monitor = test_monitor(); - let mut session = OverlaySession::new(); +#[cfg(target_os = "macos")] +fn macos_hid_event_source_state_id() -> u32 { + KCG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE +} - session.state.begin_freeze(monitor); +fn global_to_local(cursor: GlobalPoint, monitor: MonitorRect) -> Option { + let (x, y) = monitor.local_u32(cursor)?; - assert!(session.should_hide_toolbar_window(monitor)); + Some(Pos2::new(x as f32, y as f32)) +} - session.pending_freeze_capture = Some(monitor); +#[cfg(target_os = "macos")] +#[link(name = "CoreGraphics", kind = "framework")] +unsafe extern "C" { + fn CGEventGetLocation(event: CGEventRef) -> MacOSCGPoint; + fn CGEventCreate(source: *const c_void) -> CGEventRef; + fn CGEventSourceCreate(source_state_id: u32) -> CFTypeRef; + fn CGEventCreateScrollWheelEvent2( + source: *const c_void, + units: u32, + wheel_count: u32, + wheel1: i32, + wheel2: i32, + wheel3: i32, + ) -> CGEventRef; + fn CGEventPost(tap_location: u32, event: CGEventRef); + fn CGEventSetLocation(event: CGEventRef, location: MacOSCGPoint); +} - assert!(session.should_hide_toolbar_window(monitor)); +#[cfg(target_os = "macos")] +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + fn CFRelease(obj: CFTypeRef); +} - session.pending_freeze_capture = None; - session.inflight_freeze_capture = Some(monitor); +#[cfg(target_os = "macos")] +fn macos_mouse_location() -> Option { + let event = unsafe { CGEventCreate(ptr::null()) }; - assert!(session.should_hide_toolbar_window(monitor)); + if event.is_null() { + return None; } - #[test] - fn toolbar_window_stays_visible_while_final_capture_is_pending() { - let monitor = test_monitor(); - let mut session = OverlaySession::new(); - - session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); + let point = unsafe { CGEventGetLocation(event) }; - assert!(!session.should_hide_toolbar_window(monitor)); + unsafe { CFRelease(event) }; - session.pending_freeze_capture = Some(monitor); + Some(GlobalPoint::new(point.x as i32, point.y as i32)) +} - assert!(!session.should_hide_toolbar_window(monitor)); +#[cfg(target_os = "macos")] +fn macos_activate_app() { + unsafe { + let app: *mut Object = objc::msg_send![objc::class!(NSApplication), sharedApplication]; - session.pending_freeze_capture = None; - session.inflight_freeze_capture = Some(monitor); + if app.is_null() { + return; + } - assert!(!session.should_hide_toolbar_window(monitor)); + let _: () = objc::msg_send![app, activateIgnoringOtherApps: YES]; } +} - #[test] - fn force_pending_hud_and_loupe_moves_only_during_frozen_transition() { - let monitor = test_monitor(); - let mut session = OverlaySession::new(); - - assert!(!session.should_force_pending_hud_and_loupe_moves()); - - session.state.begin_freeze(monitor); +#[cfg(target_os = "macos")] +fn macos_make_window_key(window: &winit::window::Window) { + let Ok(handle) = window.window_handle() else { + return; + }; + let RawWindowHandle::AppKit(appkit) = handle.as_raw() else { + return; + }; + let ns_view = appkit.ns_view.as_ptr().cast::(); - assert!(session.should_force_pending_hud_and_loupe_moves()); + unsafe { + let ns_window: *mut Object = objc::msg_send![ns_view, window]; - session.state.finish_freeze(monitor, test_frozen_image()); + if ns_window.is_null() { + return; + } - session.authoritative_frozen_capture_ready = true; + let nil: *mut Object = ptr::null_mut(); + let _: () = objc::msg_send![ns_window, makeKeyAndOrderFront: nil]; + } - assert!(!session.should_force_pending_hud_and_loupe_moves()); + window.focus_window(); +} - session.inflight_freeze_capture = Some(monitor); +#[cfg(target_os = "macos")] +fn macos_post_scroll_wheel_event( + delta: MacOSScrollWheelEvent, + target_point: GlobalPoint, +) -> Result<()> { + let units = delta.units; + let wheel1 = delta.posted_y; + let wheel2 = delta.posted_x; - assert!(session.should_force_pending_hud_and_loupe_moves()); + if wheel1 == 0 && wheel2 == 0 { + return Ok(()); + } - session.state.mode = OverlayMode::Live; + let source = unsafe { CGEventSourceCreate(macos_hid_event_source_state_id()) }; - assert!(!session.should_force_pending_hud_and_loupe_moves()); + if source.is_null() { + return Err(eyre::eyre!("failed to create macOS scroll wheel event source")); } - #[test] - fn tinted_hud_body_fill_amount_zero_keeps_base_fill() { - for theme in [HudTheme::Dark, HudTheme::Light] { - let base_fill = hud_helpers::hud_body_fill_srgba8(theme, false); - let no_tint = - WindowRenderer::tinted_hud_body_fill(theme, false, false, 1.0, 0.0, 0.585); - - assert_eq!(no_tint.r(), base_fill[0]); - assert_eq!(no_tint.g(), base_fill[1]); - assert_eq!(no_tint.b(), base_fill[2]); - assert_eq!(no_tint.a(), 255); + let wheel_count = if wheel2 != 0 { 2 } else { 1 }; + let event = + unsafe { CGEventCreateScrollWheelEvent2(source, units, wheel_count, wheel1, wheel2, 0) }; + + if event.is_null() { + unsafe { + CFRelease(source); } + + return Err(eyre::eyre!("failed to create macOS scroll wheel event")); } - #[test] - fn tinted_hud_body_fill_100pct_tint_is_visibly_blue() { - let dark_min_delta: u16 = 57; - let light_min_delta: u16 = 24; - let sky_tint = 0.585; - - for theme in [HudTheme::Dark, HudTheme::Light] { - let base_fill = - WindowRenderer::tinted_hud_body_fill(theme, false, false, 1.0, 0.0, sky_tint); - let tinted_fill = - WindowRenderer::tinted_hud_body_fill(theme, false, false, 1.0, 1.0, sky_tint); - let rgb_delta = u16::from(base_fill.r()).abs_diff(u16::from(tinted_fill.r())) - + u16::from(base_fill.g()).abs_diff(u16::from(tinted_fill.g())) - + u16::from(base_fill.b()).abs_diff(u16::from(tinted_fill.b())); - let min_delta = - if matches!(theme, HudTheme::Dark) { dark_min_delta } else { light_min_delta }; - - assert!( - rgb_delta >= min_delta, - "expected minimum tint delta >= {min_delta}, got {rgb_delta}" - ); - } + unsafe { + CGEventSetLocation( + event, + MacOSCGPoint { x: f64::from(target_point.x), y: f64::from(target_point.y) }, + ); + CGEventPost(KCG_HID_EVENT_TAP, event); + CFRelease(event); + CFRelease(source); } - #[test] - fn tinted_hud_body_fill_preserves_alpha() { - for theme in [HudTheme::Dark, HudTheme::Light] { - let tint_hue = 0.585; - let opaque = - WindowRenderer::tinted_hud_body_fill(theme, false, true, 0.25, 1.0, tint_hue); - let translucent = - WindowRenderer::tinted_hud_body_fill(theme, false, false, 0.33, 1.0, tint_hue); - - assert_eq!(opaque.a(), 255); - assert_eq!(translucent.a(), (0.33_f32 * 255.0).round().clamp(0.0, 255.0) as u8); + Ok(()) +} + +#[cfg(target_os = "macos")] +fn macos_configure_overlay_window_mouse_moved_events(window: &winit::window::Window) { + let Ok(handle) = window.window_handle() else { + return; + }; + let RawWindowHandle::AppKit(appkit) = handle.as_raw() else { + return; + }; + let ns_view = appkit.ns_view.as_ptr().cast::(); + + unsafe { + let ns_window: *mut Object = objc::msg_send![ns_view, window]; + + if ns_window.is_null() { + return; } + + let _: () = objc::msg_send![ns_window, setOpaque: false]; + let _: () = objc::msg_send![ns_window, setHasShadow: false]; + let sharing_type_none = 0_u64; + let _: () = objc::msg_send![ns_window, setSharingType: sharing_type_none]; + let clear: *mut Object = objc::msg_send![objc::class!(NSColor), clearColor]; + let _: () = objc::msg_send![ns_window, setBackgroundColor: clear]; + let _: () = objc::msg_send![ns_window, setLevel: MACOS_OVERLAY_WINDOW_LEVEL]; + let _: () = objc::msg_send![ns_window, setAcceptsMouseMovedEvents: YES]; } +} + +#[cfg(target_os = "macos")] +fn macos_configure_hud_window( + window: &winit::window::Window, + blur_enabled: bool, + blur_amount: f32, + corner_radius_points: Option, +) { + let Ok(handle) = window.window_handle() else { + return; + }; + let RawWindowHandle::AppKit(appkit) = handle.as_raw() else { + return; + }; + let ns_view = appkit.ns_view.as_ptr().cast::(); - #[test] - fn tinted_hud_body_fill_blur_active_enforces_min_opacity() { - for theme in [HudTheme::Dark, HudTheme::Light] { - let tint_hue = 0.585; - let fill = WindowRenderer::tinted_hud_body_fill(theme, true, false, 0.0, 0.0, tint_hue); - let expected = - (hud_helpers::hud_blur_tint_alpha(theme) * 255.0).round().clamp(0.0, 255.0) as u8; + unsafe { + let ns_window: *mut Object = objc::msg_send![ns_view, window]; - assert_eq!(fill.a(), expected); + if ns_window.is_null() { + return; } - } - #[test] - fn frozen_toolbar_clamps_floating_position() { - let monitor = Rect::from_min_size(Pos2::new(-200.0, -100.0), Vec2::new(500.0, 400.0)); - let toolbar_size = Vec2::new(220.0, 42.0); - let clamped = WindowRenderer::clamp_toolbar_position( - monitor, - toolbar_size, - Pos2::new(-400.0, -240.0), - TOOLBAR_SCREEN_MARGIN_PX, - TOOLBAR_SCREEN_MARGIN_PX, - ); + // winit exposes blur as a boolean. We also set an explicit radius so we can drive it from + // settings (this uses the same private CGS API that winit uses internally). + { + #[link(name = "CoreGraphics", kind = "framework")] + unsafe extern "C" { + fn CGSMainConnectionID() -> *mut c_void; - assert_eq!(clamped.x, monitor.min.x + TOOLBAR_SCREEN_MARGIN_PX); - assert_eq!(clamped.y, monitor.min.y + TOOLBAR_SCREEN_MARGIN_PX); - } + fn CGSSetWindowBackgroundBlurRadius( + connection_id: *mut c_void, + window_id: isize, + radius: i64, + ) -> i32; + } - #[test] - fn interactive_repaint_fps_uses_known_lower_monitor_refresh() { - assert_eq!(OverlaySession::interactive_repaint_fps(Some(60.0), Some(144.0)), 60.0); - assert_eq!(OverlaySession::interactive_repaint_fps(Some(75.0), Some(120.0)), 75.0); - } + let amount = blur_amount.clamp(0.0, 1.0); + let radius = if blur_enabled { + // Map the slider linearly (0..=1) to the native blur radius. + // Keep the upper bound conservative; CGS blur radius gets strong quickly. + let max_radius = 12.0; - #[test] - fn interactive_repaint_fps_caps_known_higher_refresh_to_contract_limit() { - assert_eq!(OverlaySession::interactive_repaint_fps(Some(144.0), Some(60.0)), 120.0); - assert_eq!(OverlaySession::interactive_repaint_fps(Some(240.0), None), 120.0); - } + (amount * max_radius).round().clamp(0.0, 200.0) as i64 + } else { + 0 + }; + let window_number: isize = objc::msg_send![ns_window, windowNumber]; + let _ = CGSSetWindowBackgroundBlurRadius(CGSMainConnectionID(), window_number, radius); + } - #[test] - fn interactive_repaint_fps_falls_back_to_known_or_default_cap() { - assert_eq!(OverlaySession::interactive_repaint_fps(None, Some(90.0)), 90.0); - assert_eq!(OverlaySession::interactive_repaint_fps(None, Some(144.0)), 120.0); - assert_eq!(OverlaySession::interactive_repaint_fps(None, None), 120.0); - } + let _: () = objc::msg_send![ns_window, setOpaque: false]; + let _: () = objc::msg_send![ns_window, setHasShadow: false]; + let _: () = objc::msg_send![ns_window, setAcceptsMouseMovedEvents: YES]; + let _: () = objc::msg_send![ns_window, setLevel: MACOS_HUD_WINDOW_LEVEL]; + let sharing_type_none = 0_u64; + let _: () = objc::msg_send![ns_window, setSharingType: sharing_type_none]; + let clear: *mut Object = objc::msg_send![objc::class!(NSColor), clearColor]; + let _: () = objc::msg_send![ns_window, setBackgroundColor: clear]; + let content_view: *mut Object = objc::msg_send![ns_window, contentView]; - #[test] - fn occluded_surface_skip_requests_redraw_until_retry_window_expires() { - let now = Instant::now(); - let mut retry_until = None; + if content_view.is_null() { + return; + } - assert!(overlay::should_request_overlay_redraw_after_surface_skip( - SurfaceFrameSkipReason::Occluded, - now, - &mut retry_until, - )); - assert_eq!(retry_until, Some(now + OCCLUDED_FRAME_REDRAW_RETRY_WINDOW)); - assert!(overlay::should_request_overlay_redraw_after_surface_skip( - SurfaceFrameSkipReason::Occluded, - now + Duration::from_millis(500), - &mut retry_until, - )); - assert!(!overlay::should_request_overlay_redraw_after_surface_skip( - SurfaceFrameSkipReason::Occluded, - now + OCCLUDED_FRAME_REDRAW_RETRY_WINDOW, - &mut retry_until, - )); - assert_eq!(retry_until, None); - } + let _: () = objc::msg_send![content_view, setWantsLayer: YES]; + let layer: *mut Object = objc::msg_send![content_view, layer]; - #[test] - fn timeout_surface_skip_always_requests_redraw_without_touching_occluded_retry_window() { - let now = Instant::now(); - let retry_deadline = now + Duration::from_millis(250); - let mut retry_until = Some(retry_deadline); + if layer.is_null() { + return; + } - assert!(overlay::should_request_overlay_redraw_after_surface_skip( - SurfaceFrameSkipReason::Timeout, - now, - &mut retry_until, - )); - assert_eq!(retry_until, Some(retry_deadline)); + // Round the window itself so native blur doesn't show a rectangular boundary. + let scale = window.scale_factor().max(1.0); + let size = window.inner_size(); + let height_points = (size.height as f64) / scale; + let radius = corner_radius_points.unwrap_or(height_points * 0.5); + let _: () = objc::msg_send![layer, setCornerRadius: radius]; + let _: () = objc::msg_send![layer, setMasksToBounds: YES]; } } + +#[cfg(test)] +mod tests; diff --git a/packages/rsnap-overlay/src/overlay/aux_window_runtime.rs b/packages/rsnap-overlay/src/overlay/aux_window_runtime.rs new file mode 100644 index 00000000..421ca7de --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/aux_window_runtime.rs @@ -0,0 +1,422 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +impl OverlaySession { + pub(super) fn live_loupe_uses_hud_window(&self) -> bool { + false + } + + pub(super) fn live_loupe_renders_in_hud_window(&self) -> bool { + self.live_loupe_uses_hud_window() && self.state.alt_held + } + + pub(super) fn maybe_tick_loupe_window_warmup_redraw(&mut self) { + if self.loupe_window_warmup_redraws_remaining == 0 { + return; + } + if !matches!(self.state.mode, OverlayMode::Frozen) + || !self.loupe_window_visible + || self.state.frozen_image.is_none() + || self.state.monitor.is_none() + { + self.loupe_window_warmup_redraws_remaining = 0; + + return; + } + + self.loupe_window_warmup_redraws_remaining = + self.loupe_window_warmup_redraws_remaining.saturating_sub(1); + + self.request_redraw_loupe_window(); + self.schedule_egui_repaint_after(self.repaint_interval_for_monitor(self.state.monitor)); + } + + pub(super) fn maybe_start_loupe_window_warmup_redraw(&mut self) { + if self.loupe_window_warmup_redraws_remaining > 0 { + return; + } + if !matches!(self.state.mode, OverlayMode::Frozen) + || !self.state.alt_held + || !self.loupe_window_visible + || self.state.frozen_image.is_none() + || self.state.monitor.is_none() + { + return; + } + + self.loupe_window_warmup_redraws_remaining = LOUPE_WINDOW_WARMUP_REDRAWS; + } + + pub(super) fn reset_loupe_window_warmup_redraws(&mut self) { + self.loupe_window_warmup_redraws_remaining = 0; + } + + /// Advances periodic session work before the event loop goes idle. + pub fn about_to_wait(&mut self) -> OverlayControl { + let now = Instant::now(); + + self.maybe_log_event_loop_stall(now); + self.mark_progress(OverlayEventLoopPhase::AboutToWait); + self.maybe_clear_loupe_activation_after_focus_loss(); + self.maybe_request_keepalive_redraw(); + self.maybe_keep_selection_flow_repaint(); + self.maybe_keep_frozen_capture_redraw(); + self.maybe_tick_toolbar_window_warmup_redraw(); + self.maybe_tick_loupe_window_warmup_redraw(); + self.maybe_tick_live_cursor_tracking(); + self.maybe_tick_live_sampling(); + self.maybe_tick_frozen_cursor_tracking(); + self.maybe_apply_pending_hud_and_loupe_moves(); + self.maybe_tick_scroll_capture(); + self.maybe_keep_live_cursor_sample_redraw(); + + self.drain_worker_responses() + } + + pub(super) fn mark_progress(&mut self, phase: OverlayEventLoopPhase) { + self.mark_progress_with_detail(phase, None); + } + + pub(super) fn mark_progress_with_detail( + &mut self, + phase: OverlayEventLoopPhase, + detail: Option<&'static str>, + ) { + self.event_loop_phase = phase; + self.event_loop_last_progress_detail = detail; + self.event_loop_progress_seq = self.event_loop_progress_seq.saturating_add(1); + self.event_loop_last_progress_at = Instant::now(); + } + + pub(super) fn maybe_log_event_loop_stall(&mut self, now: Instant) { + let stall = now.duration_since(self.event_loop_last_progress_at); + + if stall < OVERLAY_EVENT_LOOP_STALL_THRESHOLD { + return; + } + if self + .event_loop_last_stall_warn_at + .is_none_or(|last| now.duration_since(last) >= SLOW_OP_WARN_INTERVAL) + { + let _ = self.event_loop_last_stall_warn_at.insert(now); + + tracing::warn!( + op = "overlay.event_loop_stall", + stall_ms = stall.as_millis(), + phase = %self.event_loop_phase.as_str(), + progress_seq = self.event_loop_progress_seq, + mode = ?self.state.mode, + window_id = ?self.event_loop_last_progress_window_id, + monitor_id = ?self.event_loop_last_progress_monitor_id, + detail = ?self.event_loop_last_progress_detail, + "Event loop stalled" + ); + } + } + + pub(super) fn window_event_kind(event: &WindowEvent) -> &'static str { + match event { + WindowEvent::ActivationTokenDone { .. } => "activation_token_done", + WindowEvent::CloseRequested => "close_requested", + WindowEvent::Destroyed => "destroyed", + WindowEvent::DroppedFile(_) => "dropped_file", + WindowEvent::HoveredFile(_) => "hovered_file", + WindowEvent::HoveredFileCancelled => "hovered_file_cancelled", + WindowEvent::Focused(_) => "focused", + WindowEvent::Moved(_) => "moved", + WindowEvent::Resized(_) => "resized", + WindowEvent::ScaleFactorChanged { .. } => "scale_factor_changed", + WindowEvent::Ime(_) => "ime", + WindowEvent::CursorEntered { .. } => "cursor_entered", + WindowEvent::CursorLeft { .. } => "cursor_left", + WindowEvent::CursorMoved { .. } => "cursor_moved", + WindowEvent::MouseWheel { .. } => "mouse_wheel", + WindowEvent::MouseInput { .. } => "mouse_input", + WindowEvent::PinchGesture { .. } => "pinch_gesture", + WindowEvent::PanGesture { .. } => "pan_gesture", + WindowEvent::DoubleTapGesture { .. } => "double_tap_gesture", + WindowEvent::RotationGesture { .. } => "rotation_gesture", + WindowEvent::TouchpadPressure { .. } => "touchpad_pressure", + WindowEvent::AxisMotion { .. } => "axis_motion", + WindowEvent::Touch(_) => "touch", + WindowEvent::ThemeChanged(_) => "theme_changed", + WindowEvent::KeyboardInput { .. } => "keyboard_input", + WindowEvent::ModifiersChanged(_) => "modifiers_changed", + WindowEvent::Occluded(_) => "occluded", + WindowEvent::RedrawRequested => "redraw_requested", + } + } + + pub(super) fn maybe_keep_live_cursor_sample_redraw(&mut self) { + if !matches!(self.state.mode, OverlayMode::Live) { + return; + } + if self.latest_live_cursor_sample_request_id.is_none() { + return; + } + if !self.live_sample_request_pending() { + return; + } + + self.schedule_egui_repaint_after( + self.repaint_interval_for_monitor(self.active_cursor_monitor()), + ); + } + + pub(super) fn maybe_keep_selection_flow_repaint(&self) { + if !self.is_active() || !self.config.selection_flow_enabled { + return; + } + + let keep_repaint = match self.state.mode { + OverlayMode::Live => self.live_overlay_selection_flow_repaint_active(), + OverlayMode::Frozen => self.state.frozen_capture_rect.is_some(), + }; + + if keep_repaint { + let monitor = match self.state.mode { + OverlayMode::Live => self.active_cursor_monitor(), + OverlayMode::Frozen => self.state.monitor, + }; + let repaint_interval = self.selection_flow_repaint_interval(monitor); + + if let Some(monitor) = monitor { + self.request_redraw_for_monitor(monitor); + } else { + self.request_redraw_all(); + } + + self.schedule_egui_repaint_after(repaint_interval); + } + } + + pub(super) fn live_overlay_selection_flow_repaint_active(&self) -> bool { + if !self.config.selection_flow_enabled { + return false; + } + + self.state.hovered_window_rect.is_some_and(|hovered| { + self.active_cursor_monitor().is_some_and(|monitor| hovered.monitor_id == monitor.id) + }) + } + + pub(super) fn live_overlay_redraw_needed_for_cursor_update( + old_monitor: Option, + monitor: MonitorRect, + previous_drag_rect: Option, + next_drag_rect: Option, + ) -> bool { + old_monitor != Some(monitor) || previous_drag_rect != next_drag_rect + } + + pub(super) fn live_hud_redraw_needed_for_cursor_update( + old_cursor: Option, + cursor: GlobalPoint, + old_monitor: Option, + monitor: MonitorRect, + ) -> bool { + old_cursor != Some(cursor) || old_monitor != Some(monitor) + } + + pub(super) fn repaint_interval_for_monitor(&self, monitor: Option) -> Duration { + let monitor_fps = monitor + .and_then(|target| { + self.windows.values().find_map(|window| { + (target == window.monitor).then_some(window.refresh_rate_millihertz) + }) + }) + .flatten() + .and_then(|hz| { + let fps = (hz as f32) / 1_000.0; + + if fps.is_finite() && fps > 0.0 { Some(fps) } else { None } + }); + let fallback_fps = self + .windows + .values() + .filter_map(|window| window.refresh_rate_millihertz) + .filter_map(|hz| { + let fps = (hz as f32) / 1_000.0; + + if fps.is_finite() && fps > 0.0 { Some(fps) } else { None } + }) + .max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + let fps = Self::interactive_repaint_fps(monitor_fps, fallback_fps); + + Duration::from_secs_f32(1.0 / fps) + } + + pub(super) fn interactive_repaint_fps( + monitor_fps: Option, + fallback_fps: Option, + ) -> f32 { + monitor_fps + .or(fallback_fps) + .map_or(INTERACTIVE_REPAINT_FPS_CAP, |fps| fps.min(INTERACTIVE_REPAINT_FPS_CAP)) + } + + pub(super) fn selection_flow_repaint_interval(&self, monitor: Option) -> Duration { + self.repaint_interval_for_monitor(monitor) + } + + pub(super) fn frozen_cursor_tracking_interval(&self, monitor: Option) -> Duration { + self.repaint_interval_for_monitor(monitor) + } + + /// Returns the active repaint cadence that keeps interactive overlays responsive. + pub fn interactive_wait_interval(&self) -> Duration { + let monitor = if self.scroll_capture.active { + self.scroll_capture.monitor.or(self.state.monitor) + } else { + self.active_cursor_monitor() + }; + + self.repaint_interval_for_monitor(monitor) + } + + pub(super) fn live_sample_request_pending(&self) -> bool { + self.latest_live_cursor_sample_request_id.is_some() + && self.applied_live_cursor_sample_request_id + != self.latest_live_cursor_sample_request_id + } + + pub(super) fn note_live_cursor_sample_request_started(&mut self, request_id: u64) { + self.live_cursor_sample_request_id = request_id; + self.latest_live_cursor_sample_request_id = Some(request_id); + self.latest_live_cursor_sample_requested_at = Some(Instant::now()); + } + + #[cfg(target_os = "macos")] + pub(super) fn finish_sync_live_cursor_sample_attempt(&mut self, request_id: u64) { + // Synchronous latest-frame reads on the current thread either produce a sample now or miss + // now. They must not leave async-style "pending" bookkeeping behind. + + debug_assert_eq!(self.latest_live_cursor_sample_request_id, Some(request_id)); + + self.applied_live_cursor_sample_request_id = Some(request_id); + } + + pub(super) fn maybe_apply_pending_hud_and_loupe_moves(&mut self) { + let now = Instant::now(); + + self.maybe_apply_pending_hud_window_move(now); + self.maybe_apply_pending_loupe_window_move(now); + } + + pub(super) fn maybe_apply_pending_hud_window_move(&mut self, now: Instant) { + self.apply_pending_hud_window_move(now, false); + } + + pub(super) fn force_apply_pending_hud_window_move(&mut self) { + self.apply_pending_hud_window_move(Instant::now(), true); + } + + pub(super) fn apply_pending_hud_window_move(&mut self, now: Instant, force: bool) { + let Some(desired) = self.pending_hud_outer_pos else { + return; + }; + let elapsed = now.duration_since(self.last_hud_window_move_at); + let interval = self + .repaint_interval_for_monitor(self.active_cursor_monitor()) + .max(HUD_LOUPE_MOVE_INTERVAL_MIN); + + if !force && elapsed < interval { + let delay = interval.saturating_sub(elapsed); + + self.schedule_egui_repaint_after(delay); + + return; + } + + let Some(hud_window) = self.hud_window.as_ref() else { + return; + }; + let started_at = Instant::now(); + + hud_window + .window + .set_outer_position(LogicalPosition::new(desired.x as f64, desired.y as f64)); + + let elapsed = started_at.elapsed(); + + self.slow_op_logger.warn_if_slow( + "overlay.hud_window_set_outer_position", + elapsed, + SLOW_OP_WARN_OUTER_POSITION, + || format!("window_id={:?} pos=({}, {})", hud_window.window.id(), desired.x, desired.y), + ); + + self.pending_hud_outer_pos = None; + self.last_hud_window_move_at = now; + } + + pub(super) fn force_apply_pending_hud_and_loupe_moves(&mut self) { + self.force_apply_pending_hud_window_move(); + self.force_apply_pending_loupe_window_move(); + } + + pub(super) fn maybe_apply_pending_loupe_window_move(&mut self, now: Instant) { + self.apply_pending_loupe_window_move(now, false); + } + + pub(super) fn force_apply_pending_loupe_window_move(&mut self) { + self.apply_pending_loupe_window_move(Instant::now(), true); + } + + pub(super) fn apply_pending_loupe_window_move(&mut self, now: Instant, force: bool) { + let Some(desired) = self.pending_loupe_outer_pos else { + return; + }; + let elapsed = now.duration_since(self.last_loupe_window_move_at); + let interval = self + .repaint_interval_for_monitor(self.active_cursor_monitor()) + .max(HUD_LOUPE_MOVE_INTERVAL_MIN); + + if !force && elapsed < interval { + let delay = interval.saturating_sub(elapsed); + + self.schedule_egui_repaint_after(delay); + + return; + } + + let Some(loupe_window) = self.loupe_window.as_ref() else { + return; + }; + let started_at = Instant::now(); + + loupe_window + .window + .set_outer_position(LogicalPosition::new(desired.x as f64, desired.y as f64)); + + let elapsed = started_at.elapsed(); + + self.slow_op_logger.warn_if_slow( + "overlay.loupe_window_set_outer_position", + elapsed, + SLOW_OP_WARN_OUTER_POSITION, + || { + format!( + "window_id={:?} pos=({}, {})", + loupe_window.window.id(), + desired.x, + desired.y + ) + }, + ); + + self.pending_loupe_outer_pos = None; + self.last_loupe_window_move_at = now; + } + + pub(super) fn schedule_egui_repaint_after(&self, delay: Duration) { + let deadline = Instant::now() + delay; + let mut next_repaint = + self.egui_repaint_deadline.lock().unwrap_or_else(|err| err.into_inner()); + + if next_repaint.is_none_or(|next| deadline < next) { + *next_repaint = Some(deadline); + } + } +} diff --git a/packages/rsnap-overlay/src/overlay/capture_window_runtime.rs b/packages/rsnap-overlay/src/overlay/capture_window_runtime.rs new file mode 100644 index 00000000..ca7030c5 --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/capture_window_runtime.rs @@ -0,0 +1,85 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +impl OverlaySession { + pub(super) fn update_cursor_state(&mut self, monitor: MonitorRect, cursor: GlobalPoint) { + self.cursor_monitor = Some(monitor); + self.state.cursor = Some(cursor); + + match self.state.mode { + OverlayMode::Live => {}, + OverlayMode::Frozen => { + if self.state.frozen_image.is_none() { + return; + } + + let frozen_monitor = self.state.monitor; + + self.state.rgb = + image_helpers::frozen_rgb(&self.state.frozen_image, frozen_monitor, cursor); + self.state.loupe = if self.state.alt_held { + image_helpers::frozen_loupe_patch( + &self.state.frozen_image, + frozen_monitor, + cursor, + self.loupe_patch_width_px, + self.loupe_patch_height_px, + ) + .map(|patch| crate::state::LoupeSample { center: cursor, patch }) + } else { + None + }; + }, + } + } + + #[cfg(not(target_os = "macos"))] + pub(super) fn hide_capture_windows(&mut self) { + self.capture_windows_hidden = true; + + if let Some(hud_window) = &self.hud_window { + hud_window.window.set_visible(false); + } + + self.hud_window_visible = false; + + if let Some(loupe_window) = &self.loupe_window { + loupe_window.window.set_visible(false); + } + } + + pub(super) fn restore_capture_windows_visibility(&mut self) { + if !self.capture_windows_hidden { + return; + } + + self.capture_windows_hidden = false; + #[cfg(not(target_os = "macos"))] + { + if let Some(hud_window) = &self.hud_window { + hud_window.window.set_visible(true); + } + + self.hud_window_visible = true; + } + + #[cfg(not(target_os = "macos"))] + if let Some(loupe_window) = &self.loupe_window { + loupe_window.window.set_visible(self.state.alt_held); + } + } + + #[cfg(not(target_os = "macos"))] + pub(super) fn raise_hud_windows(&self) { + if let Some(hud_window) = self.hud_window.as_ref() { + hud_window.window.focus_window(); + } + + if self.state.alt_held + && let Some(loupe_window) = self.loupe_window.as_ref() + { + loupe_window.window.focus_window(); + } + } +} diff --git a/packages/rsnap-overlay/src/overlay/config_runtime.rs b/packages/rsnap-overlay/src/overlay/config_runtime.rs new file mode 100644 index 00000000..11469bcd --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/config_runtime.rs @@ -0,0 +1,276 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +impl OverlaySession { + /// Applies updated runtime configuration to an existing session. + pub fn set_config(&mut self, config: OverlayConfig) { + let prev = self.config.clone(); + let previous_loupe_patch = self.loupe_patch_width_px; + let loupe_sample_side = Self::normalized_loupe_sample_side_px(config.loupe_sample_side_px); + #[cfg(target_os = "macos")] + let self_capture_exception_window_ids_changed = + prev.self_capture_exception_window_ids != config.self_capture_exception_window_ids; + + self.config = config; + self.loupe_patch_width_px = loupe_sample_side; + self.loupe_patch_height_px = loupe_sample_side; + self.state.loupe_patch_side_px = loupe_sample_side; + + let patch_changed = self.loupe_patch_width_px != previous_loupe_patch; + + if patch_changed { + self.state.loupe = None; + } + if !self.is_active() { + return; + } + + self.configure_hud_windows_for_config(); + + let prev_fake_blur = prev.show_hud_blur && !cfg!(target_os = "macos"); + let new_fake_blur = self.use_fake_hud_blur(); + + self.handle_fake_hud_blur_toggle(prev_fake_blur, new_fake_blur); + + if patch_changed { + self.request_loupe_sample_for_patch_change(); + } + #[cfg(target_os = "macos")] + if self_capture_exception_window_ids_changed { + self.apply_self_capture_exception_window_ids_to_active_streams(); + } + + self.request_redraw_all(); + } + + #[cfg(target_os = "macos")] + pub(super) fn apply_self_capture_exception_window_ids_to_active_streams(&mut self) { + self.invalidate_window_list_snapshot_for_self_capture_exception_window_ids_change(); + + self.live_sample_stream = Some(MacLiveFrameStream::with_self_capture_exception_window_ids( + self.config.self_capture_exception_window_ids.clone(), + )); + + if self.scroll_capture.active { + self.scroll_capture.live_stream = if self.should_use_scroll_capture_worker_sampling() { + None + } else { + match ( + self.scroll_capture.capture_rect_points, + self.scroll_capture.capture_rect_pixels, + ) { + (Some(capture_rect_points), Some(capture_rect_pixels)) => { + Some(MacLiveFrameStream::with_scroll_capture_region_and_waker( + self.config.self_capture_exception_window_ids.clone(), + capture_rect_points, + capture_rect_pixels, + self.scroll_frame_waker.clone(), + )) + }, + _ => { + Some(MacLiveFrameStream::with_self_capture_exception_window_ids_and_waker( + self.config.self_capture_exception_window_ids.clone(), + self.scroll_frame_waker.clone(), + )) + }, + } + }; + + self.scroll_capture.live_stream_backlog.clear(); + + self.scroll_capture.last_stream_frame_seq = 0; + self.scroll_capture.last_stream_frame_fingerprint = None; + self.scroll_capture.consecutive_identical_stream_frames = 0; + self.scroll_capture.last_consumed_stream_frame_captured_at = None; + self.scroll_capture.last_stream_event_at = None; + self.scroll_capture.last_stream_poll_at = None; + self.scroll_capture.pending_post_stall_burst_after_seq = None; + self.scroll_capture.live_stream_stale_grace = None; + self.scroll_capture.last_duplicate_stream_refresh_at = None; + } + + self.refresh_active_worker_for_self_capture_exception_window_ids_if_safe(); + } + + #[cfg(target_os = "macos")] + fn invalidate_window_list_snapshot_for_self_capture_exception_window_ids_change(&mut self) { + self.window_list_snapshot = None; + self.drop_next_window_list_refresh_snapshot = self.window_list_refresh_inflight; + self.last_window_list_refresh_request_at = + Instant::now() - self.window_list_refresh_interval; + } + + #[cfg(target_os = "macos")] + fn refresh_active_worker_for_self_capture_exception_window_ids_if_safe(&mut self) { + if self.has_inflight_worker_response_state() { + self.pending_self_capture_exception_window_ids_worker_refresh = true; + + return; + } + + self.rebuild_active_worker_for_self_capture_exception_window_ids(); + } + + #[cfg(target_os = "macos")] + pub(super) fn maybe_apply_pending_self_capture_exception_window_ids_worker_refresh(&mut self) { + if self.pending_self_capture_exception_window_ids_worker_refresh + && !self.has_inflight_worker_response_state() + { + self.rebuild_active_worker_for_self_capture_exception_window_ids(); + } + } + + #[cfg(target_os = "macos")] + fn rebuild_active_worker_for_self_capture_exception_window_ids(&mut self) { + self.worker = Some(OverlayWorker::new( + backend::default_capture_backend_with_self_capture_exception_window_ids( + self.config.self_capture_exception_window_ids.clone(), + ), + self.response_waker.clone(), + )); + self.pending_self_capture_exception_window_ids_worker_refresh = false; + } + + #[cfg(target_os = "macos")] + pub(super) fn has_inflight_worker_response_state(&self) -> bool { + self.inflight_freeze_capture.is_some() + || self.pending_click_hit_test_request_id.is_some() + || self.window_list_refresh_inflight + || self.ocr_inflight + || self.png_encode_inflight + } + + fn configure_hud_windows_for_config(&mut self) { + if let Some(hud_window) = self.hud_window.as_ref() { + let window = Arc::clone(&hud_window.window); + + self.configure_hud_window_common(window.as_ref(), None); + } + if let Some(loupe_window) = self.loupe_window.as_ref() { + let window = Arc::clone(&loupe_window.window); + + self.configure_hud_window_common( + window.as_ref(), + Some(LOUPE_TILE_CORNER_RADIUS_POINTS), + ); + } + if let Some(toolbar_window) = self.toolbar_window.as_ref() { + let window = Arc::clone(&toolbar_window.window); + + self.configure_hud_window_common( + window.as_ref(), + Some(f64::from(HUD_PILL_CORNER_RADIUS_POINTS)), + ); + } + } + + pub(super) fn configure_hud_window_common( + &mut self, + window: &winit::window::Window, + corner_radius: Option, + ) { + window.set_transparent(true); + + #[cfg(not(target_os = "macos"))] + let _ = corner_radius; + + #[cfg(not(target_os = "macos"))] + window.set_blur(self.config.show_hud_blur); + #[cfg(target_os = "macos")] + self.configure_macos_hud_window_cached( + window, + self.macos_hud_window_blur_enabled(), + self.config.hud_fog_amount, + corner_radius, + ); + } + + #[cfg(target_os = "macos")] + fn configure_macos_hud_window_cached( + &mut self, + window: &winit::window::Window, + blur_enabled: bool, + blur_amount: f32, + corner_radius: Option, + ) { + let effective_corner_radius = corner_radius.unwrap_or_else(|| { + let scale = window.scale_factor().max(1.0); + let size = window.inner_size(); + + ((size.height as f64) / scale) * 0.5 + }); + let desired = + MacOSHudWindowConfigState::new(blur_enabled, blur_amount, effective_corner_radius); + + if self + .macos_hud_window_config_cache + .get(&window.id()) + .is_some_and(|cached| cached.same(&desired)) + { + return; + } + + let started_at = Instant::now(); + + macos_configure_hud_window( + window, + blur_enabled, + blur_amount, + Some(effective_corner_radius), + ); + + let elapsed = started_at.elapsed(); + + self.slow_op_logger.warn_if_slow( + "overlay.macos_hud_window_configure", + elapsed, + SLOW_OP_WARN_HUD_CONFIG, + || { + format!( + "window_id={:?} blur_enabled={} blur_amount={} corner_radius={effective_corner_radius}", + window.id(), + blur_enabled, + blur_amount, + ) + }, + ); + + let _ = self.macos_hud_window_config_cache.insert(window.id(), desired); + } + + fn handle_fake_hud_blur_toggle(&mut self, prev_fake_blur: bool, new_fake_blur: bool) { + if prev_fake_blur == new_fake_blur { + return; + } + if new_fake_blur { + self.last_live_bg_request_at = Instant::now() - self.live_bg_request_interval; + + if matches!(self.state.mode, OverlayMode::Live) + && let Some(_cursor) = self.state.cursor + && let Some(monitor) = self.active_cursor_monitor() + { + self.maybe_request_live_bg(monitor); + } + + return; + } + + self.state.live_bg_monitor = None; + self.state.live_bg_image = None; + } + + fn request_loupe_sample_for_patch_change(&mut self) { + let cursor = match self.state.cursor { + Some(cursor) => cursor, + None => return, + }; + let monitor = match self.active_cursor_monitor() { + Some(monitor) => monitor, + None => return, + }; + let _ = self.apply_live_hover_cache_state(monitor, cursor); + let _ = self.request_live_cursor_sample(monitor, cursor, true); + let _ = self.request_live_window_list_refresh_if_needed(); + } +} diff --git a/packages/rsnap-overlay/src/overlay/cursor_context_runtime.rs b/packages/rsnap-overlay/src/overlay/cursor_context_runtime.rs new file mode 100644 index 00000000..fa5c65eb --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/cursor_context_runtime.rs @@ -0,0 +1,219 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +impl OverlaySession { + pub(super) fn initialize_cursor_state_for_cursor( + &mut self, + cursor: GlobalPoint, + monitor: Option, + ) { + let Some(monitor) = monitor else { + self.state.cursor = Some(cursor); + self.state.rgb = None; + self.cursor_monitor = None; + + return; + }; + + self.update_cursor_state(monitor, cursor); + self.update_hud_window_position(monitor, cursor); + + if matches!(self.state.mode, OverlayMode::Live) { + if self.use_fake_hud_blur() { + self.maybe_request_live_bg(monitor); + } + + self.request_live_samples_for_cursor(monitor, cursor); + } + } + + pub(super) fn monitor_for_cursor_in_rects( + monitors: &[MonitorRect], + cursor: GlobalPoint, + ) -> Option { + monitors.iter().copied().find(|monitor| monitor.contains(cursor)) + } + + pub(super) fn prime_startup_cursor_context( + &mut self, + cursor: GlobalPoint, + monitor: Option, + ) { + let Some(monitor) = monitor else { + self.state.cursor = Some(cursor); + self.state.rgb = None; + self.cursor_monitor = None; + + return; + }; + + self.update_cursor_state(monitor, cursor); + self.update_hud_window_position(monitor, cursor); + } + + #[cfg(target_os = "macos")] + pub(super) fn startup_live_rgb_plan( + startup_monitor: Option, + ) -> StartupLiveRgbPlan { + StartupLiveRgbPlan { focus_window: true, seed_monitor: startup_monitor } + } + + #[cfg(target_os = "macos")] + pub(super) fn seed_startup_live_cursor_rgb( + &mut self, + monitor: MonitorRect, + cursor: GlobalPoint, + ) { + if !matches!(self.state.mode, OverlayMode::Live) || self.state.rgb.is_some() { + return; + } + + let Some(stream) = self.live_sample_stream.as_ref() else { + return; + }; + let Some((x_px, y_px)) = monitor.local_u32_pixels(cursor) else { + return; + }; + let deadline = Instant::now() + STARTUP_LIVE_SAMPLE_WAIT_TIMEOUT; + + loop { + if let Some(sample) = + stream.latest_cursor_sample(monitor, CursorSampleRequest::rgb(x_px, y_px)) + && let Some(rgb) = sample.rgb + { + self.state.rgb = Some(rgb); + + return; + } + + if Instant::now() >= deadline { + return; + } + + thread::sleep(STARTUP_LIVE_SAMPLE_WAIT_POLL_INTERVAL); + } + } + + pub(super) fn maybe_request_live_bg(&mut self, monitor: MonitorRect) { + if !matches!(self.state.mode, OverlayMode::Live) || !self.use_fake_hud_blur() { + return; + } + if self.state.live_bg_monitor == Some(monitor) && self.state.live_bg_image.is_some() { + return; + } + + let force = self.state.alt_held && self.state.live_bg_image.is_none(); + + if !force && self.last_live_bg_request_at.elapsed() < self.live_bg_request_interval { + return; + } + + let Some(worker) = &self.worker else { + return; + }; + + if worker.request_freeze_capture(monitor, FreezeCaptureTarget::Monitor) { + self.last_live_bg_request_at = Instant::now(); + } + } + + pub(super) fn monitor_at(&self, cursor: GlobalPoint) -> Option { + self.windows + .values() + .find(|window| window.monitor.contains(cursor)) + .map(|window| window.monitor) + } + + pub(super) fn resolve_device_cursor_point( + &self, + raw: GlobalPoint, + ) -> Option<(MonitorRect, GlobalPoint, DeviceCursorPointSource)> { + if let Some(monitor) = self.monitor_at(raw) { + return Some((monitor, raw, DeviceCursorPointSource::DevicePoints)); + } + + for monitor in self.windows.values().map(|window| window.monitor) { + let sf = f64::from(monitor.scale_factor()).max(1.0); + let origin_px_x = (monitor.origin.x as f64 * sf).round() as i64; + let origin_px_y = (monitor.origin.y as f64 * sf).round() as i64; + let size_px_x = (monitor.width as f64 * sf).round() as i64; + let size_px_y = (monitor.height as f64 * sf).round() as i64; + let local_px_x = (raw.x as i64).saturating_sub(origin_px_x); + let local_px_y = (raw.y as i64).saturating_sub(origin_px_y); + + if local_px_x < 0 + || local_px_y < 0 + || local_px_x >= size_px_x + || local_px_y >= size_px_y + { + continue; + } + + let local_points_x = (local_px_x as f64 / sf).round() as i64; + let local_points_y = (local_px_y as f64 / sf).round() as i64; + let local_points_x = match i32::try_from(local_points_x) { + Ok(value) => value, + Err(_) => continue, + }; + let local_points_y = match i32::try_from(local_points_y) { + Ok(value) => value, + Err(_) => continue, + }; + let candidate = GlobalPoint::new( + monitor.origin.x.saturating_add(local_points_x), + monitor.origin.y.saturating_add(local_points_y), + ); + + if monitor.contains(candidate) { + return Some((monitor, candidate, DeviceCursorPointSource::DevicePixelsFallback)); + } + } + + None + } + + pub(super) fn resolve_live_cursor_point( + &self, + raw_device: GlobalPoint, + ) -> Option<(MonitorRect, GlobalPoint, DeviceCursorPointSource)> { + let Some((device_monitor, device_global, device_source)) = + self.resolve_device_cursor_point(raw_device) + else { + let (monitor, global) = self.last_event_cursor?; + let event_cursor_at = self.last_event_cursor_at?; + + if event_cursor_at.elapsed() > LIVE_EVENT_CURSOR_CACHE_TTL { + return None; + } + + return Some((monitor, global, DeviceCursorPointSource::EventRecentFallback)); + }; + + if let (Some(event_cursor_at), Some((event_monitor, event_global))) = + (self.last_event_cursor_at, self.last_event_cursor) + && self.state.cursor == Some(device_global) + && event_global != device_global + && event_cursor_at.elapsed() <= LIVE_EVENT_CURSOR_CACHE_TTL + { + return Some(( + event_monitor, + event_global, + DeviceCursorPointSource::EventRecentFallback, + )); + } + + Some((device_monitor, device_global, device_source)) + } + + pub(super) fn active_cursor_monitor(&self) -> Option { + self.cursor_monitor.or_else(|| self.state.cursor.and_then(|cursor| self.monitor_at(cursor))) + } + + pub(super) fn monitor_for_mode(&self) -> Option { + match self.state.mode { + OverlayMode::Frozen => self.active_cursor_monitor().or(self.state.monitor), + OverlayMode::Live => self.active_cursor_monitor(), + } + } +} diff --git a/packages/rsnap-overlay/src/overlay/cursor_runtime.rs b/packages/rsnap-overlay/src/overlay/cursor_runtime.rs new file mode 100644 index 00000000..e16acd36 --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/cursor_runtime.rs @@ -0,0 +1,261 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +impl OverlaySession { + pub(super) fn maybe_tick_frozen_cursor_tracking(&mut self) { + if !self.is_active() || !matches!(self.state.mode, OverlayMode::Frozen) { + return; + } + + let interval = + self.frozen_cursor_tracking_interval(self.state.monitor).max(CURSOR_POLL_INTERVAL_MIN); + let now = Instant::now(); + + self.schedule_egui_repaint_after(interval); + + if let Some((monitor, global)) = self.last_fresh_event_cursor() { + let old_monitor = self.active_cursor_monitor(); + + if tracing::enabled!(tracing::Level::TRACE) { + tracing::trace!( + mode = "frozen", + source = DeviceCursorPointSource::EventRecentFallback.as_str(), + monitor_id = monitor.id, + "Resolved event cursor for frozen tick." + ); + } + if self.state.cursor == Some(global) && old_monitor == Some(monitor) { + return; + } + + let previous_drag_rect = self.state.drag_rect; + + self.update_cursor_state(monitor, global); + self.update_hud_window_position(monitor, global); + self.update_live_drag_rect(monitor, global); + self.update_frozen_selection_drag_rect(global); + self.force_apply_pending_hud_and_loupe_moves(); + self.request_redraw_hud_window(); + + if self.state.alt_held || self.loupe_window_visible { + self.request_redraw_loupe_window(); + } + + if let Some(old_monitor) = old_monitor + && old_monitor != monitor + { + self.request_redraw_for_monitor(old_monitor); + } + + if Self::live_overlay_redraw_needed_for_cursor_update( + old_monitor, + monitor, + previous_drag_rect, + self.state.drag_rect, + ) { + self.request_redraw_for_monitor(monitor); + } + + return; + } + + if now.duration_since(self.last_frozen_cursor_poll_at) < interval { + return; + } + + self.last_frozen_cursor_poll_at = now; + + let raw = self.sample_mouse_location(); + let old_monitor = self.active_cursor_monitor(); + let Some((monitor, global, source)) = self.resolve_device_cursor_point(raw) else { + return; + }; + + if tracing::enabled!(tracing::Level::TRACE) { + tracing::trace!( + mode = "frozen", + source = source.as_str(), + monitor_id = monitor.id, + "Resolved device cursor for frozen tick." + ); + } + if self.state.cursor == Some(global) && old_monitor == Some(monitor) { + return; + } + + let previous_drag_rect = self.state.drag_rect; + + self.update_cursor_state(monitor, global); + self.update_hud_window_position(monitor, global); + self.update_live_drag_rect(monitor, global); + self.update_frozen_selection_drag_rect(global); + self.force_apply_pending_hud_and_loupe_moves(); + self.request_redraw_hud_window(); + + if self.state.alt_held || self.loupe_window_visible { + self.request_redraw_loupe_window(); + } + + if let Some(old_monitor) = old_monitor + && old_monitor != monitor + { + self.request_redraw_for_monitor(old_monitor); + } + + if Self::live_overlay_redraw_needed_for_cursor_update( + old_monitor, + monitor, + previous_drag_rect, + self.state.drag_rect, + ) { + self.request_redraw_for_monitor(monitor); + } + } + + pub(super) fn maybe_tick_live_cursor_tracking(&mut self) { + if !self.is_active() || !matches!(self.state.mode, OverlayMode::Live) { + return; + } + + let interval = self + .repaint_interval_for_monitor(self.active_cursor_monitor()) + .max(CURSOR_POLL_INTERVAL_MIN); + let now = Instant::now(); + + // Keep this loop alive even if CursorMoved events are sparse or coalesced. + self.schedule_egui_repaint_after(interval); + + if let Some((monitor, global)) = self.last_fresh_event_cursor() { + let old_monitor = self.active_cursor_monitor(); + + if tracing::enabled!(tracing::Level::TRACE) { + tracing::trace!( + mode = "live", + source = DeviceCursorPointSource::EventRecentFallback.as_str(), + monitor_id = monitor.id, + "Resolved event cursor for live tick." + ); + } + if self.state.cursor == Some(global) && old_monitor == Some(monitor) { + return; + } + + let previous_drag_rect = self.state.drag_rect; + let old_cursor = self.state.cursor; + + self.update_cursor_for_live_move(old_monitor, old_cursor, monitor, global); + self.update_live_drag_rect(monitor, global); + + if let Some(old_monitor) = old_monitor + && old_monitor != monitor + { + self.request_redraw_for_monitor(old_monitor); + } + + if Self::live_overlay_redraw_needed_for_cursor_update( + old_monitor, + monitor, + previous_drag_rect, + self.state.drag_rect, + ) { + self.request_redraw_for_monitor(monitor); + } + + return; + } + + // If we're already repainting at a higher cadence (for example selection flow), avoid + // sampling the OS cursor position at that same cadence. + if now.duration_since(self.last_live_cursor_poll_at) < interval { + return; + } + + self.last_live_cursor_poll_at = now; + + let raw = self.sample_mouse_location(); + let old_monitor = self.active_cursor_monitor(); + let Some((monitor, global, source)) = self.resolve_live_cursor_point(raw) else { + return; + }; + + if tracing::enabled!(tracing::Level::TRACE) { + tracing::trace!( + mode = "live", + source = source.as_str(), + monitor_id = monitor.id, + "Resolved device cursor for live tick." + ); + } + if self.state.cursor == Some(global) && old_monitor == Some(monitor) { + return; + } + + let previous_drag_rect = self.state.drag_rect; + let old_cursor = self.state.cursor; + + self.update_cursor_for_live_move(old_monitor, old_cursor, monitor, global); + self.update_live_drag_rect(monitor, global); + + if let Some(old_monitor) = old_monitor + && old_monitor != monitor + { + self.request_redraw_for_monitor(old_monitor); + } + + if Self::live_overlay_redraw_needed_for_cursor_update( + old_monitor, + monitor, + previous_drag_rect, + self.state.drag_rect, + ) { + self.request_redraw_for_monitor(monitor); + } + } + + pub(super) fn maybe_request_keepalive_redraw(&mut self) { + // Avoid a tight present loop if the OS delivers spurious redraws. + if self.is_active() && self.last_present_at.elapsed() > Duration::from_secs(30) { + self.request_redraw_all(); + } + } + + pub(super) fn maybe_tick_live_sampling(&mut self) { + if !matches!(self.state.mode, OverlayMode::Live) { + return; + } + if self.pending_click_hit_test_request_id.is_some() { + return; + } + + let now = Instant::now(); + let Some(cursor) = self.state.cursor else { + return; + }; + let Some(monitor) = self.active_cursor_monitor() else { + return; + }; + + if self + .last_event_cursor_at + .is_some_and(|at| now.duration_since(at) <= LIVE_HOVER_HIT_TEST_INTERVAL) + { + return; + } + if self.live_sample_request_pending() { + return; + } + if !self.idle_live_sampling_request_allowed(now, monitor) { + return; + } + + self.record_live_sample_stall(cursor, monitor); + + if self.use_fake_hud_blur() { + self.maybe_request_live_bg(monitor); + } + if self.request_live_samples_for_cursor(monitor, cursor) { + self.last_idle_live_sample_request_at = Some(now); + } + } +} diff --git a/packages/rsnap-overlay/src/overlay/hud_runtime.rs b/packages/rsnap-overlay/src/overlay/hud_runtime.rs new file mode 100644 index 00000000..3bf646d9 --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/hud_runtime.rs @@ -0,0 +1,519 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +impl OverlaySession { + pub(super) fn stabilized_live_hud_inner_size( + mode: OverlayMode, + previous: Option<(u32, u32)>, + desired: (u32, u32), + ) -> (u32, u32) { + if !matches!(mode, OverlayMode::Live) { + return desired; + } + + let Some(previous) = previous else { + return desired; + }; + + (previous.0.max(desired.0), desired.1) + } + + pub(super) fn hud_window_content_rect( + _mode: OverlayMode, + _live_loupe_in_hud: bool, + hud_pill: HudPillGeometry, + _loupe_tile: Option, + ) -> Rect { + hud_pill.rect + } + + pub(super) fn maybe_skip_hud_redraw(&mut self) -> Option { + if self.scroll_capture.active { + if let Some(hud_window) = self.hud_window.as_ref() + && self.hud_window_visible + { + hud_window.window.set_visible(false); + } + + self.hud_window_visible = false; + self.last_present_at = Instant::now(); + + return Some(OverlayControl::Continue); + } + if self.capture_windows_hidden { + #[cfg(not(target_os = "macos"))] + { + if let Some(hud_window) = self.hud_window.as_ref() + && self.hud_window_visible + { + hud_window.window.set_visible(false); + } + + self.hud_window_visible = false; + self.last_present_at = Instant::now(); + + #[cfg(not(target_os = "macos"))] + return Some(OverlayControl::Continue); + } + } + + None + } + + pub(super) fn draw_hud_window_frame( + &mut self, + live_loupe_in_hud: bool, + ) -> Result { + let Some(gpu) = self.gpu.as_ref() else { + return Err(eyre::eyre!("Missing GPU context")); + }; + let monitor = + self.monitor_for_mode().or_else(|| self.windows.values().next().map(|w| w.monitor)); + let mut summary = HudRedrawSummary::default(); + + if let (Some(monitor), Some(hud_window)) = (monitor, self.hud_window.as_mut()) { + summary.redraw_window_id = Some(hud_window.window.id()); + summary.redraw_monitor_id = Some(monitor.id); + + if !self.hud_window_visible { + hud_window.window.set_visible(true); + + self.hud_window_visible = true; + } + + let draw_started_at = Instant::now(); + + hud_window.renderer.draw( + gpu, + &self.state, + monitor, + true, + Some(Pos2::new(-14.0, -14.0)), + !live_loupe_in_hud, + HudAnchor::Cursor, + self.config.toolbar_placement, + self.config.show_alt_hint_keycap, + self.config.show_hud_blur, + self.config.hud_opaque, + self.config.hud_opacity, + self.config.hud_fog_amount, + self.config.hud_milk_amount, + self.config.hud_tint_hue, + self.config.theme_mode, + self.config.selection_flow_enabled, + self.config.selection_flow_stroke_width_px, + true, + false, + self.frozen_capture_source == FrozenCaptureSource::FullscreenFallback, + None, + None, + None, + )?; + + summary.renderer_draw_elapsed = Some(draw_started_at.elapsed()); + + if let Some(hud_pill) = hud_window.renderer.hud_pill { + let height_points = hud_pill.rect.height(); + let height_changed = self + .toolbar_state + .pill_height_points + .is_none_or(|prev| (prev - height_points).abs() > 0.1); + + self.toolbar_state.pill_height_points = Some(height_points); + + if height_changed + && matches!(self.state.mode, OverlayMode::Frozen) + && self.toolbar_state.visible + && self.state.monitor == Some(monitor) + { + self.toolbar_state.needs_redraw = true; + summary.request_toolbar_redraw = Some(monitor); + } + + let combined_rect = Self::hud_window_content_rect( + self.state.mode, + live_loupe_in_hud, + hud_pill, + hud_window.renderer.loupe_tile, + ); + let desired_w = combined_rect.width().ceil().max(1.0) as u32; + let desired_h = combined_rect.height().ceil().max(1.0) as u32; + let desired = Self::stabilized_live_hud_inner_size( + self.state.mode, + self.hud_inner_size_points, + (desired_w, desired_h), + ); + + if self.hud_inner_size_points != Some(desired) { + self.hud_inner_size_points = Some(desired); + summary.resize_target = Some(desired); + + let request_inner_size_started_at = Instant::now(); + let _ = hud_window.window.request_inner_size(LogicalSize::new( + f64::from(desired.0), + f64::from(desired.1), + )); + + summary.request_inner_size_elapsed = + Some(request_inner_size_started_at.elapsed()); + + if let Some(cursor) = self.state.cursor { + let position_update_started_at = Instant::now(); + + self.update_hud_window_position(monitor, cursor); + + summary.position_update_elapsed = + Some(position_update_started_at.elapsed()); + } + } + } + } + + Ok(summary) + } + + pub(super) fn should_try_pending_hud_window_move_on_redraw( + &self, + summary: &HudRedrawSummary, + ) -> bool { + summary.position_update_elapsed.is_some() + || (matches!(self.state.mode, OverlayMode::Live) + && self.pending_hud_outer_pos.is_some()) + } + + pub(super) fn should_try_pending_follow_window_move_on_live_cursor_update(&self) -> bool { + matches!(self.state.mode, OverlayMode::Live) + && (self.pending_hud_outer_pos.is_some() || self.pending_loupe_outer_pos.is_some()) + } + + pub(super) fn log_hud_redraw_metrics( + &mut self, + redraw_elapsed: Duration, + summary: &HudRedrawSummary, + ) { + tracing::trace!( + op = "overlay.hud_redraw_phase_timing", + window_id = ?summary.redraw_window_id, + monitor_id = ?summary.redraw_monitor_id, + total_us = redraw_elapsed.as_micros(), + renderer_draw_us = summary.renderer_draw_elapsed.map_or(0, |elapsed| elapsed.as_micros()), + request_inner_size_us = summary + .request_inner_size_elapsed + .map_or(0, |elapsed| elapsed.as_micros()), + position_update_us = summary + .position_update_elapsed + .map_or(0, |elapsed| elapsed.as_micros()), + toolbar_followup = summary.request_toolbar_redraw.is_some(), + resize_target = ?summary.resize_target, + "HUD redraw phase timing." + ); + + if let Some(elapsed) = summary.renderer_draw_elapsed { + self.slow_op_logger.warn_if_redraw_substep_slow( + "overlay.hud_redraw.renderer_draw", + elapsed, + redraw_elapsed, + || { + format!( + "window_id={:?} monitor_id={:?} toolbar_followup={}", + summary.redraw_window_id, + summary.redraw_monitor_id, + summary.request_toolbar_redraw.is_some() + ) + }, + ); + } + if let Some(elapsed) = summary.request_inner_size_elapsed { + self.slow_op_logger.warn_if_redraw_substep_slow( + "overlay.hud_redraw.request_inner_size", + elapsed, + redraw_elapsed, + || { + format!( + "window_id={:?} monitor_id={:?} desired_size={:?}", + summary.redraw_window_id, summary.redraw_monitor_id, summary.resize_target + ) + }, + ); + } + if let Some(elapsed) = summary.position_update_elapsed { + self.slow_op_logger.warn_if_redraw_substep_slow( + "overlay.hud_redraw.position_update", + elapsed, + redraw_elapsed, + || { + format!( + "window_id={:?} monitor_id={:?} pending_outer_pos={:?}", + summary.redraw_window_id, + summary.redraw_monitor_id, + self.pending_hud_outer_pos + ) + }, + ); + } + + self.slow_op_logger.warn_if_slow( + "overlay.hud_redraw.total", + redraw_elapsed, + LIVE_PRESENT_INTERVAL_MIN, + || { + format!( + "window_id={:?} monitor_id={:?} toolbar_followup={}", + summary.redraw_window_id, + summary.redraw_monitor_id, + summary.request_toolbar_redraw.is_some() + ) + }, + ); + } + + pub(super) fn handle_hud_redraw_requested(&mut self) -> OverlayControl { + let redraw_started_at = Instant::now(); + let live_loupe_in_hud = self.live_loupe_renders_in_hud_window(); + + self.event_loop_last_progress_window_id = + self.hud_window.as_ref().map(|hud_window| hud_window.window.id()); + self.event_loop_last_progress_monitor_id = + self.monitor_for_mode().map(|monitor| monitor.id); + + self.maybe_log_event_loop_stall(Instant::now()); + self.mark_progress(OverlayEventLoopPhase::HudRedraw); + + if let Some(control) = self.maybe_skip_hud_redraw() { + return control; + } + + let summary = match self.draw_hud_window_frame(live_loupe_in_hud) { + Ok(summary) => summary, + Err(err) => return self.exit(OverlayExit::Error(format!("{err:#}"))), + }; + + if summary.position_update_elapsed.is_some() { + self.force_apply_pending_hud_window_move(); + } else if self.should_try_pending_hud_window_move_on_redraw(&summary) { + self.maybe_apply_pending_hud_window_move(Instant::now()); + } + + if let Some(monitor) = summary.request_toolbar_redraw { + self.request_redraw_for_monitor(monitor); + } + + let redraw_elapsed = redraw_started_at.elapsed(); + + self.log_hud_redraw_metrics(redraw_elapsed, &summary); + + self.last_present_at = Instant::now(); + + OverlayControl::Continue + } + + pub(super) fn hide_loupe_window(&mut self) { + if let Some(loupe_window) = self.loupe_window.as_ref() { + loupe_window.window.set_visible(false); + } + + self.loupe_window_visible = false; + + self.reset_loupe_window_warmup_redraws(); + + self.last_present_at = Instant::now(); + } + + pub(super) fn should_skip_loupe_redraw(&self) -> bool { + self.scroll_capture.active + || self.capture_windows_hidden + || !self.state.alt_held + || (matches!(self.state.mode, OverlayMode::Live) && self.live_loupe_uses_hud_window()) + } + + pub(super) fn current_loupe_draw_target(&self) -> Option<(MonitorRect, GlobalPoint)> { + let monitor = + self.monitor_for_mode().or_else(|| self.windows.values().next().map(|w| w.monitor))?; + let cursor = self.state.cursor?; + + Some((monitor, cursor)) + } + + pub(super) fn draw_loupe_window_frame( + &mut self, + monitor: MonitorRect, + _cursor: GlobalPoint, + ) -> Result { + let redraw_started_at = Instant::now(); + let Some(loupe_window) = self.loupe_window.as_mut() else { + return Ok(false); + }; + let loupe_window_id = loupe_window.window.id(); + + #[cfg(not(target_os = "macos"))] + loupe_window.window.set_visible(true); + + let Some(gpu) = self.gpu.as_ref() else { + return Err(eyre::eyre!("Missing GPU context")); + }; + let tile_draw_started_at = Instant::now(); + + loupe_window.renderer.draw_loupe_tile_window( + gpu, + &self.state, + monitor, + self.config.show_hud_blur, + self.config.hud_opaque, + self.config.hud_opacity, + self.config.hud_fog_amount, + self.config.hud_milk_amount, + self.config.hud_tint_hue, + self.config.theme_mode, + )?; + + let tile_draw_elapsed = tile_draw_started_at.elapsed(); + let mut needs_reposition = false; + let mut request_inner_size_elapsed = None; + let mut resize_target = None; + + if let Some(tile_rect) = loupe_window.renderer.loupe_tile { + let desired_w = tile_rect.max.x.ceil().max(1.0) as u32; + let desired_h = tile_rect.max.y.ceil().max(1.0) as u32; + let desired = (desired_w, desired_h); + + if self.loupe_inner_size_points != Some(desired) { + self.loupe_inner_size_points = Some(desired); + resize_target = Some(desired); + + let request_inner_size_started_at = Instant::now(); + let _ = loupe_window.window.request_inner_size(LogicalSize::new( + f64::from(desired_w), + f64::from(desired_h), + )); + + request_inner_size_elapsed = Some(request_inner_size_started_at.elapsed()); + needs_reposition = true; + } + } + + let redraw_elapsed = redraw_started_at.elapsed(); + + self.slow_op_logger.warn_if_redraw_substep_slow( + "overlay.loupe_redraw.tile_draw", + tile_draw_elapsed, + redraw_elapsed, + || format!("window_id={loupe_window_id:?} monitor_id={}", monitor.id), + ); + + if let Some(elapsed) = request_inner_size_elapsed { + self.slow_op_logger.warn_if_redraw_substep_slow( + "overlay.loupe_redraw.request_inner_size", + elapsed, + redraw_elapsed, + || { + format!( + "window_id={loupe_window_id:?} monitor_id={} desired_size={resize_target:?}", + monitor.id + ) + }, + ); + } + + Ok(needs_reposition) + } + + pub(super) fn handle_loupe_redraw_requested(&mut self) -> OverlayControl { + let redraw_started_at = Instant::now(); + + self.event_loop_last_progress_window_id = + self.loupe_window.as_ref().map(|loupe_window| loupe_window.window.id()); + self.event_loop_last_progress_monitor_id = + self.monitor_for_mode().map(|monitor| monitor.id); + + self.maybe_log_event_loop_stall(Instant::now()); + self.mark_progress(OverlayEventLoopPhase::LoupeRedraw); + + if self.gpu.is_none() { + return self.exit(OverlayExit::Error(String::from("Missing GPU context"))); + }; + if self.should_skip_loupe_redraw() { + self.hide_loupe_window(); + + return OverlayControl::Continue; + } + + let Some((monitor, cursor)) = self.current_loupe_draw_target() else { + self.last_present_at = Instant::now(); + + return OverlayControl::Continue; + }; + let redraw_window_id = + self.loupe_window.as_ref().map(|loupe_window| loupe_window.window.id()); + let was_visible = self.loupe_window_visible; + let needs_reposition = match self.draw_loupe_window_frame(monitor, cursor) { + Ok(needs_reposition) => needs_reposition, + Err(err) => return self.exit(OverlayExit::Error(format!("{err:#}"))), + }; + let mut reposition_elapsed = None; + + if needs_reposition { + let reposition_started_at = Instant::now(); + let _ = self.update_loupe_window_position(monitor); + + self.force_apply_pending_loupe_window_move(); + + reposition_elapsed = Some(reposition_started_at.elapsed()); + } + + if let Some(loupe_window) = self.loupe_window.as_ref() + && !was_visible + { + loupe_window.window.set_visible(true); + } + + self.loupe_window_visible = true; + + if !was_visible { + self.maybe_start_loupe_window_warmup_redraw(); + } + + let redraw_elapsed = redraw_started_at.elapsed(); + + if let Some(elapsed) = reposition_elapsed { + self.slow_op_logger.warn_if_redraw_substep_slow( + "overlay.loupe_redraw.reposition", + elapsed, + redraw_elapsed, + || { + format!( + "window_id={redraw_window_id:?} monitor_id={} pending_outer_pos={:?}", + monitor.id, self.pending_loupe_outer_pos + ) + }, + ); + } + + tracing::trace!( + op = "overlay.loupe_redraw_phase_timing", + window_id = ?redraw_window_id, + monitor_id = monitor.id, + total_us = redraw_elapsed.as_micros(), + reposition_us = reposition_elapsed.map_or(0, |elapsed| elapsed.as_micros()), + was_visible, + needs_reposition, + "Loupe redraw phase timing." + ); + + self.slow_op_logger.warn_if_slow( + "overlay.loupe_redraw.total", + redraw_elapsed, + LIVE_PRESENT_INTERVAL_MIN, + || { + format!( + "window_id={redraw_window_id:?} monitor_id={} was_visible={} needs_reposition={}", + monitor.id, was_visible, needs_reposition + ) + }, + ); + + self.last_present_at = Instant::now(); + + OverlayControl::Continue + } +} diff --git a/packages/rsnap-overlay/src/overlay/rendering.rs b/packages/rsnap-overlay/src/overlay/rendering.rs new file mode 100644 index 00000000..0727a9aa --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/rendering.rs @@ -0,0 +1,1538 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +mod affordances; +mod hud_rendering; +mod hud_surface; +mod scroll_preview_window; + +use self::hud_rendering::LiveLoupeTexture; +use self::hud_surface::{HudBg, HudBlurUniformRaw}; +pub(super) use hud_surface::HudPillGeometry; +pub(super) use scroll_preview_window::ScrollPreviewWindow; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct FrozenToolbarButtonStyle { + pub(super) icon_color: Color32, + pub(super) bg_color: Color32, + pub(super) border_color: Option, +} + +pub(super) struct ScrollPreviewView { + pub(super) paused: bool, + pub(super) theme: HudTheme, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct SelectionFlowGeometryCacheKey { + rect_min_x_bits: u32, + rect_min_y_bits: u32, + rect_max_x_bits: u32, + rect_max_y_bits: u32, + corner_radius_bits: u32, + seam_offset_bits: u32, + sample_count: usize, +} +impl SelectionFlowGeometryCacheKey { + const fn new(rect: Rect, corner_radius: f32, seam_offset: f32, sample_count: usize) -> Self { + Self { + rect_min_x_bits: rect.min.x.to_bits(), + rect_min_y_bits: rect.min.y.to_bits(), + rect_max_x_bits: rect.max.x.to_bits(), + rect_max_y_bits: rect.max.y.to_bits(), + corner_radius_bits: corner_radius.to_bits(), + seam_offset_bits: seam_offset.to_bits(), + sample_count, + } + } +} + +#[derive(Debug, Default)] +pub(super) struct SelectionFlowGeometryCache { + key: Option, + samples: Vec<(Pos2, f32)>, + normals: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct SelectionDashedBorderCacheKey { + rect_min_x_bits: u32, + rect_min_y_bits: u32, + rect_max_x_bits: u32, + rect_max_y_bits: u32, + dash_length_bits: u32, + gap_length_bits: u32, +} +impl SelectionDashedBorderCacheKey { + const fn new(rect: Rect, dash_length: f32, gap_length: f32) -> Self { + Self { + rect_min_x_bits: rect.min.x.to_bits(), + rect_min_y_bits: rect.min.y.to_bits(), + rect_max_x_bits: rect.max.x.to_bits(), + rect_max_y_bits: rect.max.y.to_bits(), + dash_length_bits: dash_length.to_bits(), + gap_length_bits: gap_length.to_bits(), + } + } +} + +#[derive(Debug, Default)] +pub(super) struct SelectionDashedBorderCache { + pub(super) key: Option, + pub(super) segments: Vec<[Pos2; 2]>, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct SelectionDashedBorderMetrics { + pub(super) stroke_width: f32, + pub(super) dash_length: f32, + pub(super) gap_length: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct SelectionSizeBadgePadding { + left: f32, + right: f32, + top: f32, + bottom: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct SelectionSizeBadgeLayout { + pub(super) text_size: Vec2, + pub(super) badge_size: Vec2, + padding: SelectionSizeBadgePadding, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct SelectionSizeBadgeTarget { + pub(super) rect: Rect, + pub(super) size_points: RectPoints, +} + +pub(super) struct HudOverlayWindow { + pub(super) window: Arc, + pub(super) renderer: WindowRenderer, +} + +#[derive(Debug, Default)] +pub(super) struct HudRedrawSummary { + pub(super) request_toolbar_redraw: Option, + pub(super) renderer_draw_elapsed: Option, + pub(super) request_inner_size_elapsed: Option, + pub(super) position_update_elapsed: Option, + pub(super) resize_target: Option<(u32, u32)>, + pub(super) redraw_window_id: Option, + pub(super) redraw_monitor_id: Option, +} + +#[cfg(target_os = "macos")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct StartupLiveRgbPlan { + pub(super) focus_window: bool, + pub(super) seed_monitor: Option, +} + +#[derive(Debug, Default)] +struct WindowRendererPhaseTimings { + prepare_input: Duration, + sync_hud_bg: Duration, + run_egui: Duration, + update_hud_blur_uniform: Duration, + sync_egui_textures: Duration, + tessellate: Duration, + acquire_frame: Duration, + render_frame: Duration, + total: Duration, +} +impl WindowRendererPhaseTimings { + fn trace( + &self, + path: WindowRendererPath, + window_id: WindowId, + monitor_id: u32, + mode: OverlayMode, + toolbar_active: bool, + paint_jobs: usize, + ) { + tracing::trace!( + op = "overlay.window_renderer_phase_timing", + path = path.as_str(), + window_id = ?window_id, + monitor_id, + mode = ?mode, + toolbar_active, + paint_jobs, + total_us = self.total.as_micros(), + prepare_input_us = self.prepare_input.as_micros(), + sync_hud_bg_us = self.sync_hud_bg.as_micros(), + run_egui_us = self.run_egui.as_micros(), + update_hud_blur_uniform_us = self.update_hud_blur_uniform.as_micros(), + sync_egui_textures_us = self.sync_egui_textures.as_micros(), + tessellate_us = self.tessellate.as_micros(), + acquire_frame_us = self.acquire_frame.as_micros(), + render_frame_us = self.render_frame.as_micros(), + "Overlay window renderer phase timing." + ); + } + + fn warn_if_substeps_slow( + &self, + slow_op_logger: &mut SlowOperationLogger, + path: WindowRendererPath, + window_id: WindowId, + monitor_id: u32, + mode: OverlayMode, + paint_jobs: usize, + ) { + let context = || { + format!( + "path={} window_id={window_id:?} monitor_id={monitor_id} mode={mode:?} paint_jobs={paint_jobs}", + path.as_str() + ) + }; + + self.warn_phase_if_slow( + slow_op_logger, + "overlay.window_renderer.prepare_input", + self.prepare_input, + &context, + ); + self.warn_phase_if_slow( + slow_op_logger, + "overlay.window_renderer.sync_hud_bg", + self.sync_hud_bg, + &context, + ); + self.warn_phase_if_slow( + slow_op_logger, + "overlay.window_renderer.run_egui", + self.run_egui, + &context, + ); + self.warn_phase_if_slow( + slow_op_logger, + "overlay.window_renderer.update_hud_blur_uniform", + self.update_hud_blur_uniform, + &context, + ); + self.warn_phase_if_slow( + slow_op_logger, + "overlay.window_renderer.sync_egui_textures", + self.sync_egui_textures, + &context, + ); + self.warn_phase_if_slow( + slow_op_logger, + "overlay.window_renderer.tessellate", + self.tessellate, + &context, + ); + } + + fn warn_phase_if_slow( + &self, + slow_op_logger: &mut SlowOperationLogger, + op: &'static str, + elapsed: Duration, + describe: &F, + ) where + F: Fn() -> String, + { + if elapsed.is_zero() { + return; + } + + slow_op_logger.warn_if_redraw_substep_slow(op, elapsed, self.total, describe); + } +} + +pub(super) struct OverlayWindow { + pub(super) monitor: MonitorRect, + pub(super) window: Arc, + pub(super) renderer: WindowRenderer, + pub(super) refresh_rate_millihertz: Option, +} + +pub(super) struct GpuContext { + instance: wgpu::Instance, + adapter: Adapter, + device: Device, + queue: Queue, +} +impl GpuContext { + pub(super) fn new() -> Result { + let instance = wgpu::Instance::default(); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: PowerPreference::LowPower, + compatible_surface: None, + force_fallback_adapter: false, + })) + .map_err(|err| eyre::eyre!("Failed to request GPU adapter: {err}"))?; + let adapter_limits = adapter.limits(); + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("rsnap-overlay device"), + required_features: Features::empty(), + // Use the adapter's actual limits. Using `downlevel_defaults()` caps max texture + // size to 2048, which breaks on common HiDPI displays. + required_limits: adapter_limits, + experimental_features: ExperimentalFeatures::default(), + memory_hints: MemoryHints::Performance, + trace: Trace::Off, + })) + .wrap_err("Failed to create wgpu device")?; + + Ok(Self { instance, adapter, device, queue }) + } +} + +pub(super) struct WindowRenderer { + window: Arc, + surface: Surface<'static>, + surface_config: wgpu::SurfaceConfiguration, + needs_reconfigure: bool, + egui_ctx: egui::Context, + egui_renderer: Renderer, + bg_sampler: Sampler, + mipgen_pipeline: RenderPipeline, + mipgen_surface_pipeline: RenderPipeline, + mipgen_bind_group_layout: BindGroupLayout, + hud_blur_pipeline: RenderPipeline, + hud_blur_bind_group_layout: BindGroupLayout, + hud_blur_uniform: Buffer, + hud_bg: Option, + hud_bg_generation: u64, + pub(super) hud_pill: Option, + pub(super) loupe_tile: Option, + live_loupe_texture: Option, + hud_theme: Option, + egui_start_time: Instant, + egui_last_frame_time: Instant, + selection_flow_cache: SelectionFlowGeometryCache, + selection_dashed_border_cache: SelectionDashedBorderCache, + slow_op_logger: SlowOperationLogger, + occluded_redraw_retry_until: Option, +} +impl WindowRenderer { + fn note_successful_frame_presented(&mut self) { + self.occluded_redraw_retry_until = None; + } + + fn mip_level_count(width: u32, height: u32) -> u32 { + let max_dim = width.max(height).max(1); + + (32_u32.saturating_sub(max_dim.leading_zeros())).max(1) + } + + fn create_mipgen_pipeline( + gpu: &GpuContext, + format: wgpu::TextureFormat, + ) -> (RenderPipeline, BindGroupLayout) { + let shader = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("rsnap-mipgen shader"), + source: ShaderSource::Wgsl(Cow::Borrowed(include_str!("../mipgen.wgsl"))), + }); + let bind_group_layout = + gpu.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("rsnap-mipgen bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Texture { + multisampled: false, + view_dimension: TextureViewDimension::D2, + sample_type: TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Sampler(SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let pipeline_layout = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("rsnap-mipgen pipeline layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + let pipeline = gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("rsnap-mipgen pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: PipelineCompilationOptions::default(), + buffers: &[], + }, + primitive: wgpu::PrimitiveState { + topology: PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: FrontFace::Ccw, + cull_mode: None, + polygon_mode: PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }); + + (pipeline, bind_group_layout) + } + + fn create_mipgen_surface_pipeline( + gpu: &GpuContext, + format: wgpu::TextureFormat, + bind_group_layout: &BindGroupLayout, + ) -> RenderPipeline { + let shader = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("rsnap-mipgen fullscreen shader"), + source: ShaderSource::Wgsl(Cow::Borrowed(include_str!("../mipgen.wgsl"))), + }); + let pipeline_layout = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("rsnap-mipgen fullscreen pipeline layout"), + bind_group_layouts: &[Some(bind_group_layout)], + immediate_size: 0, + }); + + gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("rsnap-mipgen fullscreen pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: PipelineCompilationOptions::default(), + buffers: &[], + }, + primitive: wgpu::PrimitiveState { + topology: PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: FrontFace::Ccw, + cull_mode: None, + polygon_mode: PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }) + } + + fn generate_mipmaps(&self, gpu: &GpuContext, texture: &Texture, mip_level_count: u32) { + if mip_level_count <= 1 { + return; + } + + let mut encoder = gpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("rsnap-mipgen encoder"), + }); + + for level in 1..mip_level_count { + let src_view = texture.create_view(&TextureViewDescriptor { + label: Some("rsnap-mipgen src view"), + format: None, + dimension: None, + usage: None, + aspect: TextureAspect::All, + base_mip_level: level - 1, + mip_level_count: Some(1), + base_array_layer: 0, + array_layer_count: Some(1), + }); + let dst_view = texture.create_view(&TextureViewDescriptor { + label: Some("rsnap-mipgen dst view"), + format: None, + dimension: None, + usage: None, + aspect: TextureAspect::All, + base_mip_level: level, + mip_level_count: Some(1), + base_array_layer: 0, + array_layer_count: Some(1), + }); + let bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("rsnap-mipgen bind group"), + layout: &self.mipgen_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: BindingResource::TextureView(&src_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: BindingResource::Sampler(&self.bg_sampler), + }, + ], + }); + let rpass_desc = wgpu::RenderPassDescriptor { + label: Some("rsnap-mipgen pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &dst_view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), + store: StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }; + let mut rpass = encoder.begin_render_pass(&rpass_desc).forget_lifetime(); + + rpass.set_pipeline(&self.mipgen_pipeline); + rpass.set_bind_group(0, &bind_group, &[]); + rpass.draw(0..3, 0..1); + } + + gpu.queue.submit(Some(encoder.finish())); + } + fn pick_surface_format(caps: &SurfaceCapabilities) -> wgpu::TextureFormat { + caps.formats + .iter() + .copied() + .find(|f| { + matches!( + f, + wgpu::TextureFormat::Bgra8UnormSrgb | wgpu::TextureFormat::Rgba8UnormSrgb + ) + }) + .or_else(|| caps.formats.iter().copied().find(wgpu::TextureFormat::is_srgb)) + .unwrap_or(caps.formats[0]) + } + + fn pick_surface_alpha(caps: &SurfaceCapabilities) -> CompositeAlphaMode { + caps.alpha_modes + .iter() + .copied() + .find(|m| matches!(m, wgpu::CompositeAlphaMode::PreMultiplied)) + .or_else(|| { + caps.alpha_modes + .iter() + .copied() + .find(|m| matches!(m, wgpu::CompositeAlphaMode::PostMultiplied)) + }) + .or_else(|| { + caps.alpha_modes + .iter() + .copied() + .find(|m| !matches!(m, wgpu::CompositeAlphaMode::Opaque)) + }) + .unwrap_or(caps.alpha_modes[0]) + } + + fn make_surface_config( + window: &winit::window::Window, + format: wgpu::TextureFormat, + alpha_mode: CompositeAlphaMode, + ) -> wgpu::SurfaceConfiguration { + let size = window.inner_size(); + + wgpu::SurfaceConfiguration { + usage: TextureUsages::RENDER_ATTACHMENT, + format, + width: size.width.max(1), + height: size.height.max(1), + present_mode: PresentMode::Fifo, + alpha_mode, + view_formats: vec![], + desired_maximum_frame_latency: 2, + } + } + + fn create_bg_sampler(gpu: &GpuContext) -> Sampler { + gpu.device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("rsnap-frozen-bg sampler"), + address_mode_u: AddressMode::ClampToEdge, + address_mode_v: AddressMode::ClampToEdge, + address_mode_w: AddressMode::ClampToEdge, + mag_filter: FilterMode::Linear, + min_filter: FilterMode::Linear, + mipmap_filter: MipmapFilterMode::Linear, + ..Default::default() + }) + } + + fn create_hud_blur_pipeline( + gpu: &GpuContext, + surface_format: wgpu::TextureFormat, + ) -> (RenderPipeline, BindGroupLayout) { + let shader = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("rsnap-hud-blur shader"), + source: ShaderSource::Wgsl(Cow::Borrowed(include_str!("../hud_blur.wgsl"))), + }); + let bind_group_layout = + gpu.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("rsnap-hud-blur bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Texture { + multisampled: false, + view_dimension: TextureViewDimension::D2, + sample_type: TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Sampler(SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Buffer { + ty: BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: BufferSize::new( + mem::size_of::() as u64 + ), + }, + count: None, + }, + ], + }); + let pipeline_layout = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("rsnap-hud-blur pipeline layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + let pipeline = gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("rsnap-hud-blur pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: PipelineCompilationOptions::default(), + buffers: &[], + }, + primitive: wgpu::PrimitiveState { + topology: PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: FrontFace::Ccw, + cull_mode: None, + polygon_mode: PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format: surface_format, + blend: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }); + + (pipeline, bind_group_layout) + } + + fn apply_pending_reconfigure(&mut self, gpu: &GpuContext) { + if self.needs_reconfigure { + self.reconfigure(gpu); + + self.needs_reconfigure = false; + } + } + + fn prepare_egui_input( + &mut self, + gpu: &GpuContext, + pointer_state: Option, + pixels_per_point_override: Option, + ) -> (PhysicalSize, f32, egui::RawInput) { + // egui animations depend on a monotonic time base. Without this, animation state can appear + // to "snap" only after an input event (e.g. CursorMoved) triggers a new frame. + let now = Instant::now(); + let elapsed = now.duration_since(self.egui_start_time).as_secs_f64().max(0.0); + let predicted_dt = + now.duration_since(self.egui_last_frame_time).as_secs_f32().clamp(0.0, 0.5); + + self.egui_last_frame_time = now; + + // Keep the wgpu surface configuration in sync with the OS-reported window size. + // + // On macOS we can observe transient mismatches where `surface_config` is smaller than the + // actual window size (e.g. right after entering Frozen mode), which causes egui to build + // a smaller `screen_rect` and results in UI elements appearing clipped/offset until a + // later redraw or input event triggers a resize/reconfigure. + let actual_size = self.window.inner_size(); + let desired_w = actual_size.width.max(1); + let desired_h = actual_size.height.max(1); + + if self.surface_config.width != desired_w || self.surface_config.height != desired_h { + tracing::debug!( + window_id = ?self.window.id(), + actual_size_px = ?actual_size, + old_surface_px = ?(self.surface_config.width, self.surface_config.height), + new_surface_px = ?(desired_w, desired_h), + window_scale_factor = self.window.scale_factor(), + pixels_per_point_override, + "Reconfiguring wgpu surface to match window." + ); + + self.surface_config.width = desired_w; + self.surface_config.height = desired_h; + self.needs_reconfigure = false; + + self.reconfigure(gpu); + } + + let size = PhysicalSize::new(self.surface_config.width, self.surface_config.height); + let pixels_per_point = pixels_per_point_override + .filter(|v| *v > 0.0) + .unwrap_or_else(|| self.window.scale_factor() as f32); + let screen_size_points = + Vec2::new(size.width as f32 / pixels_per_point, size.height as f32 / pixels_per_point); + let max_texture_side = gpu.device.limits().max_texture_dimension_2d as usize; + + self.egui_ctx.input_mut(|i| i.max_texture_side = max_texture_side); + + let mut raw_input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, screen_size_points)), + focused: true, + time: Some(elapsed), + predicted_dt, + ..Default::default() + }; + let mut events = Vec::new(); + + raw_input.max_texture_side = Some(max_texture_side); + + if let Some(pointer) = pointer_state { + events.push(Event::PointerMoved(pointer.cursor_local)); + + if pointer.left_button_went_down { + events.push(Event::PointerButton { + pos: pointer.cursor_local, + button: PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + }); + } + if pointer.left_button_went_up { + events.push(Event::PointerButton { + pos: pointer.cursor_local, + button: PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::default(), + }); + } + } + + if !events.is_empty() { + raw_input.events = events; + } + + if let Some(viewport) = raw_input.viewports.get_mut(&ViewportId::ROOT) { + viewport.native_pixels_per_point = Some(pixels_per_point); + viewport.inner_rect = raw_input.screen_rect; + viewport.focused = Some(true); + } + + (size, pixels_per_point, raw_input) + } + + #[allow(clippy::too_many_arguments)] + fn run_egui( + &mut self, + raw_input: egui::RawInput, + state: &OverlayState, + monitor: MonitorRect, + can_draw_hud: bool, + hud_local_cursor_override: Option, + hud_compact: bool, + show_hud_blur: bool, + hud_anchor: HudAnchor, + toolbar_placement: ToolbarPlacement, + show_alt_hint_keycap: bool, + hud_blur_active: bool, + hud_opaque: bool, + hud_opacity: f32, + hud_milk_amount: f32, + hud_tint_hue: f32, + theme: HudTheme, + selection_flow_enabled: bool, + selection_flow_stroke_width_px: f32, + needs_frozen_surface_bg: bool, + show_frozen_capture_affordance: bool, + frozen_capture_is_fullscreen_fallback: bool, + frozen_toolbar_reserved_rect: Option, + selection_flow_geometry_cache: &mut SelectionFlowGeometryCache, + selection_dashed_border_cache: &mut SelectionDashedBorderCache, + mut toolbar_state: Option<&mut FrozenToolbarState>, + toolbar_pointer: Option, + ) -> (FullOutput, Option) { + let hud_data = if can_draw_hud { + state.cursor.and_then(|cursor| { + let local_cursor = + hud_local_cursor_override.or_else(|| global_to_local(cursor, monitor))?; + + Some((cursor, local_cursor)) + }) + } else { + None + }; + let mut hud_pill = None; + let mut _show_selection_affordance = false; + let egui_ctx = self.egui_ctx.clone(); + let full_output = egui_ctx.run_ui(raw_input, |ui| { + let ctx = ui.ctx(); + + Self::render_frozen_toolbar_ui( + ui.ctx(), + state, + monitor, + theme, + toolbar_placement, + hud_blur_active, + hud_opaque, + hud_opacity, + hud_milk_amount, + hud_tint_hue, + toolbar_state.as_deref_mut(), + toolbar_pointer, + &mut hud_pill, + ); + + if let Some((cursor, local_cursor)) = hud_data { + let _ = show_hud_blur; + + self.render_hud( + ctx, + state, + monitor, + cursor, + local_cursor, + hud_compact, + hud_anchor, + show_alt_hint_keycap, + hud_blur_active, + hud_opaque, + hud_opacity, + hud_milk_amount, + hud_tint_hue, + theme, + &mut hud_pill, + ); + } + + if matches!(state.mode, OverlayMode::Live) && !can_draw_hud { + let screen_rect = ctx.input(|i| i.viewport_rect()); + let layer = LayerId::new( + Order::Foreground, + Id::new(format!("live-capture-{}", monitor.id)), + ); + let painter = ctx.layer_painter(layer); + + _show_selection_affordance |= Self::render_live_capture_affordances( + ctx, + &painter, + state, + monitor, + screen_rect, + theme, + selection_flow_enabled, + selection_flow_stroke_width_px, + selection_flow_geometry_cache, + ); + } + if matches!(state.mode, OverlayMode::Frozen) + && (needs_frozen_surface_bg || show_frozen_capture_affordance) + && state.monitor == Some(monitor) + && state.frozen_capture_rect.is_some() + { + let screen_rect = ctx.input(|i| i.viewport_rect()); + + _show_selection_affordance |= Self::render_frozen_capture_affordance( + ctx, + state, + monitor, + screen_rect, + theme, + frozen_toolbar_reserved_rect, + frozen_capture_is_fullscreen_fallback, + selection_flow_enabled, + selection_flow_stroke_width_px, + selection_flow_geometry_cache, + selection_dashed_border_cache, + ); + } + }); + + (full_output, hud_pill) + } + + fn sync_egui_textures(&mut self, gpu: &GpuContext, full_output: &FullOutput) { + for (id, image_delta) in &full_output.textures_delta.set { + self.egui_renderer.update_texture(&gpu.device, &gpu.queue, *id, image_delta); + } + for id in &full_output.textures_delta.free { + self.egui_renderer.free_texture(id); + } + } + + fn acquire_frame(&mut self, gpu: &GpuContext) -> Result { + let started_at = Instant::now(); + let frame = { + let mut acquired = None; + + for attempt in 0..2 { + match self.surface.get_current_texture() { + CurrentSurfaceTexture::Success(frame) => { + acquired = Some(Ok(AcquiredSurfaceFrame::Ready(frame))); + + break; + }, + CurrentSurfaceTexture::Suboptimal(frame) => { + self.needs_reconfigure = true; + acquired = Some(Ok(AcquiredSurfaceFrame::Ready(frame))); + + break; + }, + CurrentSurfaceTexture::Outdated if attempt == 0 => { + self.reconfigure(gpu); + + self.needs_reconfigure = false; + }, + CurrentSurfaceTexture::Lost if attempt == 0 => { + let surface = gpu + .instance + .create_surface(Arc::clone(&self.window)) + .wrap_err("Failed to recreate lost surface")?; + + self.surface = surface; + + self.reconfigure(gpu); + + self.needs_reconfigure = false; + }, + CurrentSurfaceTexture::Outdated => { + acquired = Some(Err(eyre::eyre!( + "Failed to acquire surface texture after reconfigure: surface stayed outdated" + ))); + + break; + }, + CurrentSurfaceTexture::Lost => { + acquired = Some(Err(eyre::eyre!( + "Failed to acquire surface texture after recreate: surface stayed lost" + ))); + + break; + }, + CurrentSurfaceTexture::Timeout => { + acquired = Some(Ok(AcquiredSurfaceFrame::Skipped( + SurfaceFrameSkipReason::Timeout, + ))); + + break; + }, + CurrentSurfaceTexture::Occluded => { + acquired = Some(Ok(AcquiredSurfaceFrame::Skipped( + SurfaceFrameSkipReason::Occluded, + ))); + + break; + }, + CurrentSurfaceTexture::Validation => { + acquired = Some(Err(eyre::eyre!( + "Failed to acquire surface texture: validation error" + ))); + + break; + }, + } + } + + acquired.unwrap_or_else(|| { + Err(eyre::eyre!( + "Failed to acquire surface texture: bounded retries exhausted unexpectedly" + )) + }) + }; + let elapsed = started_at.elapsed(); + + self.slow_op_logger.warn_if_slow( + "overlay.window_renderer_acquire_frame", + elapsed, + SLOW_OP_WARN_RENDER, + || format!("needs_reconfigure={}", self.needs_reconfigure), + ); + + frame + } + + #[allow(clippy::too_many_arguments)] + fn render_frame( + &mut self, + gpu: &GpuContext, + draw_frozen_bg: bool, + hud_blur_active: bool, + frame: SurfaceTexture, + paint_jobs: &[ClippedPrimitive], + screen_descriptor: &ScreenDescriptor, + ) -> Result<()> { + let started_at = Instant::now(); + let view = frame.texture.create_view(&TextureViewDescriptor::default()); + let mut encoder = gpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("rsnap-overlay encoder"), + }); + let _user_cmds = self.egui_renderer.update_buffers( + &gpu.device, + &gpu.queue, + &mut encoder, + paint_jobs, + screen_descriptor, + ); + + { + let rpass_desc = wgpu::RenderPassDescriptor { + label: Some("rsnap-overlay renderpass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }), + store: StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }; + let mut rpass = encoder.begin_render_pass(&rpass_desc).forget_lifetime(); + + if draw_frozen_bg && let Some(bg) = &self.hud_bg { + rpass.set_pipeline(&self.mipgen_surface_pipeline); + rpass.set_bind_group(0, &bg.mipgen_bind_group, &[]); + rpass.draw(0..3, 0..1); + } + if hud_blur_active + && self.hud_pill.is_some() + && let Some(bg) = &self.hud_bg + { + if let Some(pill) = self.hud_pill { + let ppp = screen_descriptor.pixels_per_point; + let pad_px = (24.0 * ppp).ceil() as i32; + let surface_w = screen_descriptor.size_in_pixels[0].max(1) as i32; + let surface_h = screen_descriptor.size_in_pixels[1].max(1) as i32; + let min_x_bound = (surface_w - 1).max(0); + let min_y_bound = (surface_h - 1).max(0); + let min_x = + ((pill.rect.min.x * ppp).floor() as i32 - pad_px).clamp(0, min_x_bound); + let min_y = + ((pill.rect.min.y * ppp).floor() as i32 - pad_px).clamp(0, min_y_bound); + let max_x = + ((pill.rect.max.x * ppp).ceil() as i32 + pad_px).clamp(0, surface_w); + let max_y = + ((pill.rect.max.y * ppp).ceil() as i32 + pad_px).clamp(0, surface_h); + let w = (max_x - min_x).max(1) as u32; + let h = (max_y - min_y).max(1) as u32; + + rpass.set_scissor_rect(min_x as u32, min_y as u32, w, h); + } + + rpass.set_pipeline(&self.hud_blur_pipeline); + rpass.set_bind_group(0, &bg.hud_blur_bind_group, &[]); + rpass.draw(0..3, 0..1); + rpass.set_scissor_rect( + 0, + 0, + screen_descriptor.size_in_pixels[0].max(1), + screen_descriptor.size_in_pixels[1].max(1), + ); + } + + self.egui_renderer.render(&mut rpass, paint_jobs, screen_descriptor); + } + + gpu.queue.submit(Some(encoder.finish())); + frame.present(); + self.slow_op_logger.warn_if_slow( + "overlay.window_renderer_render_frame", + started_at.elapsed(), + SLOW_OP_WARN_RENDER, + || { + format!( + "draw_frozen_bg={} hud_blur_active={} paint_jobs={}", + draw_frozen_bg, + hud_blur_active, + paint_jobs.len() + ) + }, + ); + + Ok(()) + } + + pub(super) fn new( + gpu: &GpuContext, + window: Arc, + egui_repaint_deadline: Arc>>, + ) -> Result { + let surface = gpu + .instance + .create_surface(Arc::clone(&window)) + .wrap_err("wgpu create_surface failed")?; + let caps = surface.get_capabilities(&gpu.adapter); + let surface_format = Self::pick_surface_format(&caps); + let surface_alpha = Self::pick_surface_alpha(&caps); + let surface_config = + Self::make_surface_config(window.as_ref(), surface_format, surface_alpha); + + surface.configure(&gpu.device, &surface_config); + + let egui_ctx = egui::Context::default(); + let mut fonts = FontDefinitions::default(); + + egui_phosphor::add_to_fonts(&mut fonts, Variant::Regular); + + let phosphor_fill = String::from("phosphor-fill"); + let proportional_fallback = + fonts.families.get(&FontFamily::Proportional).and_then(|names| names.first()).cloned(); + + fonts.font_data.insert(phosphor_fill.clone(), Variant::Fill.font_data().into()); + + { + let family = + fonts.families.entry(FontFamily::Name(phosphor_fill.clone().into())).or_default(); + + family.insert(0, phosphor_fill.clone()); + + if let Some(fallback) = proportional_fallback + && !family.contains(&fallback) + { + family.push(fallback); + } + } + + egui_ctx.set_fonts(fonts); + + let repaint_deadline = Arc::clone(&egui_repaint_deadline); + + egui_ctx.set_request_repaint_callback(move |info| { + let deadline = Instant::now() + info.delay; + let mut next_repaint = repaint_deadline.lock().unwrap_or_else(|err| err.into_inner()); + let needs_update = next_repaint.is_none_or(|previous| deadline < previous); + + if needs_update { + *next_repaint = Some(deadline); + } + }); + + let egui_renderer = Renderer::new( + &gpu.device, + surface_format, + egui_wgpu::RendererOptions { + msaa_samples: 1, + depth_stencil_format: None, + dithering: false, + predictable_texture_filtering: false, + }, + ); + let bg_sampler = Self::create_bg_sampler(gpu); + let (mipgen_pipeline, mipgen_bind_group_layout) = + Self::create_mipgen_pipeline(gpu, wgpu::TextureFormat::Rgba8UnormSrgb); + let mipgen_surface_pipeline = + Self::create_mipgen_surface_pipeline(gpu, surface_format, &mipgen_bind_group_layout); + let (hud_blur_pipeline, hud_blur_bind_group_layout) = + Self::create_hud_blur_pipeline(gpu, surface_format); + let hud_blur_uniform = gpu.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("rsnap-hud-blur uniform"), + size: mem::size_of::() as u64, + usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let now = Instant::now(); + + Ok(Self { + window, + surface, + surface_config, + needs_reconfigure: false, + egui_ctx, + egui_renderer, + bg_sampler, + mipgen_pipeline, + mipgen_surface_pipeline, + mipgen_bind_group_layout, + hud_blur_pipeline, + hud_blur_bind_group_layout, + hud_blur_uniform, + hud_bg: None, + hud_bg_generation: 0, + hud_pill: None, + loupe_tile: None, + live_loupe_texture: None, + hud_theme: None, + egui_start_time: now, + egui_last_frame_time: now, + selection_flow_cache: SelectionFlowGeometryCache::default(), + selection_dashed_border_cache: SelectionDashedBorderCache::default(), + slow_op_logger: SlowOperationLogger::default(), + occluded_redraw_retry_until: None, + }) + } + + pub(super) fn resize(&mut self, size: PhysicalSize) -> Result<()> { + self.surface_config.width = size.width.max(1); + self.surface_config.height = size.height.max(1); + self.needs_reconfigure = true; + + Ok(()) + } + + fn reconfigure(&mut self, gpu: &GpuContext) { + self.surface.configure(&gpu.device, &self.surface_config); + } + + fn sync_egui_theme(&mut self, theme: HudTheme) { + if self.hud_theme == Some(theme) { + return; + } + + match theme { + HudTheme::Dark => self.egui_ctx.set_visuals(Visuals::dark()), + HudTheme::Light => self.egui_ctx.set_visuals(Visuals::light()), + } + + self.hud_theme = Some(theme); + } + + fn prepare_window_renderer_input( + &mut self, + gpu: &GpuContext, + monitor: MonitorRect, + toolbar_pointer: Option, + theme_mode: ThemeMode, + phase_timings: &mut WindowRendererPhaseTimings, + ) -> (HudTheme, PhysicalSize, f32, egui::RawInput) { + self.apply_pending_reconfigure(gpu); + + let theme = hud_helpers::effective_hud_theme(theme_mode, self.window.theme()); + + self.sync_egui_theme(theme); + + let prepare_input_started_at = Instant::now(); + let (size, pixels_per_point, raw_input) = + self.prepare_egui_input(gpu, toolbar_pointer, Some(monitor.scale_factor())); + + phase_timings.prepare_input = prepare_input_started_at.elapsed(); + + (theme, size, pixels_per_point, raw_input) + } + + #[allow(clippy::too_many_arguments)] + fn maybe_update_hud_blur_uniform( + &mut self, + gpu: &GpuContext, + size: PhysicalSize, + pixels_per_point: f32, + theme: HudTheme, + hud_shader_blur_active: bool, + hud_fog_amount: f32, + hud_milk_amount: f32, + hud_tint_hue: f32, + phase_timings: &mut WindowRendererPhaseTimings, + ) { + if !hud_shader_blur_active { + return; + } + + let update_hud_blur_uniform_started_at = Instant::now(); + + self.update_hud_blur_uniform( + gpu, + size, + pixels_per_point, + theme, + hud_fog_amount, + hud_milk_amount, + hud_tint_hue, + ); + + phase_timings.update_hud_blur_uniform = update_hud_blur_uniform_started_at.elapsed(); + } + + #[allow(clippy::too_many_arguments)] + fn finish_window_renderer_draw( + &mut self, + gpu: &GpuContext, + state: &OverlayState, + path: WindowRendererPath, + monitor: MonitorRect, + size: PhysicalSize, + pixels_per_point: f32, + draw_started_at: Instant, + phase_timings: &mut WindowRendererPhaseTimings, + paint_jobs: Vec, + draw_frozen_bg: bool, + hud_shader_blur_active: bool, + toolbar_active: bool, + ) -> Result<()> { + let screen_descriptor = + ScreenDescriptor { size_in_pixels: [size.width, size.height], pixels_per_point }; + let acquire_frame_started_at = Instant::now(); + let frame = self.acquire_frame(gpu)?; + + phase_timings.acquire_frame = acquire_frame_started_at.elapsed(); + + let frame = match frame { + AcquiredSurfaceFrame::Ready(frame) => frame, + AcquiredSurfaceFrame::Skipped(reason) => { + phase_timings.total = draw_started_at.elapsed(); + + phase_timings.warn_if_substeps_slow( + &mut self.slow_op_logger, + path, + self.window.id(), + monitor.id, + state.mode, + paint_jobs.len(), + ); + phase_timings.trace( + path, + self.window.id(), + monitor.id, + state.mode, + toolbar_active, + paint_jobs.len(), + ); + + tracing::trace!( + path = path.as_str(), + window_id = ?self.window.id(), + monitor_id = monitor.id, + reason = reason.as_str(), + "Skipped overlay window frame acquisition." + ); + + if should_request_overlay_redraw_after_surface_skip( + reason, + Instant::now(), + &mut self.occluded_redraw_retry_until, + ) { + self.window.request_redraw(); + } + + return Ok(()); + }, + }; + let render_frame_started_at = Instant::now(); + + self.render_frame( + gpu, + draw_frozen_bg, + hud_shader_blur_active, + frame, + &paint_jobs, + &screen_descriptor, + )?; + self.note_successful_frame_presented(); + + phase_timings.render_frame = render_frame_started_at.elapsed(); + phase_timings.total = draw_started_at.elapsed(); + + phase_timings.warn_if_substeps_slow( + &mut self.slow_op_logger, + path, + self.window.id(), + monitor.id, + state.mode, + paint_jobs.len(), + ); + phase_timings.trace( + path, + self.window.id(), + monitor.id, + state.mode, + toolbar_active, + paint_jobs.len(), + ); + + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn draw( + &mut self, + gpu: &GpuContext, + state: &OverlayState, + monitor: MonitorRect, + draw_hud: bool, + hud_local_cursor_override: Option, + hud_compact: bool, + hud_anchor: HudAnchor, + toolbar_placement: ToolbarPlacement, + show_alt_hint_keycap: bool, + show_hud_blur: bool, + hud_opaque: bool, + hud_opacity: f32, + hud_fog_amount: f32, + hud_milk_amount: f32, + hud_tint_hue: f32, + theme_mode: ThemeMode, + selection_flow_enabled: bool, + selection_flow_stroke_width_px: f32, + allow_frozen_surface_bg: bool, + show_frozen_capture_affordance: bool, + frozen_capture_is_fullscreen_fallback: bool, + frozen_toolbar_reserved_rect: Option, + toolbar_state: Option<&mut FrozenToolbarState>, + toolbar_pointer: Option, + ) -> Result<()> { + let draw_started_at = Instant::now(); + let mut phase_timings = WindowRendererPhaseTimings::default(); + let (theme, size, pixels_per_point, raw_input) = self.prepare_window_renderer_input( + gpu, + monitor, + toolbar_pointer, + theme_mode, + &mut phase_timings, + ); + let toolbar_active = toolbar_state.is_some(); + + self.trace_frozen_frame_metrics(state, monitor, size, pixels_per_point, toolbar_active); + + self.loupe_tile = None; + + let hud_cfg = Self::resolve_hud_draw_config( + state, + monitor, + draw_hud, + allow_frozen_surface_bg, + toolbar_active, + show_hud_blur, + hud_opaque, + ); + let sync_hud_bg_started_at = Instant::now(); + + self.sync_or_clear_hud_bg(gpu, state, monitor, hud_cfg)?; + + phase_timings.sync_hud_bg = sync_hud_bg_started_at.elapsed(); + + let hud_shader_blur_active = self.hud_shader_blur_active(state, monitor, hud_cfg); + let mut selection_flow_cache = mem::take(&mut self.selection_flow_cache); + let mut selection_dashed_border_cache = mem::take(&mut self.selection_dashed_border_cache); + let run_egui_started_at = Instant::now(); + let (full_output, hud_pill) = self.run_egui( + raw_input, + state, + monitor, + hud_cfg.can_draw_hud, + hud_local_cursor_override, + hud_compact, + show_hud_blur, + hud_anchor, + toolbar_placement, + show_alt_hint_keycap, + hud_cfg.hud_glass_active, + hud_opaque, + hud_opacity, + hud_milk_amount, + hud_tint_hue, + theme, + selection_flow_enabled, + selection_flow_stroke_width_px, + hud_cfg.needs_frozen_surface_bg, + show_frozen_capture_affordance, + frozen_capture_is_fullscreen_fallback, + frozen_toolbar_reserved_rect, + &mut selection_flow_cache, + &mut selection_dashed_border_cache, + toolbar_state, + toolbar_pointer, + ); + + phase_timings.run_egui = run_egui_started_at.elapsed(); + self.selection_flow_cache = selection_flow_cache; + self.selection_dashed_border_cache = selection_dashed_border_cache; + self.hud_pill = hud_pill; + + self.maybe_update_hud_blur_uniform( + gpu, + size, + pixels_per_point, + theme, + hud_shader_blur_active, + hud_fog_amount, + hud_milk_amount, + hud_tint_hue, + &mut phase_timings, + ); + + let sync_egui_textures_started_at = Instant::now(); + + self.sync_egui_textures(gpu, &full_output); + + phase_timings.sync_egui_textures = sync_egui_textures_started_at.elapsed(); + + let tessellate_started_at = Instant::now(); + let paint_jobs = self.egui_ctx.tessellate(full_output.shapes, pixels_per_point); + + phase_timings.tessellate = tessellate_started_at.elapsed(); + + let draw_frozen_bg = hud_cfg.needs_frozen_surface_bg + && state.monitor == Some(monitor) + && state.frozen_image.is_some(); + + self.finish_window_renderer_draw( + gpu, + state, + WindowRendererPath::Overlay, + monitor, + size, + pixels_per_point, + draw_started_at, + &mut phase_timings, + paint_jobs, + draw_frozen_bg, + hud_shader_blur_active, + toolbar_active, + ) + } +} diff --git a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs new file mode 100644 index 00000000..1cd8447b --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs @@ -0,0 +1,2056 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +impl WindowRenderer { + #[allow(clippy::too_many_arguments)] + pub(in crate::overlay) fn render_live_capture_affordances( + ctx: &egui::Context, + painter: &Painter, + state: &OverlayState, + monitor: MonitorRect, + screen_rect: Rect, + theme: HudTheme, + selection_flow_enabled: bool, + selection_flow_stroke_width_px: f32, + selection_flow_geometry_cache: &mut SelectionFlowGeometryCache, + ) -> bool { + let mut has_rect = false; + + if !matches!(state.mode, OverlayMode::Live) { + return false; + } + + let primary_not_down = !ctx.input(|i| i.pointer.primary_down()); + + if let Some(hovered_window) = state.hovered_window_rect + && hovered_window.monitor_id == monitor.id + { + let rect = Rect::from_min_size( + Pos2::new(hovered_window.rect.x as f32, hovered_window.rect.y as f32), + Vec2::new(hovered_window.rect.width as f32, hovered_window.rect.height as f32), + ); + let rect = rect.intersect(screen_rect); + + if rect.width() >= LIVE_DRAG_START_THRESHOLD_PX + && rect.height() >= LIVE_DRAG_START_THRESHOLD_PX + { + Self::render_live_drag_selection_scrim(painter, rect, screen_rect, theme); + + if selection_flow_enabled { + Self::render_selection_flow_ring( + painter, + rect, + ctx, + theme, + SelectionFlowStyle::Band, + selection_flow_stroke_width_px, + selection_flow_geometry_cache, + ); + } + + has_rect = true; + } + } + if let Some(rect) = Self::live_drag_focus_rect(state, monitor, screen_rect) { + Self::render_live_drag_selection_scrim(painter, rect, screen_rect, theme); + + has_rect = true; + } + if let Some(target) = + Self::live_capture_size_badge_target(state, monitor, screen_rect, primary_not_down) + { + Self::render_selection_size_badge( + ctx, + painter, + monitor, + screen_rect, + target, + None, + theme, + ); + + has_rect = true; + } + + let has_hovered_window_for_this_monitor = + state.hovered_window_rect.is_some_and(|hovered| hovered.monitor_id == monitor.id); + let has_drag_rect_for_this_monitor = + state.drag_rect.is_some_and(|drag_rect| drag_rect.monitor_id == monitor.id); + let cursor_on_monitor = state.cursor.is_some_and(|cursor| monitor.contains(cursor)); + + if selection_flow_enabled + && !has_hovered_window_for_this_monitor + && !has_drag_rect_for_this_monitor + && cursor_on_monitor + && primary_not_down + { + Self::render_selection_flow_ring( + painter, + screen_rect, + ctx, + theme, + SelectionFlowStyle::Band, + selection_flow_stroke_width_px, + selection_flow_geometry_cache, + ); + + has_rect = true; + } + + has_rect + } + + #[allow(clippy::too_many_arguments)] + pub(in crate::overlay) fn render_frozen_capture_affordance( + ctx: &egui::Context, + state: &OverlayState, + monitor: MonitorRect, + screen_rect: Rect, + theme: HudTheme, + frozen_toolbar_reserved_rect: Option, + frozen_capture_is_fullscreen_fallback: bool, + selection_flow_enabled: bool, + selection_flow_stroke_width_px: f32, + selection_flow_geometry_cache: &mut SelectionFlowGeometryCache, + selection_dashed_border_cache: &mut SelectionDashedBorderCache, + ) -> bool { + let Some(rect) = Self::frozen_capture_focus_rect(state, screen_rect) else { + return false; + }; + let layer = + LayerId::new(Order::Foreground, Id::new(format!("frozen-pending-{}", monitor.id))); + let painter = ctx.layer_painter(layer); + + if state.frozen_image.is_some() { + let mut has_affordance = Self::render_frozen_selection_scrim( + &painter, + rect, + screen_rect, + theme, + selection_dashed_border_cache, + ); + + if let Some(target) = Self::frozen_capture_size_badge_target(state, screen_rect) { + Self::render_selection_size_badge( + ctx, + &painter, + monitor, + screen_rect, + target, + frozen_toolbar_reserved_rect, + theme, + ); + + has_affordance = true; + } + + return has_affordance; + } + if !selection_flow_enabled { + let mut has_affordance = Self::render_frozen_selection_scrim( + &painter, + rect, + screen_rect, + theme, + selection_dashed_border_cache, + ); + + if let Some(target) = Self::frozen_capture_size_badge_target(state, screen_rect) { + Self::render_selection_size_badge( + ctx, + &painter, + monitor, + screen_rect, + target, + frozen_toolbar_reserved_rect, + theme, + ); + + has_affordance = true; + } + + return has_affordance; + } + + Self::render_selection_flow_ring( + &painter, + rect, + ctx, + theme, + if frozen_capture_is_fullscreen_fallback { + SelectionFlowStyle::Band + } else { + SelectionFlowStyle::FullBorder + }, + selection_flow_stroke_width_px, + selection_flow_geometry_cache, + ); + + if let Some(target) = Self::frozen_capture_size_badge_target(state, screen_rect) { + Self::render_selection_size_badge( + ctx, + &painter, + monitor, + screen_rect, + target, + frozen_toolbar_reserved_rect, + theme, + ); + } + + true + } + + pub(in crate::overlay) fn frozen_capture_focus_rect( + state: &OverlayState, + screen_rect: Rect, + ) -> Option { + let capture_rect = state.frozen_capture_rect?; + + Some(Self::selection_focus_rect(capture_rect, screen_rect)) + } + + pub(in crate::overlay) fn live_drag_focus_rect( + state: &OverlayState, + monitor: MonitorRect, + screen_rect: Rect, + ) -> Option { + let drag_rect = state.drag_rect?; + + if drag_rect.monitor_id != monitor.id { + return None; + } + + let rect = Self::selection_focus_rect(drag_rect.rect, screen_rect); + + if rect.width() < LIVE_DRAG_START_THRESHOLD_PX + || rect.height() < LIVE_DRAG_START_THRESHOLD_PX + { + return None; + } + + Some(rect) + } + + pub(in crate::overlay) fn selection_focus_rect(rect: RectPoints, screen_rect: Rect) -> Rect { + Rect::from_min_size( + Pos2::new(rect.x as f32, rect.y as f32), + Vec2::new(rect.width as f32, rect.height as f32), + ) + .intersect(screen_rect) + } + + pub(in crate::overlay) fn selection_size_badge_target_from_rect( + rect_points: RectPoints, + screen_rect: Rect, + ) -> Option { + let rect = Self::selection_focus_rect(rect_points, screen_rect); + + if rect.width() <= 0.0 || rect.height() <= 0.0 { + return None; + } + + Some(SelectionSizeBadgeTarget { rect, size_points: rect_points }) + } + + pub(in crate::overlay) fn live_capture_size_badge_target( + state: &OverlayState, + monitor: MonitorRect, + screen_rect: Rect, + primary_not_down: bool, + ) -> Option { + if let Some(drag_rect) = state.drag_rect + && drag_rect.monitor_id == monitor.id + && let Some(target) = + Self::selection_size_badge_target_from_rect(drag_rect.rect, screen_rect) + { + return Some(target); + } + if let Some(hovered_window) = state.hovered_window_rect + && hovered_window.monitor_id == monitor.id + && let Some(target) = + Self::selection_size_badge_target_from_rect(hovered_window.rect, screen_rect) + { + return Some(target); + } + + if primary_not_down && state.cursor.is_some_and(|cursor| monitor.contains(cursor)) { + return Some(SelectionSizeBadgeTarget { + rect: screen_rect, + size_points: RectPoints::new(0, 0, monitor.width, monitor.height), + }); + } + + None + } + + pub(in crate::overlay) fn frozen_capture_size_badge_target( + state: &OverlayState, + screen_rect: Rect, + ) -> Option { + let capture_rect = state.frozen_capture_rect?; + + Self::selection_size_badge_target_from_rect(capture_rect, screen_rect) + } + + pub(in crate::overlay) fn frozen_toolbar_reserved_rect( + state: &OverlayState, + monitor: MonitorRect, + screen_rect: Rect, + toolbar_placement: ToolbarPlacement, + toolbar_state: &FrozenToolbarState, + ) -> Option { + if !toolbar_state.visible + || !matches!(state.mode, OverlayMode::Frozen) + || state.monitor != Some(monitor) + { + return None; + } + + let capture_rect = Self::frozen_toolbar_capture_rect(state, monitor, screen_rect); + let toolbar_size = Self::frozen_toolbar_size(toolbar_state); + let default_pos = Self::frozen_toolbar_default_pos( + screen_rect, + capture_rect, + toolbar_size, + toolbar_placement, + ); + let toolbar_pos = toolbar_state.floating_position.unwrap_or(default_pos); + + if !frozen_toolbar_matches_default_slot(toolbar_pos, default_pos) { + return None; + } + + Some(Rect::from_min_size(toolbar_pos, toolbar_size)) + } + + pub(in crate::overlay) fn selection_size_badge_text( + monitor: MonitorRect, + size_points: RectPoints, + ) -> String { + let size_pixels = monitor.local_rect_to_pixels(size_points); + + format!("{}x{}", size_pixels.width, size_pixels.height) + } + + fn selection_size_badge_visual_overflow(pixels_per_point: f32) -> SelectionSizeBadgePadding { + let points_per_pixel = 1.0 / pixels_per_point.max(f32::MIN_POSITIVE); + let outline_offset = SELECTION_SIZE_BADGE_OUTLINE_OFFSET_PX * points_per_pixel; + let near_shadow_offset = SELECTION_SIZE_BADGE_NEAR_SHADOW_OFFSET_PX * points_per_pixel; + let far_shadow_offset = SELECTION_SIZE_BADGE_FAR_SHADOW_OFFSET_PX * points_per_pixel; + + SelectionSizeBadgePadding { + left: outline_offset, + right: outline_offset.max(near_shadow_offset), + top: outline_offset, + bottom: outline_offset.max(near_shadow_offset).max(far_shadow_offset), + } + } + + pub(in crate::overlay) fn selection_size_badge_layout( + ctx: &egui::Context, + text: &str, + theme: HudTheme, + pixels_per_point: f32, + ) -> SelectionSizeBadgeLayout { + let text_color = Self::hud_text_colors(theme).0; + let font_id = FontId::new(SELECTION_SIZE_BADGE_FONT_SIZE_POINTS, FontFamily::Monospace); + let galley = ctx + .fonts_mut(|fonts| fonts.layout_no_wrap(text.to_owned(), font_id.clone(), text_color)); + let text_size = galley.size(); + let visual_overflow = Self::selection_size_badge_visual_overflow(pixels_per_point); + let base_padding = SELECTION_SIZE_BADGE_TEXT_OUTSET_POINTS * 0.5; + let padding = SelectionSizeBadgePadding { + left: base_padding + visual_overflow.left, + right: base_padding + visual_overflow.right, + top: base_padding + visual_overflow.top, + bottom: base_padding + visual_overflow.bottom, + }; + + SelectionSizeBadgeLayout { + text_size, + badge_size: Vec2::new( + (text_size.x + padding.left + padding.right).ceil(), + (text_size.y + padding.top + padding.bottom).ceil(), + ), + padding, + } + } + + #[cfg(test)] + pub(in crate::overlay) fn selection_size_badge_rect( + screen_rect: Rect, + capture_rect: Rect, + badge_size: Vec2, + ) -> Rect { + Self::selection_size_badge_rect_with_reserved_rect( + screen_rect, + capture_rect, + badge_size, + None, + ) + } + + pub(in crate::overlay) fn selection_size_badge_rect_with_reserved_rect( + screen_rect: Rect, + capture_rect: Rect, + badge_size: Vec2, + reserved_rect: Option, + ) -> Rect { + // Geometry priority contract: + // 1. Keep the badge fully visible inside the viewport whenever the viewport can fit it. + // 2. Keep the badge right-aligned to the capture rect whenever that still satisfies (1). + // 3. Prefer the below-capture slot when it fits and does not hit a reserved rect. + // 4. Otherwise stay inside the capture while avoiding the reserved rect when a + // non-overlapping inside band exists. + // 5. If the reserved rect exhausts the in-capture space, try a right-aligned + // above-capture slot before accepting overlap. + let min_x = screen_rect.min.x; + let max_x = (screen_rect.max.x - badge_size.x).max(min_x); + let aligned_x = capture_rect.max.x - badge_size.x; + let x = aligned_x.clamp(min_x, max_x); + let below_y = capture_rect.max.y + SELECTION_SIZE_BADGE_GAP_PX; + let below_rect = Rect::from_min_size(Pos2::new(x, below_y), badge_size); + let fits_below = below_rect.max.y + <= screen_rect.max.y - SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX + && reserved_rect.is_none_or(|rect| !below_rect.intersects(rect)); + + if fits_below { + return below_rect; + } + + let screen_max_y = (screen_rect.max.y - badge_size.y).max(screen_rect.min.y); + let max_inside_y = + (capture_rect.max.y - badge_size.y).min(screen_max_y).max(screen_rect.min.y); + let min_inside_y = capture_rect.min.y.min(max_inside_y).max(screen_rect.min.y); + let preferred_inside_y = + (capture_rect.max.y - SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX - badge_size.y) + .clamp(min_inside_y, max_inside_y); + let preferred_inside_rect = + Rect::from_min_size(Pos2::new(x, preferred_inside_y), badge_size); + + if reserved_rect.is_none_or(|rect| !preferred_inside_rect.intersects(rect)) { + return preferred_inside_rect; + } + + if let Some(reserved_rect) = reserved_rect { + let upper_y = + reserved_rect.min.y - SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX - badge_size.y; + let lower_y = reserved_rect.max.y + SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX; + let candidate_ys = if reserved_rect.center().y <= capture_rect.center().y { + [Some(lower_y), Some(upper_y)] + } else { + [Some(upper_y), Some(lower_y)] + }; + + for candidate_y in candidate_ys.into_iter().flatten() { + if candidate_y < min_inside_y || candidate_y > max_inside_y { + continue; + } + + let candidate_rect = Rect::from_min_size(Pos2::new(x, candidate_y), badge_size); + + if !candidate_rect.intersects(reserved_rect) { + return candidate_rect; + } + } + + let above_y = capture_rect.min.y - SELECTION_SIZE_BADGE_GAP_PX - badge_size.y; + + if above_y >= screen_rect.min.y { + let above_rect = Rect::from_min_size(Pos2::new(x, above_y), badge_size); + + if !above_rect.intersects(reserved_rect) { + return above_rect; + } + } + } + + preferred_inside_rect + } + + pub(in crate::overlay) fn snap_points_to_pixel_grid(value: f32, pixels_per_point: f32) -> f32 { + let pixels_per_point = pixels_per_point.max(f32::MIN_POSITIVE); + + (value * pixels_per_point).round() / pixels_per_point + } + + pub(in crate::overlay) fn snap_pos_to_pixel_grid(pos: Pos2, pixels_per_point: f32) -> Pos2 { + Pos2::new( + Self::snap_points_to_pixel_grid(pos.x, pixels_per_point), + Self::snap_points_to_pixel_grid(pos.y, pixels_per_point), + ) + } + + pub(in crate::overlay) fn selection_size_badge_text_anchor( + badge_rect: Rect, + layout: SelectionSizeBadgeLayout, + pixels_per_point: f32, + ) -> Pos2 { + Self::snap_pos_to_pixel_grid( + Pos2::new( + badge_rect.max.x - layout.padding.right, + badge_rect.min.y + layout.padding.top + layout.text_size.y * 0.5, + ), + pixels_per_point, + ) + } + + #[cfg(test)] + pub(in crate::overlay) fn selection_size_badge_visual_bounds( + text_anchor: Pos2, + text_size: Vec2, + pixels_per_point: f32, + ) -> Rect { + let visual_overflow = Self::selection_size_badge_visual_overflow(pixels_per_point); + + Rect::from_min_max( + Pos2::new( + text_anchor.x - text_size.x - visual_overflow.left, + text_anchor.y - text_size.y * 0.5 - visual_overflow.top, + ), + Pos2::new( + text_anchor.x + visual_overflow.right, + text_anchor.y + text_size.y * 0.5 + visual_overflow.bottom, + ), + ) + } + + pub(in crate::overlay) fn selection_size_badge_text_colors( + theme: HudTheme, + ) -> (Color32, Color32, Color32, Color32) { + match theme { + HudTheme::Dark => ( + Color32::from_rgba_unmultiplied(255, 255, 255, 248), + Color32::from_rgba_unmultiplied(0, 0, 0, 108), + Color32::from_rgba_unmultiplied(0, 0, 0, 154), + Color32::from_rgba_unmultiplied(0, 0, 0, 72), + ), + HudTheme::Light => ( + Color32::from_rgba_unmultiplied(255, 255, 255, 252), + Color32::from_rgba_unmultiplied(0, 0, 0, 156), + Color32::from_rgba_unmultiplied(0, 0, 0, 196), + Color32::from_rgba_unmultiplied(0, 0, 0, 96), + ), + } + } + + pub(in crate::overlay) fn render_selection_size_badge( + ctx: &egui::Context, + painter: &Painter, + monitor: MonitorRect, + screen_rect: Rect, + target: SelectionSizeBadgeTarget, + reserved_rect: Option, + theme: HudTheme, + ) { + let text = Self::selection_size_badge_text(monitor, target.size_points); + let pixels_per_point = painter.pixels_per_point(); + let layout = Self::selection_size_badge_layout(ctx, &text, theme, pixels_per_point); + let badge_rect = Self::selection_size_badge_rect_with_reserved_rect( + screen_rect, + target.rect, + layout.badge_size, + reserved_rect, + ); + let font_id = FontId::new(SELECTION_SIZE_BADGE_FONT_SIZE_POINTS, FontFamily::Monospace); + let points_per_pixel = 1.0 / pixels_per_point.max(f32::MIN_POSITIVE); + let outline_offset = SELECTION_SIZE_BADGE_OUTLINE_OFFSET_PX * points_per_pixel; + let near_shadow_offset = SELECTION_SIZE_BADGE_NEAR_SHADOW_OFFSET_PX * points_per_pixel; + let far_shadow_offset = SELECTION_SIZE_BADGE_FAR_SHADOW_OFFSET_PX * points_per_pixel; + let text_anchor = + Self::selection_size_badge_text_anchor(badge_rect, layout, pixels_per_point); + let (text_color, outline_color, near_shadow_color, far_shadow_color) = + Self::selection_size_badge_text_colors(theme); + + painter.text( + Self::snap_pos_to_pixel_grid( + text_anchor + Vec2::new(0.0, far_shadow_offset), + pixels_per_point, + ), + Align2::RIGHT_CENTER, + text.clone(), + font_id.clone(), + far_shadow_color, + ); + + for offset in [ + Vec2::new(-outline_offset, 0.0), + Vec2::new(outline_offset, 0.0), + Vec2::new(0.0, -outline_offset), + Vec2::new(0.0, outline_offset), + ] { + painter.text( + Self::snap_pos_to_pixel_grid(text_anchor + offset, pixels_per_point), + Align2::RIGHT_CENTER, + text.clone(), + font_id.clone(), + outline_color, + ); + } + + painter.text( + Self::snap_pos_to_pixel_grid( + text_anchor + Vec2::new(near_shadow_offset, near_shadow_offset), + pixels_per_point, + ), + Align2::RIGHT_CENTER, + text.clone(), + font_id.clone(), + near_shadow_color, + ); + painter.text(text_anchor, Align2::RIGHT_CENTER, text, font_id, text_color); + } + + pub(in crate::overlay) fn frozen_selection_scrim_rects( + screen_rect: Rect, + focus_rect: Rect, + ) -> [Rect; 4] { + [ + Rect::from_min_max(screen_rect.min, Pos2::new(screen_rect.max.x, focus_rect.min.y)), + Rect::from_min_max(Pos2::new(screen_rect.min.x, focus_rect.max.y), screen_rect.max), + Rect::from_min_max( + Pos2::new(screen_rect.min.x, focus_rect.min.y), + Pos2::new(focus_rect.min.x, focus_rect.max.y), + ), + Rect::from_min_max( + Pos2::new(focus_rect.max.x, focus_rect.min.y), + Pos2::new(screen_rect.max.x, focus_rect.max.y), + ), + ] + } + + pub(in crate::overlay) fn frozen_selection_scrim_color(theme: HudTheme) -> Color32 { + let alpha = match theme { + HudTheme::Light => FROZEN_SELECTION_SCRIM_ALPHA_LIGHT, + HudTheme::Dark => FROZEN_SELECTION_SCRIM_ALPHA_DARK, + }; + + Color32::from_rgba_unmultiplied(0, 0, 0, alpha) + } + + pub(in crate::overlay) fn live_drag_selection_scrim_color(theme: HudTheme) -> Color32 { + let alpha = match theme { + HudTheme::Light => LIVE_DRAG_SELECTION_SCRIM_ALPHA_LIGHT, + HudTheme::Dark => LIVE_DRAG_SELECTION_SCRIM_ALPHA_DARK, + }; + + Color32::from_rgba_unmultiplied(0, 0, 0, alpha) + } + + pub(in crate::overlay) fn render_frozen_selection_scrim( + painter: &Painter, + focus_rect: Rect, + screen_rect: Rect, + theme: HudTheme, + selection_dashed_border_cache: &mut SelectionDashedBorderCache, + ) -> bool { + Self::render_selection_scrim( + painter, + focus_rect, + screen_rect, + Self::frozen_selection_scrim_color(theme), + selection_dashed_border_cache, + ) + } + + pub(in crate::overlay) fn render_live_drag_selection_scrim( + painter: &Painter, + focus_rect: Rect, + screen_rect: Rect, + theme: HudTheme, + ) -> bool { + Self::render_selection_scrim_fill( + painter, + focus_rect, + screen_rect, + Self::live_drag_selection_scrim_color(theme), + ) + } + + pub(in crate::overlay) fn render_selection_scrim( + painter: &Painter, + focus_rect: Rect, + screen_rect: Rect, + scrim_fill: Color32, + selection_dashed_border_cache: &mut SelectionDashedBorderCache, + ) -> bool { + let drew_scrim = + Self::render_selection_scrim_fill(painter, focus_rect, screen_rect, scrim_fill); + let drew_border = Self::render_selection_dashed_border( + painter, + focus_rect, + screen_rect, + selection_dashed_border_cache, + ); + + drew_scrim || drew_border + } + + pub(in crate::overlay) fn render_selection_scrim_fill( + painter: &Painter, + focus_rect: Rect, + screen_rect: Rect, + scrim_fill: Color32, + ) -> bool { + let scrim_rects = Self::frozen_selection_scrim_rects(screen_rect, focus_rect); + let mut drew_scrim = false; + + for rect in scrim_rects { + if rect.width() <= 0.0 || rect.height() <= 0.0 { + continue; + } + + painter.rect_filled(rect, 0.0, scrim_fill); + + drew_scrim = true; + } + + drew_scrim + } + + pub(in crate::overlay) fn render_selection_dashed_border( + painter: &Painter, + focus_rect: Rect, + screen_rect: Rect, + selection_dashed_border_cache: &mut SelectionDashedBorderCache, + ) -> bool { + let metrics = Self::selection_dashed_border_metrics(painter.pixels_per_point()); + let border_outset = + Self::selection_dashed_border_outset(metrics.stroke_width, painter.pixels_per_point()); + let Some(border_rect) = + Self::selection_dashed_border_rect(screen_rect, focus_rect, border_outset) + else { + return false; + }; + let segments = Self::selection_dashed_border_cached_segments( + selection_dashed_border_cache, + border_rect, + metrics.dash_length, + metrics.gap_length, + ); + + if segments.is_empty() { + return false; + } + + let stroke = Stroke::new( + metrics.stroke_width, + Color32::from_rgba_unmultiplied(255, 255, 255, SELECTION_DASHED_BORDER_ALPHA), + ); + + for segment in segments { + painter.add(Shape::line_segment(*segment, stroke)); + } + + true + } + + pub(in crate::overlay) fn selection_dashed_border_metrics( + pixels_per_point: f32, + ) -> SelectionDashedBorderMetrics { + let points_per_pixel = 1.0 / pixels_per_point.max(f32::MIN_POSITIVE); + + SelectionDashedBorderMetrics { + stroke_width: SELECTION_DASHED_BORDER_WIDTH_PX * points_per_pixel, + dash_length: SELECTION_DASHED_BORDER_DASH_LENGTH_PX * points_per_pixel, + gap_length: SELECTION_DASHED_BORDER_GAP_LENGTH_PX * points_per_pixel, + } + } + + pub(in crate::overlay) fn selection_dashed_border_rect( + screen_rect: Rect, + focus_rect: Rect, + border_outset: f32, + ) -> Option { + Self::selection_has_outside_region(screen_rect, focus_rect) + .then_some(focus_rect.expand(border_outset)) + } + + pub(in crate::overlay) fn selection_dashed_border_outset( + stroke_width: f32, + pixels_per_point: f32, + ) -> f32 { + let feathering = 1.0 / pixels_per_point.max(f32::MIN_POSITIVE); + + // Match epaint's outer stroke radius so the anti-aliased dashed keyline + // stays fully in the scrim instead of bleeding into the capture rect. + (stroke_width + feathering) * 0.5 + } + + pub(in crate::overlay) fn selection_has_outside_region( + screen_rect: Rect, + focus_rect: Rect, + ) -> bool { + Self::frozen_selection_scrim_rects(screen_rect, focus_rect) + .into_iter() + .any(|rect| rect.width() > 0.0 && rect.height() > 0.0) + } + + pub(in crate::overlay) fn selection_dashed_border_segments( + rect: Rect, + target_dash_length: f32, + target_gap_length: f32, + ) -> Vec<[Pos2; 2]> { + let perimeter = Self::selection_dashed_border_perimeter(rect); + + if perimeter <= 0.0 { + return Vec::new(); + } + + let mut segments = Vec::new(); + + for (dash_start, dash_end) in Self::selection_dashed_border_dash_ranges( + perimeter, + target_dash_length, + target_gap_length, + ) { + Self::append_selection_dashed_border_dash_segments( + rect, + dash_start, + dash_end, + &mut segments, + ); + } + + segments + } + + pub(in crate::overlay) fn selection_dashed_border_cached_segments( + selection_dashed_border_cache: &mut SelectionDashedBorderCache, + rect: Rect, + target_dash_length: f32, + target_gap_length: f32, + ) -> &[[Pos2; 2]] { + let key = SelectionDashedBorderCacheKey::new(rect, target_dash_length, target_gap_length); + + if selection_dashed_border_cache.key != Some(key) { + selection_dashed_border_cache.segments.clear(); + selection_dashed_border_cache.segments.extend(Self::selection_dashed_border_segments( + rect, + target_dash_length, + target_gap_length, + )); + + selection_dashed_border_cache.key = Some(key); + } + + selection_dashed_border_cache.segments.as_slice() + } + + pub(in crate::overlay) fn selection_dashed_border_dash_ranges( + perimeter: f32, + target_dash_length: f32, + target_gap_length: f32, + ) -> Vec<(f32, f32)> { + if perimeter <= 0.0 { + return Vec::new(); + } + + let target_cycle = (target_dash_length + target_gap_length).max(f32::MIN_POSITIVE); + let cycle_count = (perimeter / target_cycle).round().max(1.0) as usize; + let cycle_span = perimeter / cycle_count as f32; + let dash_length = target_dash_length.min(cycle_span); + + (0..cycle_count) + .map(|index| { + let dash_start = index as f32 * cycle_span; + + (dash_start, dash_start + dash_length) + }) + .collect() + } + + pub(in crate::overlay) fn append_selection_dashed_border_dash_segments( + rect: Rect, + dash_start: f32, + dash_end: f32, + segments: &mut Vec<[Pos2; 2]>, + ) { + let mut segment_start = dash_start; + + for corner_distance in Self::selection_dashed_border_corner_distances(rect) { + if segment_start >= dash_end { + break; + } + if corner_distance <= segment_start || corner_distance >= dash_end { + continue; + } + + Self::push_selection_dashed_border_segment( + rect, + segment_start, + corner_distance, + segments, + ); + + segment_start = corner_distance; + } + + if segment_start < dash_end { + Self::push_selection_dashed_border_segment(rect, segment_start, dash_end, segments); + } + } + + pub(in crate::overlay) fn push_selection_dashed_border_segment( + rect: Rect, + start_distance: f32, + end_distance: f32, + segments: &mut Vec<[Pos2; 2]>, + ) { + let start = Self::selection_dashed_border_point_at(rect, start_distance); + let end = Self::selection_dashed_border_point_at(rect, end_distance); + + if start != end { + segments.push([start, end]); + } + } + + pub(in crate::overlay) fn selection_dashed_border_point_at(rect: Rect, distance: f32) -> Pos2 { + let width = rect.width(); + let height = rect.height(); + let perimeter = Self::selection_dashed_border_perimeter(rect); + let distance = distance.rem_euclid(perimeter); + + if distance < width { + return Pos2::new(rect.min.x + distance, rect.min.y); + } + if distance < width + height { + return Pos2::new(rect.max.x, rect.min.y + (distance - width)); + } + if distance < width * 2.0 + height { + return Pos2::new(rect.max.x - (distance - width - height), rect.max.y); + } + + Pos2::new(rect.min.x, rect.max.y - (distance - width * 2.0 - height)) + } + + pub(in crate::overlay) fn selection_dashed_border_corner_distances(rect: Rect) -> [f32; 4] { + let width = rect.width(); + let height = rect.height(); + + [width, width + height, width * 2.0 + height, Self::selection_dashed_border_perimeter(rect)] + } + + pub(in crate::overlay) fn selection_dashed_border_perimeter(rect: Rect) -> f32 { + if rect.width() <= 0.0 || rect.height() <= 0.0 { + return 0.0; + } + + (rect.width() + rect.height()) * 2.0 + } + + pub(in crate::overlay) fn render_selection_flow_ring( + painter: &Painter, + rect: Rect, + ctx: &egui::Context, + theme: HudTheme, + style: SelectionFlowStyle, + selection_flow_stroke_width_px: f32, + selection_flow_geometry_cache: &mut SelectionFlowGeometryCache, + ) { + if rect.width() < LIVE_DRAG_START_THRESHOLD_PX + || rect.height() < LIVE_DRAG_START_THRESHOLD_PX + { + return; + } + + let corner_radius = Self::selection_flow_corner_radius(rect); + let perimeter = Self::selection_flow_perimeter(rect, corner_radius); + let time = ctx.input(|i| i.time) as f32; + let sample_count = Self::selection_flow_sample_count(perimeter); + let seam_offset = if rect.width() > corner_radius * 2.0 { + (rect.width() - corner_radius * 2.0) * 0.5 + } else { + 0.0 + }; + let (samples, normals) = Self::selection_flow_cached_geometry( + selection_flow_geometry_cache, + rect, + corner_radius, + sample_count, + seam_offset, + ); + let base_alpha_scale = 1.0; + let stroke_width = selection_flow_stroke_width_px.clamp(1.0, 8.0); + + if samples.is_empty() { + return; + } + + let flow_time = time * SELECTION_FLOW_SPEED; + let phase = flow_time * 1.28 + 0.72; + + match style { + SelectionFlowStyle::Band => Self::selection_flow_draw_layer( + painter, + samples, + normals, + stroke_width, + base_alpha_scale * 0.52, + phase, + SELECTION_FLOW_CORE_FLOW_WIDTH, + theme, + ), + SelectionFlowStyle::FullBorder => Self::selection_flow_draw_layer_full_border( + painter, + samples, + normals, + stroke_width, + base_alpha_scale * SELECTION_FLOW_FROZEN_ALPHA_SCALE, + phase, + SELECTION_FLOW_FROZEN_INTENSITY, + theme, + ), + } + } + + pub(in crate::overlay) fn selection_flow_corner_radius(rect: Rect) -> f32 { + SELECTION_FLOW_CORNER_RADIUS_PX + .min(rect.width() / 2.0 - 0.25) + .min(rect.height() / 2.0 - 0.25) + .max(0.0) + } + + pub(in crate::overlay) fn selection_flow_palette( + theme: HudTheme, + ) -> &'static [(u8, u8, u8); 3] { + match theme { + HudTheme::Dark => &SELECTION_FLOW_PALETTE, + HudTheme::Light => &SELECTION_FLOW_LIGHT_PALETTE, + } + } + + pub(in crate::overlay) fn selection_flow_cached_geometry( + selection_flow_geometry_cache: &mut SelectionFlowGeometryCache, + rect: Rect, + corner_radius: f32, + sample_count: usize, + seam_offset: f32, + ) -> (&[(Pos2, f32)], &[Vec2]) { + let key = + SelectionFlowGeometryCacheKey::new(rect, corner_radius, seam_offset, sample_count); + + if selection_flow_geometry_cache.key == Some(key) + && !selection_flow_geometry_cache.samples.is_empty() + { + return ( + &selection_flow_geometry_cache.samples, + &selection_flow_geometry_cache.normals, + ); + } + + let samples = + Self::selection_flow_path_samples(rect, corner_radius, sample_count, seam_offset); + let normals = Self::selection_flow_compute_normals(&samples); + + selection_flow_geometry_cache.key = Some(key); + selection_flow_geometry_cache.samples = samples; + selection_flow_geometry_cache.normals = normals; + + (&selection_flow_geometry_cache.samples, &selection_flow_geometry_cache.normals) + } + + pub(in crate::overlay) fn selection_flow_compute_normals(samples: &[(Pos2, f32)]) -> Vec { + let n = samples.len(); + + if n == 0 { + return Vec::new(); + } + + let mut normals = Vec::with_capacity(n); + let mut first_non_zero = None; + + for i in 0..n { + let (current_point, _) = samples[i]; + let (prev_point, _) = samples[(i + n - 1) % n]; + let (next_point, _) = samples[(i + 1) % n]; + let prev_tangent = current_point - prev_point; + let next_tangent = next_point - current_point; + let mut normal = Vec2::ZERO; + + if prev_tangent.length_sq() > f32::EPSILON { + let prev_len = prev_tangent.length(); + + normal += Vec2::new(-prev_tangent.y / prev_len, prev_tangent.x / prev_len); + } + if next_tangent.length_sq() > f32::EPSILON { + let next_len = next_tangent.length(); + + normal += Vec2::new(-next_tangent.y / next_len, next_tangent.x / next_len); + } + if normal.length_sq() <= f32::EPSILON { + if next_tangent.length_sq() > f32::EPSILON { + let next_len = next_tangent.length(); + + normal = Vec2::new(-next_tangent.y / next_len, next_tangent.x / next_len); + } else if prev_tangent.length_sq() > f32::EPSILON { + let prev_len = prev_tangent.length(); + + normal = Vec2::new(-prev_tangent.y / prev_len, prev_tangent.x / prev_len); + } + } + + let normal = if normal.length_sq() > f32::EPSILON { + let normalized = normal / normal.length(); + + if first_non_zero.is_none() && normalized.length_sq() > f32::EPSILON { + first_non_zero = Some(i); + } + + normalized + } else { + Vec2::ZERO + }; + + normals.push(normal); + } + + if let Some(first_idx) = first_non_zero { + let mut previous = normals[first_idx]; + + for normal in normals.iter_mut().skip(first_idx + 1) { + if normal.length_sq() > f32::EPSILON && normal.dot(previous) < 0.0 { + *normal = -*normal; + } + if normal.length_sq() > f32::EPSILON { + previous = *normal; + } + } + for normal in normals.iter_mut().take(first_idx).rev() { + if normal.length_sq() > f32::EPSILON && normal.dot(previous) < 0.0 { + *normal = -*normal; + } + if normal.length_sq() > f32::EPSILON { + previous = *normal; + } + } + + if normals[first_idx].length_sq() > f32::EPSILON + && normals[(first_idx + n - 1) % n].length_sq() > f32::EPSILON + && normals[first_idx].dot(normals[(first_idx + n - 1) % n]) < 0.0 + { + for normal in &mut normals { + *normal = -*normal; + } + } + } + + normals + } + + #[allow(clippy::too_many_arguments)] + pub(in crate::overlay) fn selection_flow_draw_layer( + painter: &Painter, + samples: &[(Pos2, f32)], + normals: &[Vec2], + line_width: f32, + alpha_scale: f32, + phase: f32, + flow_band_width: f32, + theme: HudTheme, + ) { + if samples.is_empty() || normals.is_empty() || samples.len() != normals.len() { + return; + } + + let half = (line_width * 0.5).max(0.1); + let n = samples.len(); + let mut mesh = Mesh::default(); + + for i in 0..n { + let (current_point, t) = samples[i]; + let movement = Self::selection_flow_flow_band(t, phase, flow_band_width); + let intensity = SELECTION_FLOW_FLOW_BOOST * movement; + let color = Self::selection_flow_color(t + phase, theme, alpha_scale, intensity); + let normal = normals[i] * half; + + mesh.colored_vertex(current_point + normal, color); + mesh.colored_vertex(current_point - normal, color); + } + for i in 0..n { + let i0 = (i * 2) as u32; + let i1 = ((i * 2) + 1) as u32; + let n0 = (((i + 1) % n) * 2) as u32; + let n1 = (((i + 1) % n) * 2 + 1) as u32; + + mesh.add_triangle(i0, i1, n0); + mesh.add_triangle(i1, n1, n0); + } + + painter.add(Shape::Mesh(mesh.into())); + } + + #[allow(clippy::too_many_arguments)] + pub(in crate::overlay) fn selection_flow_draw_layer_full_border( + painter: &Painter, + samples: &[(Pos2, f32)], + normals: &[Vec2], + line_width: f32, + alpha_scale: f32, + phase: f32, + intensity: f32, + theme: HudTheme, + ) { + if samples.is_empty() || normals.is_empty() || samples.len() != normals.len() { + return; + } + + let half = (line_width * 0.5).max(0.1); + let n = samples.len(); + let mut mesh = Mesh::default(); + + for i in 0..n { + let (current_point, t) = samples[i]; + let color = Self::selection_flow_color(t + phase, theme, alpha_scale, intensity); + let normal = normals[i] * half; + + mesh.colored_vertex(current_point + normal, color); + mesh.colored_vertex(current_point - normal, color); + } + for i in 0..n { + let i0 = (i * 2) as u32; + let i1 = ((i * 2) + 1) as u32; + let n0 = (((i + 1) % n) * 2) as u32; + let n1 = (((i + 1) % n) * 2 + 1) as u32; + + mesh.add_triangle(i0, i1, n0); + mesh.add_triangle(i1, n1, n0); + } + + painter.add(Shape::Mesh(mesh.into())); + } + + pub(in crate::overlay) fn selection_flow_flow_band( + progress: f32, + phase: f32, + band_width: f32, + ) -> f32 { + let width = band_width.clamp(0.001, 0.5); + let distance = (progress - phase).rem_euclid(1.0); + let distance = distance.min(1.0 - distance); + let normalized = (distance / width).min(1.0); + + (1.0 - normalized).powf(2.0) + } + + pub(in crate::overlay) fn selection_flow_sample_count(perimeter: f32) -> usize { + if perimeter <= 0.0 || !perimeter.is_finite() { + return SELECTION_FLOW_MIN_SEGMENTS; + } + + let by_step = (perimeter / SELECTION_FLOW_SAMPLE_STEP_PX).ceil() as usize; + + by_step.clamp(SELECTION_FLOW_MIN_SEGMENTS, SELECTION_FLOW_MAX_SEGMENTS) + } + + pub(in crate::overlay) fn selection_flow_path_samples( + rect: Rect, + corner_radius: f32, + sample_count: usize, + start_offset: f32, + ) -> Vec<(Pos2, f32)> { + let perimeter = Self::selection_flow_perimeter(rect, corner_radius); + + if perimeter <= 0.0 { + return Vec::new(); + } + + let start = (start_offset / perimeter).rem_euclid(1.0); + + (0..sample_count) + .map(|index| { + let t = (index as f32 + 0.5) / sample_count as f32; + let progress = (t + start).rem_euclid(1.0); + + ( + Self::selection_flow_sample_at_distance( + rect, + corner_radius, + perimeter * progress, + ), + t, + ) + }) + .collect() + } + + pub(in crate::overlay) fn selection_flow_sample_at_distance( + rect: Rect, + corner_radius: f32, + distance: f32, + ) -> Pos2 { + if corner_radius <= f32::EPSILON { + let perimeter = Self::selection_flow_perimeter(rect, 0.0); + let keep = distance.rem_euclid(perimeter); + let edge_top = rect.width(); + let edge_right = rect.height(); + + if keep < edge_top { + return Pos2::new(rect.min.x + keep, rect.min.y); + } + if keep < edge_top + edge_right { + return Pos2::new(rect.max.x, rect.min.y + (keep - edge_top)); + } + if keep < edge_top * 2.0 + edge_right { + return Pos2::new(rect.max.x - (keep - edge_top - edge_right), rect.max.y); + } + + return Pos2::new(rect.min.x, rect.max.y - (keep - edge_top * 2.0 - edge_right)); + } + + let x0 = rect.min.x; + let x1 = rect.max.x; + let y0 = rect.min.y; + let y1 = rect.max.y; + let perimeter = Self::selection_flow_perimeter(rect, corner_radius); + let remain = distance.rem_euclid(perimeter); + let edge_top_len = (rect.width() - corner_radius * 2.0).max(0.0); + let edge_right_len = (rect.height() - corner_radius * 2.0).max(0.0); + let corner_len = std::f32::consts::FRAC_PI_2 * corner_radius; + + if remain < edge_top_len { + return Pos2::new(x0 + corner_radius + remain, y0); + } + + let mut offset = remain - edge_top_len; + + if offset < corner_len { + let angle = -std::f32::consts::FRAC_PI_2 + offset / corner_radius; + + return Pos2::new( + x1 - corner_radius + corner_radius * angle.cos(), + y0 + corner_radius + corner_radius * angle.sin(), + ); + } + + offset -= corner_len; + + if offset < edge_right_len { + return Pos2::new(x1, y0 + corner_radius + offset); + } + + offset -= edge_right_len; + + if offset < corner_len { + let angle = offset / corner_radius; + + return Pos2::new( + x1 - corner_radius + corner_radius * angle.cos(), + y1 - corner_radius + corner_radius * angle.sin(), + ); + } + + offset -= corner_len; + + if offset < edge_top_len { + return Pos2::new(x1 - corner_radius - offset, y1); + } + + offset -= edge_top_len; + + if offset < corner_len { + let angle = std::f32::consts::FRAC_PI_2 + offset / corner_radius; + + return Pos2::new( + x0 + corner_radius + corner_radius * angle.cos(), + y1 - corner_radius + corner_radius * angle.sin(), + ); + } + + offset -= corner_len; + + if offset < edge_right_len { + return Pos2::new(x0, y1 - corner_radius - offset); + } + + offset -= edge_right_len; + + if offset < corner_len { + let angle = std::f32::consts::PI + offset / corner_radius; + + return Pos2::new( + x0 + corner_radius + corner_radius * angle.cos(), + y0 + corner_radius + corner_radius * angle.sin(), + ); + } + + Pos2::new(x0 + corner_radius, y0) + } + + pub(in crate::overlay) fn selection_flow_perimeter(rect: Rect, corner_radius: f32) -> f32 { + let edge_top_len = (rect.width() - corner_radius * 2.0).max(0.0); + let edge_right_len = (rect.height() - corner_radius * 2.0).max(0.0); + let corner_len = std::f32::consts::FRAC_PI_2 * corner_radius; + + 2.0 * (edge_top_len + edge_right_len) + 4.0 * corner_len + } + + pub(in crate::overlay) fn selection_flow_color( + progress: f32, + theme: HudTheme, + alpha_scale: f32, + intensity: f32, + ) -> Color32 { + let palette = Self::selection_flow_palette(theme); + let normalized = progress.rem_euclid(1.0); + let band_position = normalized * palette.len() as f32; + let band = band_position.floor() as usize % palette.len(); + let local = band_position - band as f32; + let (r0, g0, b0) = palette[band]; + let (r1, g1, b1) = palette[(band + 1) % palette.len()]; + let blend = |a: u8, b: u8, ratio: f32| -> u8 { + (a as f32 + (b as f32 - a as f32) * ratio).clamp(0.0, 255.0).round() as u8 + }; + let theme_alpha = 1.0; + let alpha = (255.0 * alpha_scale * intensity * theme_alpha).clamp(0.0, 255.0); + + Color32::from_rgba_unmultiplied( + blend(r0, r1, local), + blend(g0, g1, local), + blend(b0, b1, local), + alpha as u8, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(in crate::overlay) fn render_frozen_toolbar_ui( + ctx: &egui::Context, + state: &OverlayState, + monitor: MonitorRect, + theme: HudTheme, + toolbar_placement: ToolbarPlacement, + hud_blur_active: bool, + hud_opaque: bool, + hud_opacity: f32, + hud_milk_amount: f32, + hud_tint_hue: f32, + toolbar_state: Option<&mut FrozenToolbarState>, + pointer_state: Option, + hud_pill_out: &mut Option, + ) { + let Some(toolbar_state) = toolbar_state else { + return; + }; + + if !matches!(state.mode, OverlayMode::Frozen) || !toolbar_state.visible { + return; + } + if state.monitor != Some(monitor) { + return; + } + + let (cursor, left_button_down) = if let Some(pointer_state) = pointer_state { + (pointer_state.cursor_local, pointer_state.left_button_down) + } else { + toolbar_state.dragging = false; + + (Pos2::new(-1.0, -1.0), false) + }; + let toolbar_size = Self::frozen_toolbar_size(toolbar_state); + let screen_rect = ctx.input(|i| i.viewport_rect()); + let capture_rect = Self::frozen_toolbar_capture_rect(state, monitor, screen_rect); + let Some(toolbar_pos) = Self::resolve_frozen_toolbar_birth( + ctx, + state, + monitor, + toolbar_state, + screen_rect, + capture_rect, + toolbar_size, + toolbar_placement, + ) else { + return; + }; + + #[cfg(any(not(target_os = "macos"), test))] + { + if !advance_frozen_toolbar_readiness_sample_state(toolbar_state, screen_rect) { + ctx.request_repaint(); + + return; + } + } + + Self::draw_frozen_toolbar( + ctx, + toolbar_state, + monitor, + screen_rect, + toolbar_pos, + toolbar_size, + theme, + hud_blur_active, + hud_opaque, + hud_opacity, + hud_milk_amount, + hud_tint_hue, + cursor, + left_button_down, + hud_pill_out, + ); + } + + pub(in crate::overlay) fn frozen_toolbar_tools( + toolbar_state: &FrozenToolbarState, + ) -> &'static [FrozenToolbarTool] { + #[cfg(target_os = "macos")] + const TOOLS_SCROLL_MODE: [FrozenToolbarTool; 3] = + [FrozenToolbarTool::Ocr, FrozenToolbarTool::Copy, FrozenToolbarTool::Save]; + #[cfg(not(target_os = "macos"))] + const TOOLS_SCROLL_MODE: [FrozenToolbarTool; 2] = + [FrozenToolbarTool::Copy, FrozenToolbarTool::Save]; + #[cfg(target_os = "macos")] + const TOOLS_WITH_SCROLL_AND_AUTO_CENTER: [FrozenToolbarTool; 11] = [ + FrozenToolbarTool::Pointer, + FrozenToolbarTool::Pen, + FrozenToolbarTool::Text, + FrozenToolbarTool::Mosaic, + FrozenToolbarTool::Undo, + FrozenToolbarTool::Redo, + FrozenToolbarTool::AutoCenter, + FrozenToolbarTool::Scroll, + FrozenToolbarTool::Ocr, + FrozenToolbarTool::Copy, + FrozenToolbarTool::Save, + ]; + #[cfg(not(target_os = "macos"))] + const TOOLS_WITH_SCROLL_AND_AUTO_CENTER: [FrozenToolbarTool; 10] = [ + FrozenToolbarTool::Pointer, + FrozenToolbarTool::Pen, + FrozenToolbarTool::Text, + FrozenToolbarTool::Mosaic, + FrozenToolbarTool::Undo, + FrozenToolbarTool::Redo, + FrozenToolbarTool::AutoCenter, + FrozenToolbarTool::Scroll, + FrozenToolbarTool::Copy, + FrozenToolbarTool::Save, + ]; + #[cfg(target_os = "macos")] + const TOOLS_WITH_AUTO_CENTER: [FrozenToolbarTool; 10] = [ + FrozenToolbarTool::Pointer, + FrozenToolbarTool::Pen, + FrozenToolbarTool::Text, + FrozenToolbarTool::Mosaic, + FrozenToolbarTool::Undo, + FrozenToolbarTool::Redo, + FrozenToolbarTool::AutoCenter, + FrozenToolbarTool::Ocr, + FrozenToolbarTool::Copy, + FrozenToolbarTool::Save, + ]; + #[cfg(not(target_os = "macos"))] + const TOOLS_WITH_AUTO_CENTER: [FrozenToolbarTool; 9] = [ + FrozenToolbarTool::Pointer, + FrozenToolbarTool::Pen, + FrozenToolbarTool::Text, + FrozenToolbarTool::Mosaic, + FrozenToolbarTool::Undo, + FrozenToolbarTool::Redo, + FrozenToolbarTool::AutoCenter, + FrozenToolbarTool::Copy, + FrozenToolbarTool::Save, + ]; + #[cfg(target_os = "macos")] + const TOOLS_WITH_SCROLL: [FrozenToolbarTool; 10] = [ + FrozenToolbarTool::Pointer, + FrozenToolbarTool::Pen, + FrozenToolbarTool::Text, + FrozenToolbarTool::Mosaic, + FrozenToolbarTool::Undo, + FrozenToolbarTool::Redo, + FrozenToolbarTool::Scroll, + FrozenToolbarTool::Ocr, + FrozenToolbarTool::Copy, + FrozenToolbarTool::Save, + ]; + #[cfg(not(target_os = "macos"))] + const TOOLS_WITH_SCROLL: [FrozenToolbarTool; 9] = [ + FrozenToolbarTool::Pointer, + FrozenToolbarTool::Pen, + FrozenToolbarTool::Text, + FrozenToolbarTool::Mosaic, + FrozenToolbarTool::Undo, + FrozenToolbarTool::Redo, + FrozenToolbarTool::Scroll, + FrozenToolbarTool::Copy, + FrozenToolbarTool::Save, + ]; + #[cfg(target_os = "macos")] + const TOOLS_WITHOUT_SCROLL: [FrozenToolbarTool; 9] = [ + FrozenToolbarTool::Pointer, + FrozenToolbarTool::Pen, + FrozenToolbarTool::Text, + FrozenToolbarTool::Mosaic, + FrozenToolbarTool::Undo, + FrozenToolbarTool::Redo, + FrozenToolbarTool::Ocr, + FrozenToolbarTool::Copy, + FrozenToolbarTool::Save, + ]; + #[cfg(not(target_os = "macos"))] + const TOOLS_WITHOUT_SCROLL: [FrozenToolbarTool; 8] = [ + FrozenToolbarTool::Pointer, + FrozenToolbarTool::Pen, + FrozenToolbarTool::Text, + FrozenToolbarTool::Mosaic, + FrozenToolbarTool::Undo, + FrozenToolbarTool::Redo, + FrozenToolbarTool::Copy, + FrozenToolbarTool::Save, + ]; + + if toolbar_state.scroll_capture_active { + &TOOLS_SCROLL_MODE + } else if toolbar_state.auto_center_available && toolbar_state.scroll_capture_available { + &TOOLS_WITH_SCROLL_AND_AUTO_CENTER + } else if toolbar_state.auto_center_available { + &TOOLS_WITH_AUTO_CENTER + } else if toolbar_state.scroll_capture_available { + &TOOLS_WITH_SCROLL + } else { + &TOOLS_WITHOUT_SCROLL + } + } + + pub(in crate::overlay) fn frozen_toolbar_size(toolbar_state: &FrozenToolbarState) -> Vec2 { + let tool_count = Self::frozen_toolbar_tools(toolbar_state).len() as f32; + let spacing_count = (tool_count - 1.0).max(0.0); + let width = tool_count * FROZEN_TOOLBAR_BUTTON_SIZE_POINTS + + spacing_count * FROZEN_TOOLBAR_ITEM_SPACING_POINTS + + 2.0 * HUD_PILL_INNER_MARGIN_X_POINTS + + 2.0 * HUD_PILL_STROKE_WIDTH_POINTS; + let height = toolbar_state.pill_height_points.unwrap_or(TOOLBAR_EXPANDED_HEIGHT_PX); + + Vec2::new(width, height) + } + + #[allow(clippy::too_many_arguments)] + pub(in crate::overlay) fn resolve_frozen_toolbar_birth( + ctx: &egui::Context, + state: &OverlayState, + monitor: MonitorRect, + toolbar_state: &mut FrozenToolbarState, + screen_rect: Rect, + capture_rect: Rect, + toolbar_size: Vec2, + toolbar_placement: ToolbarPlacement, + ) -> Option { + if let Some(pos) = toolbar_state.floating_position { + return Some(pos); + } + + let screen_size_points = screen_rect.size(); + + tracing::trace!( + monitor_id = monitor.id, + frozen_generation = state.frozen_generation, + screen_rect = ?screen_rect, + screen_size_points = ?screen_size_points, + pixels_per_point = ctx.pixels_per_point(), + last_screen_size_points = ?toolbar_state.layout_last_screen_size_points, + stable_frames = toolbar_state.layout_stable_frames, + "Frozen toolbar birth attempt." + ); + + let needs_new_sample = frozen_toolbar_needs_new_sample( + toolbar_state.layout_last_screen_size_points, + screen_size_points, + ); + + if needs_new_sample { + toolbar_state.layout_last_screen_size_points = Some(screen_size_points); + toolbar_state.layout_stable_frames = 0; + toolbar_state.needs_redraw = true; + + tracing::debug!( + monitor_id = monitor.id, + frozen_generation = state.frozen_generation, + new_screen_size_points = ?screen_size_points, + "Frozen toolbar waiting for stable screen rect (new sample)." + ); + + ctx.request_repaint(); + + return None; + } + if toolbar_state.layout_stable_frames < 1 { + toolbar_state.layout_stable_frames = + toolbar_state.layout_stable_frames.saturating_add(1); + toolbar_state.needs_redraw = true; + + tracing::debug!( + monitor_id = monitor.id, + frozen_generation = state.frozen_generation, + screen_size_points = ?screen_size_points, + stable_frames = toolbar_state.layout_stable_frames, + "Frozen toolbar waiting for stable screen rect (confirm)." + ); + + ctx.request_repaint(); + + return None; + } + + let default_pos = Self::frozen_toolbar_default_pos( + screen_rect, + capture_rect, + toolbar_size, + toolbar_placement, + ); + + tracing::debug!( + monitor_id = monitor.id, + frozen_generation = state.frozen_generation, + toolbar_size_points = ?toolbar_size, + default_pos = ?default_pos, + "Frozen toolbar birth resolved." + ); + + toolbar_state.default_slot_position = Some(default_pos); + toolbar_state.floating_position = Some(default_pos); + + Some(default_pos) + } + + pub(in crate::overlay) fn frozen_toolbar_capture_rect( + state: &OverlayState, + monitor: MonitorRect, + screen_rect: Rect, + ) -> Rect { + let Some(capture_rect) = state.frozen_capture_rect else { + return screen_rect; + }; + let Some(frozen_monitor) = state.monitor else { + return screen_rect; + }; + + if frozen_monitor != monitor { + return screen_rect; + } + + let capture_rect = Rect::from_min_size( + Pos2::new(capture_rect.x as f32, capture_rect.y as f32), + Vec2::new(capture_rect.width as f32, capture_rect.height as f32), + ); + + capture_rect.intersect(screen_rect) + } + + pub(in crate::overlay) fn frozen_toolbar_default_pos( + screen_rect: Rect, + capture_rect: Rect, + toolbar_size: Vec2, + toolbar_placement: ToolbarPlacement, + ) -> Pos2 { + let y = match toolbar_placement { + ToolbarPlacement::Bottom => { + let below_y = capture_rect.max.y + TOOLBAR_CAPTURE_GAP_PX; + let within_screen = + below_y + toolbar_size.y + TOOLBAR_SCREEN_MARGIN_PX <= screen_rect.max.y; + + if within_screen { + below_y + } else { + capture_rect.max.y - TOOLBAR_SCREEN_MARGIN_PX - toolbar_size.y + } + }, + ToolbarPlacement::Top => { + let above_y = capture_rect.min.y - TOOLBAR_CAPTURE_GAP_PX - toolbar_size.y; + let within_screen = above_y >= screen_rect.min.y + TOOLBAR_SCREEN_MARGIN_PX; + + if within_screen { above_y } else { capture_rect.min.y + TOOLBAR_SCREEN_MARGIN_PX } + }, + }; + let min_y = screen_rect.min.y + TOOLBAR_SCREEN_MARGIN_PX; + let max_y = (screen_rect.max.y - toolbar_size.y - TOOLBAR_SCREEN_MARGIN_PX).max(min_y); + let x = Self::frozen_toolbar_default_x(screen_rect, toolbar_size, capture_rect.center().x); + let y = y.max(min_y).min(max_y); + + Pos2::new(x, y) + } + + pub(in crate::overlay) fn frozen_toolbar_default_x( + screen_rect: Rect, + toolbar_size: Vec2, + anchor_center_x: f32, + ) -> f32 { + let min_x = screen_rect.min.x + TOOLBAR_SCREEN_MARGIN_PX; + let max_x = (screen_rect.max.x - toolbar_size.x - TOOLBAR_SCREEN_MARGIN_PX).max(min_x); + + (anchor_center_x - toolbar_size.x / 2.0).clamp(min_x, max_x) + } + + #[allow(clippy::too_many_arguments)] + pub(in crate::overlay) fn draw_frozen_toolbar( + ctx: &egui::Context, + toolbar_state: &mut FrozenToolbarState, + monitor: MonitorRect, + screen_rect: Rect, + toolbar_pos: Pos2, + toolbar_size: Vec2, + theme: HudTheme, + hud_blur_active: bool, + hud_opaque: bool, + hud_opacity: f32, + hud_milk_amount: f32, + hud_tint_hue: f32, + cursor: Pos2, + left_button_down: bool, + hud_pill_out: &mut Option, + ) { + Area::new(Id::new(format!("frozen-toolbar-{}", monitor.id))) + .order(Order::Foreground) + .fixed_pos(toolbar_pos) + .show(ctx, |ui| { + let (rect, response) = + ui.allocate_exact_size(toolbar_size, Sense::click_and_drag()); + let body_fill = Self::tinted_hud_body_fill( + theme, + hud_blur_active, + hud_opaque, + hud_opacity, + hud_milk_amount, + hud_tint_hue, + ); + let toolbar_frame = + Self::hud_pill_frame(theme, hud_opaque, hud_opacity, body_fill, false); + + if response.drag_started() { + toolbar_state.dragging = true; + toolbar_state.floating_position = Some(toolbar_pos); + toolbar_state.drag_offset = cursor - toolbar_pos; + } + if toolbar_state.dragging && left_button_down { + let desired_pos = cursor - toolbar_state.drag_offset; + + toolbar_state.floating_position = Some(Self::clamp_toolbar_position( + screen_rect, + toolbar_size, + desired_pos, + TOOLBAR_SCREEN_MARGIN_PX, + TOOLBAR_SCREEN_MARGIN_PX, + )); + } else if toolbar_state.dragging { + toolbar_state.dragging = false; + } + + // Draw the capsule ourselves at the exact allocated rect. This keeps the visible pill + // and the blur rect perfectly aligned (no shrink-to-content surprises on first frame). + ui.painter().rect_filled( + rect, + f32::from(HUD_PILL_CORNER_RADIUS_POINTS), + toolbar_frame.fill, + ); + ui.painter().rect_stroke( + rect.shrink(0.5), + CornerRadius::same(HUD_PILL_CORNER_RADIUS_POINTS), + toolbar_frame.stroke, + StrokeKind::Inside, + ); + + let inner_stroke_color = match theme { + HudTheme::Dark => Color32::from_rgba_unmultiplied(0, 0, 0, 44), + HudTheme::Light => Color32::from_rgba_unmultiplied(255, 255, 255, 140), + }; + let inner_stroke = Stroke::new(1.0, inner_stroke_color); + let inner_rect = rect.shrink(1.0); + + ui.painter().rect_stroke( + inner_rect, + CornerRadius::same(HUD_PILL_CORNER_RADIUS_POINTS.saturating_sub(1)), + inner_stroke, + StrokeKind::Inside, + ); + + let inner_rect = rect.shrink2(egui::vec2( + HUD_PILL_INNER_MARGIN_X_POINTS, + HUD_PILL_INNER_MARGIN_Y_POINTS, + )); + let _ = ui.scope_builder(UiBuilder::new().max_rect(inner_rect), |ui| { + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + ui.spacing_mut().item_spacing = egui::vec2(4.0, 0.0); + + Self::render_frozen_toolbar_controls(ui, toolbar_state, theme); + }); + }); + + *hud_pill_out = Some(HudPillGeometry { + rect, + radius_points: f32::from(HUD_PILL_CORNER_RADIUS_POINTS), + }); + }); + } + + #[allow(clippy::too_many_arguments)] + pub(in crate::overlay) fn render_frozen_toolbar_controls( + ui: &mut Ui, + toolbar_state: &mut FrozenToolbarState, + theme: HudTheme, + ) { + if toolbar_state.selected_tool == FrozenToolbarTool::Scroll { + toolbar_state.selected_tool = FrozenToolbarTool::Pointer; + } + + let tools = Self::frozen_toolbar_tools(toolbar_state); + let button_size = FROZEN_TOOLBAR_BUTTON_SIZE_POINTS; + let button_font_size = 18.0; + let item_spacing = FROZEN_TOOLBAR_ITEM_SPACING_POINTS; + let hit_area_inset = 5.0; + + ui.horizontal_centered(|ui| { + ui.spacing_mut().item_spacing.x = item_spacing; + + for tool in tools { + let is_mode_tool = tool.is_mode_tool(); + let action_ready = + !tool.requires_final_capture() || toolbar_state.final_capture_ready; + let response = + ui.allocate_response(Vec2::new(button_size, button_size), Sense::click()); + let hovered = action_ready && response.hovered(); + let response = if action_ready { + response.on_hover_text(tool.label()) + } else { + response.on_hover_text("Preparing capture...") + }; + let hover_anim: f32 = if hovered { 1.0 } else { 0.0 }; + + if action_ready && response.clicked() { + let tool = *tool; + + if is_mode_tool { + toolbar_state.selected_tool = tool; + } else { + toolbar_state.pending_action = Some(tool); + } + + toolbar_state.needs_redraw = true; + } + + let selected = is_mode_tool && *tool == toolbar_state.selected_tool; + let selected_anim: f32 = if selected { 1.0 } else { 0.0 }; + let glow = hover_anim.max(selected_anim); + let icon_font = if selected { + FontFamily::Name("phosphor-fill".into()) + } else { + FontFamily::Proportional + }; + let style = + Self::frozen_toolbar_button_style(theme, action_ready, hovered, selected); + + if glow > 0.0 { + let bg_rect = response.rect.shrink(hit_area_inset); + + ui.painter().rect_filled(bg_rect, 8.0, style.bg_color); + } + + if let Some(border_color) = style.border_color { + ui.painter().rect_stroke( + response.rect.shrink(hit_area_inset), + 8.0, + Stroke::new(1.0, border_color), + StrokeKind::Inside, + ); + } + + ui.painter().text( + response.rect.center(), + Align2::CENTER_CENTER, + tool.icon(), + FontId::new(button_font_size, icon_font), + style.icon_color, + ); + } + }); + } + + pub(in crate::overlay) fn frozen_toolbar_button_style( + theme: HudTheme, + action_ready: bool, + hovered: bool, + selected: bool, + ) -> FrozenToolbarButtonStyle { + let hover_anim = if hovered { 1.0 } else { 0.0 }; + let selected_anim = if selected { 1.0 } else { 0.0 }; + let (normal_color, hover_color, selected_color, hover_bg, selected_bg) = + Self::frozen_toolbar_colors(theme); + let mut icon_color = if action_ready { + normal_color + } else { + Color32::from_rgba_unmultiplied( + normal_color.r(), + normal_color.g(), + normal_color.b(), + (normal_color.a() as f32 * 0.45).round() as u8, + ) + }; + let mut bg_color = Color32::from_rgba_unmultiplied(255, 255, 255, 0); + + if selected_anim > 0.0 { + icon_color = Self::blend_color(icon_color, selected_color, selected_anim); + bg_color = Self::blend_color(bg_color, selected_bg, selected_anim); + } + if hover_anim > 0.0 { + icon_color = Self::blend_color(icon_color, hover_color, hover_anim); + bg_color = Self::blend_color(bg_color, hover_bg, hover_anim * (1.0 - selected_anim)); + } + + FrozenToolbarButtonStyle { icon_color, bg_color, border_color: None } + } + + pub(in crate::overlay) fn frozen_toolbar_colors( + theme: HudTheme, + ) -> (Color32, Color32, Color32, Color32, Color32) { + let (normal_color, hover_color, selected_color) = match theme { + HudTheme::Dark => ( + Color32::from_rgba_unmultiplied(255, 255, 255, 160), + Color32::from_rgba_unmultiplied(255, 255, 255, 222), + Color32::from_rgba_unmultiplied(255, 255, 255, 255), + ), + HudTheme::Light => ( + Color32::from_rgba_unmultiplied(28, 28, 32, 182), + Color32::from_rgba_unmultiplied(28, 28, 32, 220), + Color32::from_rgba_unmultiplied(28, 28, 32, 255), + ), + }; + let hover_bg = match theme { + HudTheme::Dark => Color32::from_rgba_unmultiplied(255, 255, 255, 20), + HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 20), + }; + let selected_bg = match theme { + HudTheme::Dark => Color32::from_rgba_unmultiplied(255, 255, 255, 28), + HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 24), + }; + + (normal_color, hover_color, selected_color, hover_bg, selected_bg) + } + + pub(in crate::overlay) fn blend_color(a: Color32, b: Color32, t: f32) -> Color32 { + let t = t.clamp(0.0, 1.0); + let u = 1.0 - t; + + Color32::from_rgba_unmultiplied( + ((f32::from(a.r()) * u + f32::from(b.r()) * t).round().clamp(0.0, 255.0)) as u8, + ((f32::from(a.g()) * u + f32::from(b.g()) * t).round().clamp(0.0, 255.0)) as u8, + ((f32::from(a.b()) * u + f32::from(b.b()) * t).round().clamp(0.0, 255.0)) as u8, + ((f32::from(a.a()) * u + f32::from(b.a()) * t).round().clamp(0.0, 255.0)) as u8, + ) + } + + pub(in crate::overlay) fn clamp_toolbar_position( + screen_rect: Rect, + toolbar_size: Vec2, + cursor: Pos2, + side_margin: f32, + top_margin: f32, + ) -> Pos2 { + let min_x = screen_rect.min.x + side_margin; + let min_y = screen_rect.min.y + top_margin; + let max_x = (screen_rect.max.x - toolbar_size.x - side_margin).max(min_x); + let max_y = (screen_rect.max.y - toolbar_size.y - top_margin * 0.5).max(min_y); + + Pos2::new(cursor.x.clamp(min_x, max_x.max(min_x)), cursor.y.clamp(min_y, max_y.max(min_y))) + } +} diff --git a/packages/rsnap-overlay/src/overlay/rendering/hud_rendering.rs b/packages/rsnap-overlay/src/overlay/rendering/hud_rendering.rs new file mode 100644 index 00000000..4afe5df9 --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/rendering/hud_rendering.rs @@ -0,0 +1,571 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +pub(super) struct LiveLoupeTexture { + texture: TextureHandle, + patch_size_px: [usize; 2], + rgba: Vec, +} + +impl WindowRenderer { + pub(in crate::overlay::rendering) fn should_draw_hud( + state: &OverlayState, + monitor: MonitorRect, + ) -> bool { + if cfg!(target_os = "macos") && matches!(state.mode, OverlayMode::Frozen) { + return true; + } + + !matches!(state.mode, OverlayMode::Frozen) + || state.monitor != Some(monitor) + || state.frozen_image.is_some() + || state.error_message.is_some() + } + + #[allow(clippy::too_many_arguments)] + pub(in crate::overlay::rendering) fn render_hud( + &mut self, + ctx: &egui::Context, + state: &OverlayState, + monitor: MonitorRect, + cursor: GlobalPoint, + local_cursor: Pos2, + hud_compact: bool, + hud_anchor: HudAnchor, + show_alt_hint_keycap: bool, + hud_blur_active: bool, + hud_opaque: bool, + hud_opacity: f32, + hud_milk_amount: f32, + hud_tint_hue: f32, + theme: HudTheme, + hud_pill_out: &mut Option, + ) { + let (hud_x, hud_y) = match hud_anchor { + HudAnchor::Cursor => (local_cursor.x + 14.0, local_cursor.y + 14.0), + }; + + Area::new("hud".into()).order(Order::Foreground).fixed_pos(Pos2::new(hud_x, hud_y)).show( + ctx, + |ui| { + self.render_hud_frame( + ui, + state, + monitor, + cursor, + hud_compact, + show_alt_hint_keycap, + hud_blur_active, + hud_opaque, + hud_opacity, + hud_milk_amount, + hud_tint_hue, + theme, + hud_pill_out, + ); + }, + ); + } + + #[allow(clippy::too_many_arguments)] + fn render_hud_frame( + &mut self, + ui: &mut Ui, + state: &OverlayState, + monitor: MonitorRect, + cursor: GlobalPoint, + hud_compact: bool, + show_alt_hint_keycap: bool, + hud_blur_active: bool, + hud_opaque: bool, + hud_opacity: f32, + hud_milk_amount: f32, + hud_tint_hue: f32, + theme: HudTheme, + hud_pill_out: &mut Option, + ) { + let body_fill = Self::tinted_hud_body_fill( + theme, + hud_blur_active, + hud_opaque, + hud_opacity, + hud_milk_amount, + hud_tint_hue, + ); + let pill_frame = + Self::hud_pill_frame(theme, hud_opaque, hud_opacity, body_fill, !hud_compact); + let inner = pill_frame.show(ui, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(10.0, 6.0); + + if let Some(err) = &state.error_message { + let err_color = match theme { + HudTheme::Dark => Color32::from_rgba_unmultiplied(235, 235, 245, 235), + HudTheme::Light => Color32::from_rgba_unmultiplied(28, 28, 32, 235), + }; + + ui.label(RichText::new(err).color(err_color).monospace()); + } else { + Self::render_hud_content(ui, state, monitor, cursor, show_alt_hint_keycap, theme); + } + }); + let pill_rect = inner.response.rect; + + *hud_pill_out = Some(HudPillGeometry { + rect: pill_rect, + radius_points: f32::from(HUD_PILL_CORNER_RADIUS_POINTS), + }); + + if hud_compact { + return; + } + + let inner_stroke_color = match theme { + HudTheme::Dark => Color32::from_rgba_unmultiplied(0, 0, 0, 44), + HudTheme::Light => Color32::from_rgba_unmultiplied(255, 255, 255, 140), + }; + let inner_stroke = Stroke::new(1.0, inner_stroke_color); + let inner_rect = pill_rect.shrink(1.0); + + ui.painter().rect_stroke( + inner_rect, + CornerRadius::same(HUD_PILL_CORNER_RADIUS_POINTS.saturating_sub(1)), + inner_stroke, + StrokeKind::Inside, + ); + + if !hud_compact { + self.render_loupe_tile( + ui, + state, + pill_rect, + hud_blur_active, + hud_opaque, + body_fill, + theme, + ); + } + } + + pub(in crate::overlay::rendering) fn hud_pill_frame( + theme: HudTheme, + _hud_opaque: bool, + _hud_opacity: f32, + body_fill: Color32, + with_shadow: bool, + ) -> Frame { + let outer_stroke_color = match theme { + HudTheme::Dark => Color32::from_rgba_unmultiplied(255, 255, 255, 40), + HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 44), + }; + let pill_shadow = if with_shadow { + egui::epaint::Shadow { + offset: [0, 0], + blur: 10, + spread: 0, + color: match theme { + HudTheme::Dark => Color32::from_rgba_unmultiplied(0, 0, 0, 28), + HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 18), + }, + } + } else { + egui::Shadow::NONE + }; + + Frame { + fill: body_fill, + stroke: Stroke::new(1.0, outer_stroke_color), + shadow: pill_shadow, + corner_radius: CornerRadius::same(HUD_PILL_CORNER_RADIUS_POINTS), + inner_margin: Margin::symmetric(12, 8), + ..Frame::default() + } + } + + fn render_hud_content( + ui: &mut Ui, + state: &OverlayState, + monitor: MonitorRect, + cursor: GlobalPoint, + show_alt_hint_keycap: bool, + theme: HudTheme, + ) { + let (label_color, secondary_color) = Self::hud_text_colors(theme); + let pos_text = hud_helpers::format_live_hud_position_text(monitor, cursor); + let (hex_text, rgb_text) = hud_helpers::format_live_hud_rgb_text(state.rgb); + let swatch_size = egui::vec2(10.0, 10.0); + + ui.vertical(|ui| { + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + ui.label(RichText::new(pos_text).color(label_color).monospace()); + ui.label(RichText::new("•").color(secondary_color).monospace()); + + let (rect, _) = ui.allocate_exact_size(swatch_size, Sense::hover()); + let swatch_color = match state.rgb { + Some(rgb) => Color32::from_rgb(rgb.r, rgb.g, rgb.b), + None => Color32::from_rgba_unmultiplied(255, 255, 255, 26), + }; + + ui.painter().rect_filled(rect, 3.0, swatch_color); + ui.painter().rect_stroke( + rect, + 3.0, + Stroke::new( + 1.0, + match theme { + HudTheme::Dark => Color32::from_rgba_unmultiplied(255, 255, 255, 36), + HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 44), + }, + ), + StrokeKind::Inside, + ); + ui.label(RichText::new(hex_text).color(label_color).monospace()); + ui.label(RichText::new(rgb_text).color(secondary_color).monospace()); + + if show_alt_hint_keycap { + let alt_active = state.alt_held; + let (keycap_fill, keycap_stroke, keycap_text) = match theme { + HudTheme::Dark if alt_active => ( + Color32::from_rgba_unmultiplied(255, 255, 255, 40), + Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 70)), + label_color, + ), + HudTheme::Dark => ( + Color32::from_rgba_unmultiplied(255, 255, 255, 18), + Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 30)), + secondary_color, + ), + HudTheme::Light if alt_active => ( + Color32::from_rgba_unmultiplied(0, 0, 0, 22), + Stroke::new(1.0, Color32::from_rgba_unmultiplied(0, 0, 0, 64)), + label_color, + ), + HudTheme::Light => ( + Color32::from_rgba_unmultiplied(0, 0, 0, 12), + Stroke::new(1.0, Color32::from_rgba_unmultiplied(0, 0, 0, 32)), + secondary_color, + ), + }; + + Frame { + fill: keycap_fill, + stroke: keycap_stroke, + corner_radius: CornerRadius::same(6), + inner_margin: Margin::symmetric(6, 2), + ..Frame::default() + } + .show(ui, |ui| { + ui.label(RichText::new("Tab").color(keycap_text).monospace()); + }); + } + }); + }); + } + + pub(in crate::overlay::rendering) fn hud_text_colors(theme: HudTheme) -> (Color32, Color32) { + match theme { + HudTheme::Dark => ( + Color32::from_rgba_unmultiplied(235, 235, 245, 235), + Color32::from_rgba_unmultiplied(235, 235, 245, 150), + ), + HudTheme::Light => ( + Color32::from_rgba_unmultiplied(28, 28, 32, 235), + Color32::from_rgba_unmultiplied(28, 28, 32, 160), + ), + } + } + + #[allow(clippy::too_many_arguments)] + fn render_loupe_tile( + &mut self, + ui: &mut Ui, + state: &OverlayState, + pill_rect: Rect, + hud_blur_active: bool, + hud_opaque: bool, + body_fill: Color32, + theme: HudTheme, + ) { + let ctx = ui.ctx().clone(); + + self.loupe_tile = None; + + if !state.alt_held { + return; + } + + const CELL: f32 = 10.0; + + let side = hud_helpers::stable_live_loupe_side_points(state, CELL); + let tile_padding = Margin::same(10); + let tile_w = side + (tile_padding.left as f32) + (tile_padding.right as f32); + let tile_h = side + (tile_padding.top as f32) + (tile_padding.bottom as f32); + let screen = ctx.content_rect(); + let gap = HUD_LOUPE_STRIP_GAP_POINTS as f32; + let mut x = pill_rect.min.x; + + x = x.clamp(screen.min.x + 6.0, (screen.max.x - tile_w - 6.0).max(screen.min.x + 6.0)); + + let below_y = pill_rect.max.y + gap; + let above_y = pill_rect.min.y - gap - tile_h; + let mut y = if below_y + tile_h <= screen.max.y { below_y } else { above_y }; + + y = y.clamp(screen.min.y + 6.0, (screen.max.y - tile_h - 6.0).max(screen.min.y + 6.0)); + + let pos = Pos2::new(x, y); + let tile = Area::new(Id::new("rsnap-loupe-tile")) + .order(Order::Foreground) + .fixed_pos(pos) + .show(&ctx, |ui| { + let _ = hud_blur_active; + let fill = body_fill; + let outer_stroke_color = match theme { + HudTheme::Dark => Color32::from_rgba_unmultiplied(255, 255, 255, 40), + HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 44), + }; + let outer_stroke = Stroke::new(1.0, outer_stroke_color); + let shadow = egui::epaint::Shadow { + offset: [0, 0], + blur: 10, + spread: 0, + color: match theme { + HudTheme::Dark => Color32::from_rgba_unmultiplied(0, 0, 0, 28), + HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 18), + }, + }; + let frame = Frame { + fill, + stroke: outer_stroke, + shadow, + corner_radius: CornerRadius::same(18), + inner_margin: tile_padding, + ..Frame::default() + }; + + frame.show(ui, |ui| { + ui.set_min_size(Vec2::new(side, side)); + self.render_loupe(ui, state, hud_blur_active, hud_opaque, theme); + }); + }); + + self.loupe_tile = Some(tile.response.rect); + } + + pub(in crate::overlay::rendering) fn render_loupe( + &mut self, + ui: &mut Ui, + state: &OverlayState, + hud_blur_active: bool, + hud_opaque: bool, + theme: HudTheme, + ) { + const CELL: f32 = 10.0; + + let mode = state.mode; + + if matches!(mode, OverlayMode::Live) { + self.render_live_loupe(ui, state, CELL, hud_blur_active, hud_opaque, theme); + } else if matches!(mode, OverlayMode::Frozen) + && (state.frozen_image.is_some() || state.loupe.is_some()) + { + let Some(monitor) = state.monitor else { + return; + }; + let Some(cursor) = state.cursor else { + return; + }; + + self.render_frozen_loupe( + ui, + state, + monitor, + cursor, + CELL, + hud_blur_active, + hud_opaque, + theme, + ); + } + } + + fn sync_live_loupe_texture( + &mut self, + loupe: Option<&crate::state::LoupeSample>, + ) -> Option { + let Some(loupe) = loupe else { + self.live_loupe_texture = None; + + return None; + }; + let patch_size_px = [loupe.patch.width() as usize, loupe.patch.height() as usize]; + let patch_rgba = loupe.patch.as_raw(); + + match self.live_loupe_texture.as_mut() { + Some(cached) if cached.patch_size_px == patch_size_px => { + if cached.rgba != *patch_rgba { + let color_image = ColorImage::from_rgba_unmultiplied( + [patch_size_px[0], patch_size_px[1]], + patch_rgba, + ); + + cached.texture.set(color_image, TextureOptions::NEAREST); + cached.rgba.clone_from(patch_rgba); + } + }, + _ => { + let color_image = ColorImage::from_rgba_unmultiplied( + [patch_size_px[0], patch_size_px[1]], + patch_rgba, + ); + let texture = self.egui_ctx.load_texture( + String::from("live-loupe-image"), + color_image, + TextureOptions::NEAREST, + ); + + self.live_loupe_texture = + Some(LiveLoupeTexture { texture, patch_size_px, rgba: patch_rgba.clone() }); + }, + } + + self.live_loupe_texture.as_ref().map(|cached| cached.texture.id()) + } + + fn render_live_loupe( + &mut self, + ui: &mut Ui, + state: &OverlayState, + cell: f32, + _hud_blur_active: bool, + hud_opaque: bool, + theme: HudTheme, + ) { + let fallback_side_px = state.loupe_patch_side_px.max(1); + let (w, h) = state + .loupe + .as_ref() + .map(|loupe| loupe.patch.dimensions()) + .unwrap_or((fallback_side_px, fallback_side_px)); + let side = hud_helpers::stable_live_loupe_side_points(state, cell); + let (rect, _) = ui.allocate_exact_size(Vec2::new(side, side), Sense::hover()); + let body_fill = hud_helpers::hud_body_fill_srgba8(theme, hud_opaque); + let stroke = Stroke::new(1.0, Color32::from_rgba_unmultiplied(0, 0, 0, 140)); + let placeholder_fill = + Color32::from_rgba_unmultiplied(body_fill[0], body_fill[1], body_fill[2], 255); + let image_rect = + Rect::from_center_size(rect.center(), Vec2::new((w as f32) * cell, (h as f32) * cell)); + + if let Some(texture_id) = self.sync_live_loupe_texture(state.loupe.as_ref()) { + ui.painter().rect_filled(rect, 3.0, placeholder_fill); + ui.painter().image( + texture_id, + image_rect, + Rect::from_min_max(Pos2::new(0.0, 0.0), Pos2::new(1.0, 1.0)), + Color32::WHITE, + ); + } else { + ui.painter().rect_filled(rect, 3.0, placeholder_fill); + } + + ui.painter().rect_stroke(rect, 3.0, stroke, StrokeKind::Outside); + + let center_x = (w / 2) as f32; + let center_y = (h / 2) as f32; + let center_min = + Pos2::new(image_rect.min.x + center_x * cell, image_rect.min.y + center_y * cell); + let center_rect = Rect::from_min_size(center_min, Vec2::splat(cell)); + + ui.painter().rect_stroke( + center_rect, + 0.0, + Stroke::new(2.0, Color32::from_rgba_unmultiplied(255, 255, 255, 180)), + StrokeKind::Inside, + ); + } + + #[allow(clippy::too_many_arguments)] + fn render_frozen_loupe( + &mut self, + ui: &mut Ui, + state: &OverlayState, + monitor: MonitorRect, + cursor: GlobalPoint, + cell: f32, + hud_blur_active: bool, + hud_opaque: bool, + theme: HudTheme, + ) { + if state.loupe.is_some() { + self.render_live_loupe(ui, state, cell, hud_blur_active, hud_opaque, theme); + + return; + } + + const LOUPE_RADIUS_PX: i32 = 5; + const LOUPE_SIDE_PX: i32 = (LOUPE_RADIUS_PX * 2) + 1; + + let side = (LOUPE_SIDE_PX as f32) * cell; + let (rect, _) = ui.allocate_exact_size(Vec2::new(side, side), Sense::hover()); + let Some(image) = state.frozen_image.as_ref() else { + return; + }; + let Some((center_x, center_y)) = monitor.local_u32_pixels(cursor) else { + return; + }; + let (width, height) = image.dimensions(); + let width = width as i32; + let height = height as i32; + let center_x = center_x as i32; + let center_y = center_y as i32; + let stroke = Stroke::new(1.0, Color32::from_rgba_unmultiplied(0, 0, 0, 140)); + let grid_stroke = Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 26)); + + for dy in -LOUPE_RADIUS_PX..=LOUPE_RADIUS_PX { + for dx in -LOUPE_RADIUS_PX..=LOUPE_RADIUS_PX { + let x = center_x + dx; + let y = center_y + dy; + let cell_x = dx + LOUPE_RADIUS_PX; + let cell_y = dy + LOUPE_RADIUS_PX; + let cell_min = Pos2::new( + rect.min.x + (cell_x as f32) * cell, + rect.min.y + (cell_y as f32) * cell, + ); + let cell_rect = Rect::from_min_size(cell_min, Vec2::splat(cell)); + let fill = if x < 0 || y < 0 || x >= width || y >= height { + Color32::from_rgba_unmultiplied(0, 0, 0, 0) + } else { + let pixel = + image.get_pixel_checked(x as u32, y as u32).expect("pixel bounds checked"); + + Color32::from_rgb(pixel.0[0], pixel.0[1], pixel.0[2]) + }; + + ui.painter().rect_filled(cell_rect, 0.0, fill); + } + } + for i in 0..=LOUPE_SIDE_PX { + let x = rect.min.x + (i as f32) * cell; + let y = rect.min.y + (i as f32) * cell; + + ui.painter() + .line_segment([Pos2::new(x, rect.min.y), Pos2::new(x, rect.max.y)], grid_stroke); + ui.painter() + .line_segment([Pos2::new(rect.min.x, y), Pos2::new(rect.max.x, y)], grid_stroke); + } + + ui.painter().rect_stroke(rect, 3.0, stroke, StrokeKind::Outside); + + let center_min = Pos2::new( + rect.min.x + (LOUPE_RADIUS_PX as f32) * cell, + rect.min.y + (LOUPE_RADIUS_PX as f32) * cell, + ); + let center_rect = Rect::from_min_size(center_min, Vec2::splat(cell)); + + ui.painter().rect_stroke( + center_rect, + 0.0, + Stroke::new(2.0, Color32::from_rgba_unmultiplied(255, 255, 255, 180)), + StrokeKind::Inside, + ); + } +} diff --git a/packages/rsnap-overlay/src/overlay/rendering/hud_surface.rs b/packages/rsnap-overlay/src/overlay/rendering/hud_surface.rs new file mode 100644 index 00000000..a6bf8861 --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/rendering/hud_surface.rs @@ -0,0 +1,562 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +impl WindowRenderer { + pub(in crate::overlay::rendering) fn trace_frozen_frame_metrics( + &self, + state: &OverlayState, + monitor: MonitorRect, + size: PhysicalSize, + pixels_per_point: f32, + toolbar_active: bool, + ) { + if !matches!(state.mode, OverlayMode::Frozen) || state.monitor != Some(monitor) { + return; + } + + let screen_size_points = + Vec2::new(size.width as f32 / pixels_per_point, size.height as f32 / pixels_per_point); + + tracing::trace!( + window_id = ?self.window.id(), + monitor_id = monitor.id, + window_scale_factor = self.window.scale_factor(), + monitor_scale_factor = monitor.scale_factor(), + size_in_pixels = ?size, + pixels_per_point, + screen_size_points = ?screen_size_points, + flip_y = false, + frozen_generation = state.frozen_generation, + frozen_image_ready = state.frozen_image.is_some(), + toolbar_active, + "Frozen frame metrics." + ); + } + + pub(in crate::overlay::rendering) fn resolve_hud_draw_config( + state: &OverlayState, + monitor: MonitorRect, + draw_hud: bool, + allow_frozen_surface_bg: bool, + toolbar_active: bool, + show_hud_blur: bool, + hud_opaque: bool, + ) -> HudDrawConfig { + let can_draw_hud = draw_hud && Self::should_draw_hud(state, monitor); + let needs_frozen_surface_bg = + allow_frozen_surface_bg && !draw_hud && matches!(state.mode, OverlayMode::Frozen); + // `show_hud_blur` is a UX toggle for "glass mode". + // - On macOS: HUD uses native compositor blur; toolbar uses native HUD windowing, so shader + // blur stays tied to monitor-aligned overlay windows. + // - On non-macOS: HUD and toolbar remain in overlay windows with shader blur paths. + let hud_glass_active = can_draw_hud && show_hud_blur && !hud_opaque; + let toolbar_glass_active = toolbar_active && show_hud_blur && !hud_opaque; + let use_shader_blur_for_hud = !cfg!(target_os = "macos"); + let needs_shader_blur_bg = + toolbar_glass_active || (hud_glass_active && use_shader_blur_for_hud); + + HudDrawConfig { + can_draw_hud, + needs_frozen_surface_bg, + needs_shader_blur_bg, + hud_glass_active, + } + } + + pub(in crate::overlay::rendering) fn sync_or_clear_hud_bg( + &mut self, + gpu: &GpuContext, + state: &OverlayState, + monitor: MonitorRect, + hud_cfg: HudDrawConfig, + ) -> Result<()> { + if hud_cfg.needs_frozen_surface_bg || hud_cfg.needs_shader_blur_bg { + return self.sync_hud_bg(gpu, state, monitor); + } + + self.hud_bg = None; + self.hud_bg_generation = match state.mode { + OverlayMode::Live => state.live_bg_generation, + OverlayMode::Frozen => state.frozen_generation, + }; + + Ok(()) + } + + pub(in crate::overlay::rendering) fn hud_shader_blur_active( + &self, + state: &OverlayState, + monitor: MonitorRect, + hud_cfg: HudDrawConfig, + ) -> bool { + hud_cfg.needs_shader_blur_bg + && self.hud_bg.is_some() + && match state.mode { + OverlayMode::Live => state.live_bg_monitor == Some(monitor), + OverlayMode::Frozen => state.monitor == Some(monitor), + } + } + + #[allow(clippy::too_many_arguments)] + pub(in crate::overlay) fn draw_loupe_tile_window( + &mut self, + gpu: &GpuContext, + state: &OverlayState, + monitor: MonitorRect, + show_hud_blur: bool, + hud_opaque: bool, + hud_opacity: f32, + hud_fog_amount: f32, + hud_milk_amount: f32, + hud_tint_hue: f32, + theme_mode: ThemeMode, + ) -> Result<()> { + let draw_started_at = Instant::now(); + let mut phase_timings = WindowRendererPhaseTimings::default(); + let (theme, size, pixels_per_point, raw_input) = + self.prepare_window_renderer_input(gpu, monitor, None, theme_mode, &mut phase_timings); + + self.loupe_tile = None; + + let shader_blur_active = !cfg!(target_os = "macos") + && matches!(state.mode, OverlayMode::Frozen) + && show_hud_blur + && !hud_opaque; + let hud_cfg = HudDrawConfig { + can_draw_hud: false, + needs_frozen_surface_bg: false, + needs_shader_blur_bg: shader_blur_active, + hud_glass_active: shader_blur_active, + }; + let sync_hud_bg_started_at = Instant::now(); + + self.sync_or_clear_hud_bg(gpu, state, monitor, hud_cfg)?; + + phase_timings.sync_hud_bg = sync_hud_bg_started_at.elapsed(); + + let hud_shader_blur_active = self.hud_shader_blur_active(state, monitor, hud_cfg); + let hud_blur_active = show_hud_blur && !hud_opaque; + let body_fill = Self::tinted_hud_body_fill( + theme, + hud_blur_active, + hud_opaque, + hud_opacity, + hud_milk_amount, + hud_tint_hue, + ); + let run_loupe_tile_egui_started_at = Instant::now(); + let (full_output, loupe_tile_rect) = self.run_loupe_tile_egui( + raw_input, + state, + theme, + hud_blur_active, + hud_opaque, + body_fill, + ); + + phase_timings.run_egui = run_loupe_tile_egui_started_at.elapsed(); + self.loupe_tile = loupe_tile_rect; + + if hud_shader_blur_active { + self.hud_pill = loupe_tile_rect.map(|rect| HudPillGeometry { + rect, + radius_points: LOUPE_TILE_CORNER_RADIUS_POINTS as f32, + }); + + if self.hud_pill.is_some() { + self.maybe_update_hud_blur_uniform( + gpu, + size, + pixels_per_point, + theme, + hud_shader_blur_active, + hud_fog_amount, + hud_milk_amount, + hud_tint_hue, + &mut phase_timings, + ); + } + } else { + self.hud_pill = None; + } + + let sync_egui_textures_started_at = Instant::now(); + + self.sync_egui_textures(gpu, &full_output); + + phase_timings.sync_egui_textures = sync_egui_textures_started_at.elapsed(); + + let tessellate_started_at = Instant::now(); + let paint_jobs = self.egui_ctx.tessellate(full_output.shapes, pixels_per_point); + + phase_timings.tessellate = tessellate_started_at.elapsed(); + + self.finish_window_renderer_draw( + gpu, + state, + WindowRendererPath::LoupeTile, + monitor, + size, + pixels_per_point, + draw_started_at, + &mut phase_timings, + paint_jobs, + false, + hud_shader_blur_active, + false, + ) + } + + pub(in crate::overlay) fn tinted_hud_body_fill( + theme: HudTheme, + hud_blur_active: bool, + hud_opaque: bool, + hud_opacity: f32, + hud_milk_amount: f32, + hud_tint_hue: f32, + ) -> Color32 { + let mut opacity = if hud_opaque { 1.0 } else { hud_opacity.clamp(0.0, 1.0) }; + + if hud_blur_active { + opacity = opacity.max(hud_helpers::hud_blur_tint_alpha(theme)); + } + + let tint = hud_milk_amount.clamp(0.0, 1.0); + let mut fill = hud_helpers::hud_body_fill_srgba8(theme, false); + let tint_hue = hud_tint_hue.clamp(0.0, 1.0); + let tint_saturation = 1.0; + let (_, _, base_lightness) = hud_helpers::rgb_to_hsl(Rgb::new(fill[0], fill[1], fill[2])); + let tinted_target = hud_helpers::hsl_to_rgb(tint_hue, tint_saturation, base_lightness); + + fn lerp_u8(a: u8, b: u8, t: f32) -> u8 { + ((f32::from(a) + ((f32::from(b) - f32::from(a)) * t)).round().clamp(0.0, 255.0)) as u8 + } + + fill[0] = lerp_u8(fill[0], tinted_target.r, tint); + fill[1] = lerp_u8(fill[1], tinted_target.g, tint); + fill[2] = lerp_u8(fill[2], tinted_target.b, tint); + fill[3] = (opacity * 255.0).round().clamp(0.0, 255.0) as u8; + + Color32::from_rgba_unmultiplied(fill[0], fill[1], fill[2], fill[3]) + } + + #[allow(clippy::too_many_arguments)] + pub(in crate::overlay::rendering) fn run_loupe_tile_egui( + &mut self, + raw_input: egui::RawInput, + state: &OverlayState, + theme: HudTheme, + hud_blur_active: bool, + hud_opaque: bool, + body_fill: Color32, + ) -> (FullOutput, Option) { + let mut loupe_tile_rect = None; + let egui_ctx = self.egui_ctx.clone(); + let full_output = egui_ctx.run_ui(raw_input, |ui| { + let ctx = ui.ctx(); + + if !state.alt_held { + return; + } + + const CELL: f32 = 10.0; + + let side = hud_helpers::stable_live_loupe_side_points(state, CELL); + let tile_padding = Margin::same(10); + let outer_stroke_color = match theme { + HudTheme::Dark => Color32::from_rgba_unmultiplied(255, 255, 255, 40), + HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 44), + }; + let outer_stroke = Stroke::new(1.0, outer_stroke_color); + let shadow = egui::epaint::Shadow { + offset: [0, 0], + blur: 10, + spread: 0, + color: match theme { + HudTheme::Dark => Color32::from_rgba_unmultiplied(0, 0, 0, 28), + HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 18), + }, + }; + let tile_radius = LOUPE_TILE_CORNER_RADIUS_POINTS as u8; + let frame = Frame { + fill: body_fill, + stroke: outer_stroke, + shadow, + corner_radius: CornerRadius::same(tile_radius), + inner_margin: tile_padding, + ..Frame::default() + }; + let pad = 6.0; + + Area::new(Id::new("rsnap-loupe-window")) + .order(Order::Foreground) + .fixed_pos(Pos2::new(pad, pad)) + .show(ctx, |ui| { + let inner = frame.show(ui, |ui| { + ui.set_min_size(Vec2::new(side, side)); + self.render_loupe(ui, state, hud_blur_active, hud_opaque, theme); + }); + let tile_rect = inner.response.rect; + + loupe_tile_rect = Some(tile_rect); + + let inner_stroke_color = match theme { + HudTheme::Dark => Color32::from_rgba_unmultiplied(0, 0, 0, 44), + HudTheme::Light => Color32::from_rgba_unmultiplied(255, 255, 255, 140), + }; + let inner_stroke = Stroke::new(1.0, inner_stroke_color); + let inner_rect = tile_rect.shrink(1.0); + + ui.painter().rect_stroke( + inner_rect, + CornerRadius::same(tile_radius.saturating_sub(1)), + inner_stroke, + StrokeKind::Inside, + ); + }); + }); + + (full_output, loupe_tile_rect) + } + + #[allow(clippy::too_many_arguments)] + pub(in crate::overlay::rendering) fn update_hud_blur_uniform( + &mut self, + gpu: &GpuContext, + size: PhysicalSize, + pixels_per_point: f32, + theme: HudTheme, + hud_fog_amount: f32, + hud_milk_amount: f32, + hud_tint_hue: f32, + ) { + if self.hud_bg.is_none() { + return; + } + + let Some(hud_pill) = self.hud_pill else { + return; + }; + let surface_w = size.width as f32; + let surface_h = size.height as f32; + + if surface_w <= 0.0 || surface_h <= 0.0 { + return; + } + + let max_lod = self.hud_bg.as_ref().map(|bg| bg.max_lod).unwrap_or(0.0); + let rect_min_px = + [hud_pill.rect.min.x * pixels_per_point, hud_pill.rect.min.y * pixels_per_point]; + let rect_size_px = + [hud_pill.rect.width() * pixels_per_point, hud_pill.rect.height() * pixels_per_point]; + let rect_min_size = [rect_min_px[0], rect_min_px[1], rect_size_px[0], rect_size_px[1]]; + let tint = + Self::tinted_hud_body_fill(theme, false, false, 1.0, hud_milk_amount, hud_tint_hue); + let tint_rgba = [ + hud_helpers::srgb8_to_linear_f32(tint[0]), + hud_helpers::srgb8_to_linear_f32(tint[1]), + hud_helpers::srgb8_to_linear_f32(tint[2]), + hud_helpers::hud_blur_tint_alpha(theme), + ]; + let effects = + [hud_fog_amount.clamp(0.0, 1.0), hud_milk_amount.clamp(0.0, 1.0), max_lod, 0.0]; + let u = HudBlurUniformRaw { + rect_min_size, + radius_blur_soft: [ + hud_pill.radius_points * pixels_per_point, + (0.9 + (hud_fog_amount.clamp(0.0, 1.0) * 3.2)) * pixels_per_point, + 1.0 * pixels_per_point, + 0.0, + ], + surface_size_px: [surface_w, surface_h, 0.0, 0.0], + tint_rgba, + effects, + }; + + gpu.queue.write_buffer(&self.hud_blur_uniform, 0, u.as_bytes()); + } + + pub(in crate::overlay::rendering) fn sync_hud_bg( + &mut self, + gpu: &GpuContext, + state: &OverlayState, + monitor: MonitorRect, + ) -> Result<()> { + let (target_generation, target_image) = match state.mode { + OverlayMode::Live if state.live_bg_monitor == Some(monitor) => { + (state.live_bg_generation, state.live_bg_image.as_ref()) + }, + OverlayMode::Frozen if state.monitor == Some(monitor) => { + (state.frozen_generation, state.frozen_image.as_ref()) + }, + OverlayMode::Live => { + self.hud_bg = None; + self.hud_bg_generation = state.live_bg_generation; + + return Ok(()); + }, + OverlayMode::Frozen => { + self.hud_bg = None; + self.hud_bg_generation = state.frozen_generation; + + return Ok(()); + }, + }; + + if self.hud_bg.is_some() && self.hud_bg_generation == target_generation { + if target_image.is_none() { + // Keep displaying the already-uploaded background even if image bytes moved. + return Ok(()); + } + + return Ok(()); + } + + let Some(image) = target_image else { + // Capture is in progress and no image is available yet. + self.hud_bg = None; + self.hud_bg_generation = target_generation; + + return Ok(()); + }; + + self.render_frozen_bg_to_texture(gpu, image, target_generation) + } + + pub(in crate::overlay::rendering) fn render_frozen_bg_to_texture( + &mut self, + gpu: &GpuContext, + image: &RgbaImage, + target_generation: u64, + ) -> Result<()> { + let upload_image = image_helpers::downscale_for_gpu_upload( + image, + gpu.device.limits().max_texture_dimension_2d, + ); + let (width, height) = upload_image.dimensions(); + let max_side = gpu.device.limits().max_texture_dimension_2d; + let mip_level_count = Self::mip_level_count(width, height).min(10); + + debug_assert!(width <= max_side && height <= max_side); + + let texture = gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some("rsnap-frozen-bg texture"), + size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + mip_level_count, + sample_count: 1, + dimension: TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: TextureUsages::TEXTURE_BINDING + | TextureUsages::COPY_DST + | TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let upload_bytes = upload_image.as_raw(); + let bytes_per_pixel = 4_usize; + let unpadded_bytes_per_row = (width as usize) * bytes_per_pixel; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align; + let rgba_padded; + let rgba_bytes: &[u8] = if padded_bytes_per_row == unpadded_bytes_per_row { + upload_bytes + } else { + let src = upload_bytes; + + rgba_padded = image_helpers::pad_rows( + src, + unpadded_bytes_per_row, + padded_bytes_per_row, + height as usize, + ); + + &rgba_padded + }; + + gpu.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: Origin3d::ZERO, + aspect: TextureAspect::All, + }, + rgba_bytes, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_bytes_per_row as u32), + rows_per_image: Some(height), + }, + wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + ); + self.generate_mipmaps(gpu, &texture, mip_level_count); + + let view = texture.create_view(&TextureViewDescriptor::default()); + let hud_blur_bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("rsnap-hud-blur bind group"), + layout: &self.hud_blur_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: BindingResource::TextureView(&view) }, + wgpu::BindGroupEntry { + binding: 1, + resource: BindingResource::Sampler(&self.bg_sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: self.hud_blur_uniform.as_entire_binding(), + }, + ], + }); + let mipgen_bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("rsnap-mipgen fullscreen bind group"), + layout: &self.mipgen_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: BindingResource::TextureView(&view) }, + wgpu::BindGroupEntry { + binding: 1, + resource: BindingResource::Sampler(&self.bg_sampler), + }, + ], + }); + let max_lod = (mip_level_count.saturating_sub(1)) as f32; + + self.hud_bg = Some(HudBg { + _texture: texture, + _view: view, + hud_blur_bind_group, + mipgen_bind_group, + max_lod, + }); + self.hud_bg_generation = target_generation; + + Ok(()) + } +} + +pub(super) struct HudBg { + _texture: Texture, + _view: TextureView, + pub(super) hud_blur_bind_group: BindGroup, + pub(super) mipgen_bind_group: BindGroup, + max_lod: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(in crate::overlay) struct HudPillGeometry { + pub(in crate::overlay) rect: Rect, + pub(in crate::overlay) radius_points: f32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug)] +pub(super) struct HudBlurUniformRaw { + rect_min_size: [f32; 4], + radius_blur_soft: [f32; 4], + surface_size_px: [f32; 4], + tint_rgba: [f32; 4], + effects: [f32; 4], +} +impl HudBlurUniformRaw { + fn as_bytes(&self) -> &[u8] { + unsafe { slice::from_raw_parts(ptr::from_ref(self).cast::(), mem::size_of::()) } + } +} diff --git a/packages/rsnap-overlay/src/overlay/rendering/scroll_preview_window.rs b/packages/rsnap-overlay/src/overlay/rendering/scroll_preview_window.rs new file mode 100644 index 00000000..155d95f2 --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/rendering/scroll_preview_window.rs @@ -0,0 +1,373 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +struct ScrollPreviewStrip { + texture: TextureHandle, + pixel_size: [usize; 2], + rgba: Vec, + size_points: Vec2, +} + +pub(in crate::overlay) struct ScrollPreviewWindow { + pub(in crate::overlay) window: Arc, + surface: Surface<'static>, + surface_config: wgpu::SurfaceConfiguration, + needs_reconfigure: bool, + egui_ctx: egui::Context, + egui_state: egui_winit::State, + renderer: Renderer, + preview_image: Option, +} +impl ScrollPreviewWindow { + pub(in crate::overlay) fn new( + event_loop: &ActiveEventLoop, + gpu: &GpuContext, + ) -> Result { + let attrs = winit::window::Window::default_attributes() + .with_title("rsnap-scroll-preview") + .with_visible(false) + .with_resizable(false) + .with_decorations(false) + .with_transparent(true) + .with_inner_size(LogicalSize::new( + SCROLL_PREVIEW_WINDOW_WIDTH_POINTS, + SCROLL_PREVIEW_WINDOW_HEIGHT_POINTS, + )) + .with_window_level(WindowLevel::AlwaysOnTop); + let window = event_loop + .create_window(attrs) + .map_err(|err| format!("Unable to create scroll preview window: {err}"))?; + let window = Arc::new(window); + let surface = gpu + .instance + .create_surface(Arc::clone(&window)) + .map_err(|err| format!("wgpu create_surface failed: {err:#}"))?; + let caps = surface.get_capabilities(&gpu.adapter); + let surface_format = WindowRenderer::pick_surface_format(&caps); + let surface_alpha = WindowRenderer::pick_surface_alpha(&caps); + let surface_config = + WindowRenderer::make_surface_config(window.as_ref(), surface_format, surface_alpha); + let egui_ctx = egui::Context::default(); + let mut fonts = FontDefinitions::default(); + + egui_phosphor::add_to_fonts(&mut fonts, Variant::Regular); + + egui_ctx.set_fonts(fonts); + + let egui_state = egui_winit::State::new( + egui_ctx.clone(), + ViewportId::ROOT, + window.as_ref(), + None, + None, + None, + ); + let renderer = Renderer::new( + &gpu.device, + surface_config.format, + egui_wgpu::RendererOptions { + msaa_samples: 1, + depth_stencil_format: None, + dithering: false, + predictable_texture_filtering: false, + }, + ); + + surface.configure(&gpu.device, &surface_config); + + let _ = window.set_cursor_hittest(false); + + #[cfg(target_os = "macos")] + macos_configure_hud_window(window.as_ref(), false, 0.0, Some(18.0)); + + Ok(Self { + window, + surface, + surface_config, + needs_reconfigure: false, + egui_ctx, + egui_state, + renderer, + preview_image: None, + }) + } + + pub(in crate::overlay) fn handle_window_event(&mut self, event: &WindowEvent) { + match event { + WindowEvent::Resized(size) => self.resize(*size), + WindowEvent::ScaleFactorChanged { .. } => self.resize(self.window.inner_size()), + WindowEvent::ThemeChanged(_) => self.window.request_redraw(), + _ => {}, + } + + let _ = self.egui_state.on_window_event(&self.window, event); + + self.window.request_redraw(); + } + + pub(in crate::overlay) fn sync_image(&mut self, image: Option) { + let Some(image) = image else { + self.preview_image = None; + + return; + }; + let preview_image = image_helpers::resize_scroll_preview_segment(&image); + let pixel_size = [preview_image.width() as usize, preview_image.height() as usize]; + let rgba = preview_image.as_raw().clone(); + let color_image = ColorImage::from_rgba_unmultiplied(pixel_size, &rgba); + let ppp = self.window.scale_factor() as f32; + let size_points = + Vec2::new(preview_image.width() as f32 / ppp, preview_image.height() as f32 / ppp); + + match self.preview_image.as_mut() { + Some(strip) if strip.pixel_size == pixel_size => { + strip.texture.set(color_image, TextureOptions::LINEAR); + + strip.pixel_size = pixel_size; + strip.rgba = rgba; + strip.size_points = size_points; + }, + _ => { + let texture = self.egui_ctx.load_texture( + String::from("scroll-preview-image"), + color_image, + TextureOptions::LINEAR, + ); + + self.preview_image = + Some(ScrollPreviewStrip { texture, pixel_size, rgba, size_points }); + }, + } + } + + fn render_preview_ui(&mut self, view: ScrollPreviewView) -> FullOutput { + let raw_input = self.egui_state.take_egui_input(&self.window); + + self.egui_ctx.run_ui(raw_input, |ui| { + CentralPanel::default().frame(Frame::new().fill(Color32::TRANSPARENT)).show_inside( + ui, + |ui| { + let _ = view.paused; + let tile_fill = match view.theme { + HudTheme::Dark => Color32::from_rgba_unmultiplied(20, 22, 27, 228), + HudTheme::Light => Color32::from_rgba_unmultiplied(244, 246, 249, 236), + }; + let tile_stroke = match view.theme { + HudTheme::Dark => Color32::from_rgba_unmultiplied(255, 255, 255, 18), + HudTheme::Light => Color32::from_rgba_unmultiplied(30, 36, 44, 22), + }; + let tile_frame = Frame::new() + .fill(tile_fill) + .stroke(Stroke::new(1.0, tile_stroke)) + .corner_radius(CornerRadius::same(18)) + .inner_margin(Margin::symmetric(14, 14)); + + tile_frame.show(ui, |ui| { + ui.set_min_size(ui.available_size()); + + if let Some(preview_image) = self.preview_image.as_ref() { + let available = ui.available_size(); + let scale = + (available.x / preview_image.size_points.x).clamp(0.05, 1.0); + let draw_size = preview_image.size_points * scale; + + ui.with_layout(Layout::top_down(Align::Center), |ui| { + ui.image((preview_image.texture.id(), draw_size)); + }); + } else { + ui.allocate_space(ui.available_size()); + } + }); + }, + ); + }) + } + + fn render_preview_frame(&mut self, gpu: &GpuContext, full_output: FullOutput) -> Result<()> { + self.egui_state.handle_platform_output(&self.window, full_output.platform_output); + + for (id, delta) in &full_output.textures_delta.set { + self.renderer.update_texture(&gpu.device, &gpu.queue, *id, delta); + } + for id in &full_output.textures_delta.free { + self.renderer.free_texture(id); + } + + let pixels_per_point = self.window.scale_factor() as f32; + let paint_jobs = self.egui_ctx.tessellate(full_output.shapes, pixels_per_point); + let size = self.window.inner_size(); + let screen_descriptor = ScreenDescriptor { + size_in_pixels: [size.width.max(1), size.height.max(1)], + pixels_per_point, + }; + let frame = match self.acquire_frame(gpu)? { + AcquiredSurfaceFrame::Ready(frame) => frame, + AcquiredSurfaceFrame::Skipped(reason) => { + tracing::trace!( + window_id = ?self.window.id(), + reason = reason.as_str(), + "Skipped scroll preview frame acquisition." + ); + + if reason.should_request_redraw() { + self.window.request_redraw(); + } + + return Ok(()); + }, + }; + let view = frame.texture.create_view(&TextureViewDescriptor::default()); + let mut encoder = gpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("rsnap-scroll-preview encoder"), + }); + let _ = self.renderer.update_buffers( + &gpu.device, + &gpu.queue, + &mut encoder, + &paint_jobs, + &screen_descriptor, + ); + + { + let rpass_desc = wgpu::RenderPassDescriptor { + label: Some("rsnap-scroll-preview rpass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }), + store: StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }; + let mut rpass = encoder.begin_render_pass(&rpass_desc).forget_lifetime(); + + self.renderer.render(&mut rpass, &paint_jobs, &screen_descriptor); + } + + gpu.queue.submit(Some(encoder.finish())); + frame.present(); + + Ok(()) + } + + pub(in crate::overlay) fn draw( + &mut self, + gpu: &GpuContext, + theme: HudTheme, + view: ScrollPreviewView, + ) -> Result<()> { + self.sync_surface_to_window(gpu); + + if self.needs_reconfigure { + self.reconfigure_surface(gpu); + } + + match theme { + HudTheme::Dark => self.egui_ctx.set_visuals(Visuals::dark()), + HudTheme::Light => self.egui_ctx.set_visuals(Visuals::light()), + } + + let full_output = self.render_preview_ui(view); + + self.render_preview_frame(gpu, full_output) + } + + fn acquire_frame(&mut self, gpu: &GpuContext) -> Result { + for attempt in 0..2 { + match self.surface.get_current_texture() { + CurrentSurfaceTexture::Success(frame) => { + return Ok(AcquiredSurfaceFrame::Ready(frame)); + }, + CurrentSurfaceTexture::Suboptimal(frame) => { + self.needs_reconfigure = true; + + return Ok(AcquiredSurfaceFrame::Ready(frame)); + }, + CurrentSurfaceTexture::Outdated if attempt == 0 => { + self.reconfigure_surface(gpu); + }, + CurrentSurfaceTexture::Lost if attempt == 0 => { + self.recreate_surface(gpu).wrap_err("recreate scroll preview surface")?; + }, + CurrentSurfaceTexture::Outdated => { + return Err(eyre::eyre!( + "scroll preview get_current_texture stayed outdated after reconfigure" + )); + }, + CurrentSurfaceTexture::Lost => { + return Err(eyre::eyre!( + "scroll preview get_current_texture stayed lost after recreate" + )); + }, + CurrentSurfaceTexture::Timeout => { + return Ok(AcquiredSurfaceFrame::Skipped(SurfaceFrameSkipReason::Timeout)); + }, + CurrentSurfaceTexture::Occluded => { + return Ok(AcquiredSurfaceFrame::Skipped(SurfaceFrameSkipReason::Occluded)); + }, + CurrentSurfaceTexture::Validation => { + return Err(eyre::eyre!("scroll preview get_current_texture hit validation")); + }, + } + } + + unreachable!("surface acquisition attempts are bounded") + } + + fn recreate_surface(&mut self, gpu: &GpuContext) -> Result<()> { + let surface = gpu + .instance + .create_surface(Arc::clone(&self.window)) + .wrap_err("create scroll preview surface")?; + + self.surface = surface; + + self.reconfigure_surface(gpu); + + Ok(()) + } + + fn reconfigure_surface(&mut self, gpu: &GpuContext) { + self.surface.configure(&gpu.device, &self.surface_config); + + self.needs_reconfigure = false; + } + + fn sync_surface_to_window(&mut self, gpu: &GpuContext) { + let actual_size = self.window.inner_size(); + let desired_w = actual_size.width.max(1); + let desired_h = actual_size.height.max(1); + + if self.surface_config.width == desired_w && self.surface_config.height == desired_h { + return; + } + + tracing::debug!( + window_id = ?self.window.id(), + actual_size_px = ?actual_size, + old_surface_px = ?(self.surface_config.width, self.surface_config.height), + new_surface_px = ?(desired_w, desired_h), + window_scale_factor = self.window.scale_factor(), + "Reconfiguring scroll preview surface to match window." + ); + + self.surface_config.width = desired_w; + self.surface_config.height = desired_h; + self.needs_reconfigure = false; + + self.reconfigure_surface(gpu); + } + + pub(in crate::overlay) fn resize(&mut self, size: PhysicalSize) { + self.surface_config.width = size.width.max(1); + self.surface_config.height = size.height.max(1); + self.needs_reconfigure = true; + } +} diff --git a/packages/rsnap-overlay/src/overlay/scroll_preview_runtime.rs b/packages/rsnap-overlay/src/overlay/scroll_preview_runtime.rs new file mode 100644 index 00000000..c4773214 --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/scroll_preview_runtime.rs @@ -0,0 +1,240 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +impl OverlaySession { + pub(super) fn sync_scroll_preview_segments(&mut self) { + let image = self.current_scroll_preview_render_image(); + + { + let Some(preview) = self.scroll_preview_window.as_mut() else { + return; + }; + + preview.sync_image(image); + preview.window.request_redraw(); + } + + if let Some(monitor) = self.scroll_capture.monitor.or(self.state.monitor) { + #[cfg(target_os = "macos")] + { + self.position_scroll_preview_window(monitor); + } + + #[cfg(not(target_os = "macos"))] + { + let _ = monitor; + } + } + } + + pub(super) fn refresh_scroll_preview_committed_image(&mut self) { + self.scroll_capture.preview_committed_image = + self.scroll_capture.session.as_ref().map(|session| session.export_image().clone()); + } + + pub(super) fn refresh_scroll_preview_display_image(&mut self) { + let motion_rows_hint = None; + + self.scroll_capture.last_overlay_preview_motion_rows_hint = motion_rows_hint; + self.scroll_capture.last_overlay_preview_provisional_motion_rows_hint = None; + self.scroll_capture.last_overlay_preview_existing_candidate_height = None; + self.scroll_capture.last_overlay_preview_existing_candidate_motion_rows_hint = None; + self.scroll_capture.last_overlay_preview_ledger_candidate_height = None; + self.scroll_capture.last_overlay_preview_ledger_candidate_motion_rows_hint = None; + self.scroll_capture.last_overlay_preview_retained_candidate_height = None; + self.scroll_capture.last_overlay_preview_retained_candidate_motion_rows_hint = None; + self.scroll_capture.last_overlay_preview_retained_hint_matches_motion_rows = false; + self.scroll_capture.last_overlay_preview_fresh_latest_frame_can_drive = false; + self.scroll_capture.last_overlay_preview_strong_unresolved_registration = false; + self.scroll_capture.last_overlay_preview_latest_frame_present = + self.scroll_capture.preview_latest_frame.is_some(); + self.scroll_capture.last_overlay_preview_used_provisional = false; + + if let Some(session) = self.scroll_capture.session.as_mut() { + self.scroll_capture.preview_committed_image = Some(session.export_image().clone()); + self.scroll_capture.preview_display_image = + self.scroll_capture.preview_committed_image.clone(); + + return; + } + + self.scroll_capture.preview_display_image = + self.scroll_capture.preview_committed_image.as_ref().map(|base_preview| { + scroll_capture::compose_provisional_preview_image( + base_preview, + self.scroll_capture.preview_latest_frame.as_ref(), + motion_rows_hint, + SCROLL_CAPTURE_PREVIEW_WIDTH_PX, + ) + }); + } + + pub(super) fn scroll_capture_preview_dimensions(&self) -> Option<[u32; 2]> { + self.current_scroll_preview_render_image() + .as_ref() + .map(|image| [image.width(), image.height()]) + } + + pub(super) fn scroll_preview_display_size_points(&self) -> Option { + let [width_px, height_px] = self.scroll_capture_preview_dimensions()?; + + if width_px == 0 || height_px == 0 { + return None; + } + + let width_points = SCROLL_PREVIEW_WINDOW_WIDTH_POINTS as f32; + let scale = width_points / width_px as f32; + + Some(Vec2::new(width_points, (height_px as f32 * scale).max(1.0))) + } + + pub(super) fn current_scroll_preview_render_image(&self) -> Option { + if self.scroll_capture.active { + return self.current_export_image(); + } + + self.scroll_capture.preview_display_image.clone().or_else(|| self.current_export_image()) + } + + pub(super) fn handle_scroll_preview_event( + &mut self, + window_id: WindowId, + event: &WindowEvent, + ) -> Option { + if self + .scroll_preview_window + .as_ref() + .is_none_or(|preview_window| preview_window.window.id() != window_id) + { + return None; + } + + Some(match event { + WindowEvent::RedrawRequested => self.handle_scroll_preview_redraw_requested(), + WindowEvent::MouseInput { + state: ElementState::Pressed, + button: MouseButton::Right, + .. + } => self.cancel_overlay("scroll_preview_right_click"), + WindowEvent::KeyboardInput { event, .. } => self.handle_key_event(event), + WindowEvent::ModifiersChanged(modifiers) => self.handle_modifiers_changed(modifiers), + _ => self.handle_scroll_preview_window_event(event), + }) + } + + pub(super) fn handle_scroll_preview_window_event( + &mut self, + event: &WindowEvent, + ) -> OverlayControl { + let Some(preview_window) = self.scroll_preview_window.as_mut() else { + return OverlayControl::Continue; + }; + + preview_window.handle_window_event(event); + + OverlayControl::Continue + } + + pub(super) fn handle_scroll_preview_redraw_requested(&mut self) -> OverlayControl { + let Some(preview_window) = self.scroll_preview_window.as_mut() else { + return OverlayControl::Continue; + }; + + if !self.scroll_capture.active { + preview_window.window.set_visible(false); + + return OverlayControl::Continue; + } + + let theme = + hud_helpers::effective_hud_theme(self.config.theme_mode, preview_window.window.theme()); + let view = ScrollPreviewView { paused: self.scroll_capture.paused, theme }; + let Some(gpu) = self.gpu.as_ref() else { + return self.exit(OverlayExit::Error(String::from("Missing GPU context"))); + }; + + match preview_window.draw(gpu, theme, view) { + Ok(()) => OverlayControl::Continue, + Err(err) => self.exit(OverlayExit::Error(format!("{err:#}"))), + } + } + + #[cfg(target_os = "macos")] + pub(super) fn position_scroll_preview_window(&self, monitor: MonitorRect) { + let Some(preview_window) = self.scroll_preview_window.as_ref() else { + return; + }; + let preview_rect = self.scroll_preview_local_rect(monitor); + let current_size = preview_window.window.inner_size(); + let desired_width = preview_rect.width().round().max(1.0) as u32; + let desired_height = preview_rect.height().round().max(1.0) as u32; + + if current_size.width != desired_width || current_size.height != desired_height { + let _ = preview_window.window.request_inner_size(LogicalSize::new( + f64::from(desired_width), + f64::from(desired_height), + )); + } + + preview_window.window.set_outer_position(LogicalPosition::new( + f64::from(monitor.origin.x) + f64::from(preview_rect.min.x), + f64::from(monitor.origin.y) + f64::from(preview_rect.min.y), + )); + } + + pub(super) fn scroll_preview_local_rect(&self, monitor: MonitorRect) -> Rect { + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let gap = SCROLL_PREVIEW_WINDOW_MARGIN_POINTS as f32; + let preview_width = SCROLL_PREVIEW_WINDOW_WIDTH_POINTS as f32; + + if let Some(capture_rect) = self.state.frozen_capture_rect { + let capture_rect = Rect::from_min_size( + Pos2::new(capture_rect.x as f32, capture_rect.y as f32), + Vec2::new(capture_rect.width as f32, capture_rect.height as f32), + ) + .intersect(screen_rect); + let preview_size = self + .scroll_preview_display_size_points() + .unwrap_or(Vec2::new(preview_width, capture_rect.height().max(1.0))); + let preview_width = preview_size.x.max(preview_width); + let max_preview_height = (screen_rect.max.y - capture_rect.min.y - gap).max(1.0); + let preview_height = preview_size.y.min(max_preview_height).max(1.0); + let right_x = capture_rect.max.x + gap; + let left_x = capture_rect.min.x - gap - preview_width; + let x = if right_x + preview_width <= screen_rect.max.x { + right_x + } else if left_x >= screen_rect.min.x { + left_x + } else { + (screen_rect.max.x - preview_width - gap).max(screen_rect.min.x + gap) + }; + + return Rect::from_min_size( + Pos2::new(x, capture_rect.min.y), + Vec2::new(preview_width, preview_height), + ); + } + + let preview_size = if let Some(preview_window) = self.scroll_preview_window.as_ref() { + let scale = preview_window.window.scale_factor().max(1.0) as f32; + let size = preview_window.window.inner_size(); + + Vec2::new( + ((size.width as f32) / scale).max(preview_width), + ((size.height as f32) / scale).max(SCROLL_PREVIEW_WINDOW_HEIGHT_POINTS as f32), + ) + } else { + Vec2::new(preview_width, SCROLL_PREVIEW_WINDOW_HEIGHT_POINTS as f32) + }; + let min_x = screen_rect.min.x + gap; + let max_x = (screen_rect.max.x - preview_size.x - gap).max(min_x); + let min_y = screen_rect.min.y + gap; + let max_y = (screen_rect.max.y - preview_size.y - gap).max(min_y); + let y = min_y.min(max_y); + let pos = Pos2::new(max_x, y); + + Rect::from_min_size(pos, preview_size) + } +} diff --git a/packages/rsnap-overlay/src/overlay/tests.rs b/packages/rsnap-overlay/src/overlay/tests.rs new file mode 100644 index 00000000..b50073de --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/tests.rs @@ -0,0 +1,1546 @@ +#[cfg(target_os = "macos")] +use std::collections::VecDeque; +#[cfg(target_os = "macos")] +use std::sync::Arc; +#[cfg(target_os = "macos")] +use std::thread; +use std::time::Duration; +use std::time::Instant; + +use image::{Rgba, RgbaImage}; +#[cfg(target_os = "macos")] +use winit::dpi::PhysicalPosition; +use winit::event::{ElementState, MouseButton, MouseScrollDelta}; +#[cfg(target_os = "macos")] +use winit::keyboard::ModifiersState; +#[cfg(target_os = "macos")] +use winit::window::WindowId; + +#[cfg(target_os = "macos")] +use crate::backend; +#[cfg(target_os = "macos")] +use crate::backend::CaptureBackend; +#[cfg(target_os = "macos")] +use crate::live_frame_stream_macos::MacLiveFrameStream; +use crate::overlay::FrozenCaptureSource; +use crate::overlay::PngAction; +#[cfg(target_os = "macos")] +use crate::overlay::session_state::ScrollCaptureLiveFrame; +use crate::overlay::{ + self, FrozenSelectionDragState, FrozenToolbarState, FrozenToolbarTool, + HUD_LOUPE_STRIP_GAP_POINTS, HudRedrawSummary, HudTheme, OCCLUDED_FRAME_REDRAW_RETRY_WINDOW, + OverlaySession, Pos2, Rect, SCROLL_CAPTURE_SAMPLE_INTERVAL, + SELECTION_DASHED_BORDER_DASH_LENGTH_PX, SELECTION_DASHED_BORDER_GAP_LENGTH_PX, + SELECTION_DASHED_BORDER_WIDTH_PX, SELECTION_SIZE_BADGE_GAP_PX, + SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX, SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX, + SelectionDashedBorderCache, SelectionDashedBorderMetrics, SelectionFlowGeometryCache, + SelectionSizeBadgeTarget, SurfaceFrameSkipReason, TOOLBAR_CAPTURE_GAP_PX, + TOOLBAR_SCREEN_MARGIN_PX, ToolbarPlacement, Vec2, WindowRenderer, hud_helpers, regular, +}; +#[cfg(target_os = "macos")] +use crate::overlay::{ + AltActivationMode, HUD_PILL_CORNER_RADIUS_POINTS, HudPillGeometry, + InflightScrollCaptureObservation, KCG_SCROLL_EVENT_UNIT_PIXEL, LiveSampleApplyResult, + LiveStreamStaleGrace, MacOSScrollPixelResidual, OverlayControl, + SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW, SCROLL_CAPTURE_INPUT_FRESHNESS, + SCROLL_CAPTURE_LIVE_STREAM_STALE_GRACE_FRAMES, SCROLL_CAPTURE_MOUSE_PASSTHROUGH_IDLE_GRACE, + ScrollCaptureFrameSource, StartupLiveRgbPlan, +}; +use crate::scroll_capture::{ScrollDirection, ScrollObserveOutcome, ScrollSession}; +#[cfg(target_os = "macos")] +use crate::state::LiveCursorSample; +use crate::state::{ + GlobalPoint, LoupeSample, MonitorRect, MonitorRectPoints, OverlayMode, OverlayState, + RectPoints, Rgb, +}; +#[cfg(target_os = "macos")] +use crate::state::{WindowListSnapshot, WindowRect}; +#[cfg(target_os = "macos")] +use crate::worker::OverlayWorker; +use crate::worker::{WorkerErrorSource, WorkerResponse}; + +mod live_runtime; +mod rendering_behaviors; +mod scroll_input_runtime; +mod self_capture_runtime; +mod stream_refresh_runtime; +mod worker_observation_runtime; +mod worker_tick_runtime; + +#[cfg(target_os = "macos")] +struct SequenceScrollCaptureBackend { + frames: VecDeque>, +} + +#[cfg(target_os = "macos")] +impl SequenceScrollCaptureBackend { + fn new(frames: impl IntoIterator>) -> Self { + Self { frames: frames.into_iter().collect() } + } +} + +#[cfg(target_os = "macos")] +impl CaptureBackend for SequenceScrollCaptureBackend { + fn capture_monitor(&mut self, _monitor: MonitorRect) -> color_eyre::eyre::Result { + Err(color_eyre::eyre::eyre!("unused in this test")) + } + + fn capture_monitor_region_for_scroll_capture( + &mut self, + _monitor: MonitorRect, + _rect_px: RectPoints, + ) -> color_eyre::eyre::Result> { + Ok(self.frames.pop_front().unwrap_or(None)) + } + + fn pixel_rgb_in_monitor( + &mut self, + _monitor: MonitorRect, + _point: GlobalPoint, + ) -> color_eyre::eyre::Result> { + Ok(None) + } + + fn rgba_patch_in_monitor( + &mut self, + _monitor: MonitorRect, + _point: GlobalPoint, + _width_px: u32, + _height_px: u32, + ) -> color_eyre::eyre::Result> { + Ok(None) + } + + fn refresh_window_cache(&mut self) -> color_eyre::eyre::Result> { + Err(color_eyre::eyre::eyre!("unused in this test")) + } +} + +fn make_scroll_capture_test_image(width: u32, rows: &[[u8; 4]]) -> image::RgbaImage { + let mut image = image::RgbaImage::new(width, rows.len() as u32); + + for (y, row) in rows.iter().enumerate() { + for x in 0..width { + image.put_pixel(x, y as u32, Rgba(*row)); + } + } + + image +} + +fn make_scroll_capture_window( + document: &[[u8; 4]], + width: u32, + start_row: usize, + window_rows: usize, +) -> image::RgbaImage { + make_scroll_capture_test_image(width, &document[start_row..start_row + window_rows]) +} + +#[cfg(target_os = "macos")] +fn make_sparse_worker_capture_window(width: u32, height: u32, start_row: u32) -> image::RgbaImage { + let stripe_x = 104_u32; + let mut image = image::RgbaImage::from_pixel(width, height, Rgba([255, 255, 255, 255])); + + for y in 0..height { + let document_row = start_row.saturating_add(y); + let shade = ((document_row.saturating_mul(17)) % 180) as u8; + + for x in stripe_x..stripe_x.saturating_add(6) { + image.put_pixel(x, y, Rgba([shade, shade, shade, 255])); + } + for x in stripe_x.saturating_add(10)..stripe_x.saturating_add(13) { + if document_row % 19 < 9 { + image.put_pixel(x, y, Rgba([40, 40, 40, 255])); + } + } + } + + image +} + +#[cfg(target_os = "macos")] +fn make_browser_like_worker_capture_window( + width: u32, + height: u32, + start_row: u32, +) -> image::RgbaImage { + let scrollbar_left = width.saturating_sub(18); + let content_left = 56_u32; + let content_right = width.saturating_sub(48); + let heading_width = 220_u32; + let paragraph_width = content_right.saturating_sub(content_left); + let mut image = make_sparse_worker_capture_window(width, height, start_row); + + for y in 0..height { + let document_row = start_row.saturating_add(y); + + if document_row % 420 < 18 { + for x in content_left..content_left.saturating_add(heading_width) { + image.put_pixel(x, y, Rgba([26, 26, 26, 255])); + } + } else if document_row % 420 >= 54 && document_row % 420 < 220 { + if document_row % 24 < 3 { + let trim = ((document_row / 24) % 5) * 18; + + for x in + content_left..content_left.saturating_add(paragraph_width.saturating_sub(trim)) + { + image.put_pixel(x, y, Rgba([72, 72, 72, 255])); + } + } + } else if document_row % 420 >= 270 && document_row % 420 < 360 && document_row % 20 < 2 { + for x in content_left.saturating_add(20) + ..content_left.saturating_add(paragraph_width.saturating_sub(70)) + { + image.put_pixel(x, y, Rgba([98, 98, 98, 255])); + } + } + + for x in scrollbar_left..width { + image.put_pixel(x, y, Rgba([232, 232, 232, 255])); + } + } + + let thumb_height = (height / 5).max(16); + let thumb_top = (start_row / 3) % height.max(thumb_height + 1); + let thumb_top = thumb_top.min(height.saturating_sub(thumb_height)); + + for y in thumb_top..thumb_top.saturating_add(thumb_height) { + for x in scrollbar_left.saturating_add(3)..width.saturating_sub(4) { + image.put_pixel(x, y, Rgba([96, 96, 96, 255])); + } + } + + image +} + +fn set_scroll_capture_input(session: &mut OverlaySession, direction: ScrollDirection) { + session.scroll_capture.input_direction = Some(direction); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; +} + +#[cfg(target_os = "macos")] +fn enable_test_worker_scroll_capture_path(session: &mut OverlaySession) { + session.scroll_capture.force_worker_sampling_in_tests = true; +} + +#[cfg(target_os = "macos")] +fn drain_scroll_capture_worker_until_idle(session: &mut OverlaySession) { + for _ in 0..64 { + let _ = session.drain_worker_responses(); + + if session.scroll_capture.inflight_request_id.is_none() { + return; + } + + thread::sleep(Duration::from_millis(5)); + } + + panic!( + "timed out waiting for worker scroll-capture response; inflight_request_id={:?}", + session.scroll_capture.inflight_request_id + ); +} + +fn observe_scroll_capture_frame( + session: &mut OverlaySession, + frame: image::RgbaImage, +) -> Option { + session.observe_scroll_capture_frame(frame).transpose().unwrap() +} + +fn scroll_capture_export_height(session: &OverlaySession) -> u32 { + session.scroll_capture.session.as_ref().unwrap().export_image().height() +} + +fn test_monitor() -> MonitorRect { + MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + } +} + +fn test_monitor_with_scale(width: u32, height: u32, scale_factor_x1000: u32) -> MonitorRect { + MonitorRect { id: 1, origin: GlobalPoint::new(0, 0), width, height, scale_factor_x1000 } +} + +fn test_frozen_image() -> RgbaImage { + RgbaImage::from_pixel(8, 8, Rgba([12, 34, 56, 255])) +} + +fn test_egui_context() -> egui::Context { + let ctx = egui::Context::default(); + let mut fonts = egui::FontDefinitions::default(); + let phosphor_fill = String::from("phosphor-fill"); + let proportional_fallback = fonts + .families + .get(&egui::FontFamily::Proportional) + .and_then(|names| names.first()) + .cloned(); + + egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular); + + fonts.font_data.insert(phosphor_fill.clone(), egui_phosphor::Variant::Fill.font_data().into()); + fonts + .families + .entry(egui::FontFamily::Name(phosphor_fill.clone().into())) + .or_default() + .extend([phosphor_fill]); + + if let Some(fallback) = proportional_fallback { + let family = + fonts.families.entry(egui::FontFamily::Name("phosphor-fill".into())).or_default(); + + if !family.contains(&fallback) { + family.push(fallback); + } + } + + ctx.set_fonts(fonts); + + let _ = ctx.run_ui(egui::RawInput::default(), |_ui| {}); + + ctx +} + +#[cfg(target_os = "macos")] +fn configured_session_with_macos_worker() -> (OverlaySession, u64) { + let worker = OverlayWorker::new(backend::default_capture_backend(), None); + let worker_debug_id = worker.debug_id(); + let mut session = OverlaySession::new(); + + session.worker = Some(worker); + session.live_sample_stream = Some(MacLiveFrameStream::new()); + session.scroll_capture.active = true; + session.scroll_capture.live_stream = Some(MacLiveFrameStream::with_waker(None)); + session.config.self_capture_exception_window_ids = vec![17]; + + (session, worker_debug_id) +} + +#[cfg(target_os = "macos")] +fn seed_ready_scroll_capture_selection(session: &mut OverlaySession) { + let monitor = test_monitor_with_scale(8, 8, 1_000); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + session.state.frozen_capture_rect = Some(RectPoints::new(1, 1, 4, 4)); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + session.authoritative_frozen_capture_ready = true; +} + +#[test] +fn begin_png_action_copies_preview_render_image_during_active_scroll_capture() { + let mut session = OverlaySession::new(); + let base = make_scroll_capture_test_image(3, &[[10, 0, 0, 255]; 8]); + let grown = make_scroll_capture_test_image(3, &[[20, 0, 0, 255]; 12]); + let mut scroll_session = ScrollSession::new(base, 320).expect("scroll session"); + let _ = scroll_session.observe_downward_sample(grown).expect("observe"); + let expected_export = scroll_session.export_image().clone(); + let monitor = test_monitor(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + session.state.frozen_capture_rect = Some(RectPoints::new(100, 120, 220, 180)); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + session.authoritative_frozen_capture_ready = true; + session.scroll_capture.active = true; + session.scroll_capture.session = Some(scroll_session); + session.scroll_capture.preview_display_image = + Some(RgbaImage::from_pixel(320, 64, Rgba([77, 0, 0, 255]))); + + session.begin_png_action(PngAction::Copy); + + assert_eq!(session.pending_png_action, Some(PngAction::Copy)); + assert_eq!(session.pending_encode_png.as_ref(), Some(&expected_export)); + assert_eq!(session.state.error_message.as_deref(), Some("Copying...")); +} + +#[cfg(target_os = "macos")] +#[test] +fn begin_ocr_action_clears_stale_png_output_intent() { + let monitor = test_monitor(); + let expected_export = test_frozen_image(); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, expected_export.clone()); + + session.state.frozen_capture_rect = Some(RectPoints::new(100, 120, 220, 180)); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + session.authoritative_frozen_capture_ready = true; + + session.begin_png_action(PngAction::Copy); + + assert_eq!(session.pending_png_action, Some(PngAction::Copy)); + assert_eq!(session.pending_encode_png.as_ref(), Some(&expected_export)); + + session.begin_ocr_action(); + + assert_eq!(session.pending_png_action, None); + assert!(session.pending_encode_png.is_none()); + assert_eq!( + session.pending_recognize_text.as_ref().map(|request| &request.image), + Some(&expected_export) + ); + assert_eq!(session.active_ocr_request_id, Some(0)); + assert_eq!(session.state.error_message.as_deref(), Some("Recognizing text...")); +} + +#[cfg(target_os = "macos")] +#[test] +fn begin_ocr_action_ticks_active_scroll_capture_before_queueing_recognition() { + let monitor = test_monitor(); + let rect = RectPoints::new(100, 120, 512, 640); + let base = make_browser_like_worker_capture_window(512, 640, 0); + let mut session = OverlaySession::new(); + + session.worker = Some(OverlayWorker::new( + Box::new(SequenceScrollCaptureBackend::new([Some( + make_browser_like_worker_capture_window(512, 640, 84), + )])), + None, + )); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + session.state.frozen_capture_rect = Some(rect); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + session.authoritative_frozen_capture_ready = true; + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(rect); + session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); + + enable_test_worker_scroll_capture_path(&mut session); + set_scroll_capture_input(&mut session, ScrollDirection::Down); + + session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + + session.begin_ocr_action(); + + assert!( + session.scroll_capture.inflight_request_id.is_some(), + "OCR should flush active scroll capture by kicking the same worker sample path as PNG export" + ); + assert!(session.pending_recognize_text.is_some()); + assert_eq!(session.state.error_message.as_deref(), Some("Recognizing text...")); +} + +#[cfg(target_os = "macos")] +#[test] +fn stale_png_response_is_ignored_after_ocr_supersedes_export() { + let monitor = test_monitor(); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + session.state.frozen_capture_rect = Some(RectPoints::new(100, 120, 220, 180)); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + session.authoritative_frozen_capture_ready = true; + + session.begin_png_action(PngAction::Copy); + session.begin_ocr_action(); + + let control = session.handle_encoded_png_response(Vec::new()); + + assert!(matches!(control, OverlayControl::Continue)); + assert_eq!(session.pending_png_action, None); + assert_eq!(session.state.error_message.as_deref(), Some("Recognizing text...")); +} + +#[cfg(target_os = "macos")] +#[test] +fn stale_ocr_response_is_ignored_after_copy_supersedes_ocr() { + let monitor = test_monitor(); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + session.state.frozen_capture_rect = Some(RectPoints::new(100, 120, 220, 180)); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + session.authoritative_frozen_capture_ready = true; + + session.begin_ocr_action(); + + let request_id = session.active_ocr_request_id.expect("ocr request id"); + + session.pending_recognize_text = None; + session.ocr_inflight = true; + + session.begin_png_action(PngAction::Copy); + + let control = session.maybe_tick_worker_response_limiter(WorkerResponse::RecognizedText { + request_id, + text: String::from("stale text"), + }); + + assert!(matches!(control, OverlayControl::Continue)); + assert_eq!(session.active_ocr_request_id, None); + assert!(!session.ocr_inflight); + assert_eq!(session.pending_png_action, Some(PngAction::Copy)); + assert_eq!(session.state.error_message.as_deref(), Some("Copying...")); +} + +#[cfg(target_os = "macos")] +#[test] +fn stale_ocr_error_is_ignored_while_newer_ocr_request_is_pending() { + let monitor = test_monitor(); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + session.state.frozen_capture_rect = Some(RectPoints::new(100, 120, 220, 180)); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + session.authoritative_frozen_capture_ready = true; + + session.begin_ocr_action(); + + let first_request_id = session.active_ocr_request_id.expect("first ocr request id"); + + session.pending_recognize_text = None; + session.ocr_inflight = true; + + session.begin_ocr_action(); + + let second_request_id = + session.pending_recognize_text.as_ref().expect("newer pending ocr request").request_id; + + assert_ne!(first_request_id, second_request_id); + + let control = session.maybe_tick_worker_response_limiter(WorkerResponse::Error { + source: WorkerErrorSource::RecognizeText, + message: String::from("stale OCR failure"), + }); + + assert!(matches!(control, OverlayControl::Continue)); + assert_eq!(session.active_ocr_request_id, Some(second_request_id)); + assert_eq!( + session.pending_recognize_text.as_ref().map(|request| request.request_id), + Some(second_request_id) + ); + assert!(!session.ocr_inflight); + assert_eq!(session.state.error_message.as_deref(), Some("Recognizing text...")); +} + +#[cfg(target_os = "macos")] +#[test] +fn duplicate_live_frames_schedule_forced_refresh_when_downward_backlog_is_fresh() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let capture_rect = RectPoints::new(100, 120, 200, 240); + let observed_at = Instant::now(); + let frame = ScrollCaptureLiveFrame { + frame_seq: 7, + captured_at: observed_at, + image: RgbaImage::from_pixel(16, 16, Rgba([7, 8, 9, 255])), + }; + let mut session = OverlaySession::new(); + + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(capture_rect); + session.scroll_capture.live_stream = Some(MacLiveFrameStream::new()); + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(observed_at); + session.scroll_capture.input_gesture_active = true; + session.scroll_capture.downward_motion_rows_pending = 512.0; + + assert!(session.note_scroll_capture_live_stream_frame_activity(&frame)); + assert!(!session.note_scroll_capture_live_stream_frame_activity(&frame)); + assert!(!session.note_scroll_capture_live_stream_frame_activity(&frame)); + assert!(!session.note_scroll_capture_live_stream_frame_activity(&frame)); + assert_eq!(session.scroll_capture.consecutive_identical_stream_frames, 3); + + session.maybe_schedule_duplicate_stream_refresh(frame.frame_seq, observed_at); + + assert_eq!( + session + .scroll_capture + .live_stream + .as_ref() + .and_then(MacLiveFrameStream::debug_last_request_kind), + Some("refresh_monitor_nonblocking_if_stale") + ); + assert_eq!(session.scroll_capture.pending_post_stall_burst_after_seq, Some(frame.frame_seq)); + assert_eq!(session.scroll_capture.last_duplicate_stream_refresh_at, Some(observed_at)); +} + +#[cfg(not(target_os = "macos"))] +#[test] +fn scroll_capture_is_unavailable_on_non_macos_even_with_drag_selection() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let mut session = OverlaySession::new(); + + session.state.mode = OverlayMode::Frozen; + session.state.monitor = Some(monitor); + session.state.frozen_capture_rect = Some(RectPoints::new(100, 120, 200, 240)); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + + assert!(!session.scroll_capture_is_available()); +} + +#[cfg(target_os = "macos")] +#[test] +fn scroll_capture_guard_error_keeps_frozen_capture_available() { + let mut session = OverlaySession::new(); + + seed_ready_scroll_capture_selection(&mut session); + + session.set_scroll_capture_start_guard(Arc::new(|| { + Err(color_eyre::eyre::eyre!("Open System Settings and retry.")) + })); + + let control = session.start_scroll_capture(); + + assert!(matches!(control, OverlayControl::Continue)); + assert!(session.state.frozen_image.is_some()); + assert!( + session + .state + .error_message + .as_deref() + .is_some_and(|message| message.contains("Open System Settings and retry.")) + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn scroll_capture_guard_silent_reject_keeps_frozen_capture_available_without_error() { + let mut session = OverlaySession::new(); + + seed_ready_scroll_capture_selection(&mut session); + + session.set_scroll_capture_start_guard(Arc::new(|| Ok(false))); + + let control = session.start_scroll_capture(); + + assert!(matches!(control, OverlayControl::Continue)); + assert!(session.state.frozen_image.is_some()); + assert!(session.state.error_message.is_none()); +} + +#[cfg(target_os = "macos")] +#[test] +fn scroll_capture_starting_hook_error_keeps_frozen_capture_available() { + let mut session = OverlaySession::new(); + + seed_ready_scroll_capture_selection(&mut session); + + session.set_scroll_capture_start_guard(Arc::new(|| Ok(true))); + session.set_scroll_capture_starting_hook(Arc::new(|| { + Err(color_eyre::eyre::eyre!("Observer was not ready.")) + })); + + let control = session.start_scroll_capture(); + + assert!(matches!(control, OverlayControl::Continue)); + assert!(session.state.frozen_image.is_some()); + assert!( + session + .state + .error_message + .as_deref() + .is_some_and(|message| message.contains("Observer was not ready.")) + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn scroll_capture_preflight_runs_before_permission_guard() { + let guard_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut session = OverlaySession::new(); + + session.set_scroll_capture_start_guard(Arc::new({ + let guard_calls = Arc::clone(&guard_calls); + + move || { + guard_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + + Ok(true) + } + })); + + let control = session.start_scroll_capture(); + + assert!(matches!(control, OverlayControl::Continue)); + assert_eq!(guard_calls.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!( + session.state.error_message.as_deref(), + Some("Scroll capture requires a dragged region selection.") + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn scroll_capture_starting_hook_runs_before_started_hook() { + let hook_order = Arc::new(std::sync::Mutex::new(Vec::<&'static str>::new())); + let mut session = OverlaySession::new(); + + seed_ready_scroll_capture_selection(&mut session); + + session.set_scroll_capture_start_guard(Arc::new(|| Ok(true))); + + session.set_scroll_capture_starting_hook(Arc::new({ + let hook_order = Arc::clone(&hook_order); + + move || { + let mut hook_order = match hook_order.lock() { + Ok(hook_order) => hook_order, + Err(poisoned) => poisoned.into_inner(), + }; + + hook_order.push("starting"); + + Ok(()) + } + })); + session.set_scroll_capture_started_hook(Arc::new({ + let hook_order = Arc::clone(&hook_order); + + move || { + let mut hook_order = match hook_order.lock() { + Ok(hook_order) => hook_order, + Err(poisoned) => poisoned.into_inner(), + }; + + hook_order.push("started"); + } + })); + + let control = session.start_scroll_capture(); + + assert!(matches!(control, OverlayControl::Continue)); + assert!(session.scroll_capture.active); + + let hook_order = match hook_order.lock() { + Ok(hook_order) => hook_order, + Err(poisoned) => poisoned.into_inner(), + }; + + assert_eq!(*hook_order, vec!["starting", "started"]); +} + +#[cfg(target_os = "macos")] +#[test] +fn scroll_capture_start_preserves_existing_live_sample_stream() { + let mut session = OverlaySession::new(); + + seed_ready_scroll_capture_selection(&mut session); + + session.live_sample_stream = Some(MacLiveFrameStream::new()); + + let control = session.start_scroll_capture(); + + assert!(matches!(control, OverlayControl::Continue)); + assert!(session.scroll_capture.active); + assert!(session.live_sample_stream.is_some()); + assert!(session.scroll_capture.live_stream.is_some()); +} + +#[cfg(target_os = "macos")] +#[test] +fn scroll_capture_start_skips_scroll_live_stream_when_worker_sampling_is_forced() { + let mut session = OverlaySession::new(); + + seed_ready_scroll_capture_selection(&mut session); + enable_test_worker_scroll_capture_path(&mut session); + + let control = session.start_scroll_capture(); + + assert!(matches!(control, OverlayControl::Continue)); + assert!(session.scroll_capture.active); + assert!(session.scroll_capture.live_stream.is_none()); + assert!(session.scroll_capture.live_stream_backlog.is_empty()); +} + +#[cfg(target_os = "macos")] +#[test] +fn reset_for_start_preserves_external_scroll_input_drain_reader() { + let mut session = OverlaySession::default(); + + session.set_external_scroll_input_drain_reader(Arc::new(|_, _| { + vec![(1, Instant::now(), 10.0, 20.0, 4.0, true, false)] + })); + session.reset_for_start(); + + assert!(session.scroll_capture.external_scroll_input_drain_reader.is_some()); +} + +#[cfg(target_os = "macos")] +#[test] +fn reset_for_start_clears_reused_session_transient_flags() { + let mut session = OverlaySession { + window_list_refresh_inflight: true, + drop_next_window_list_refresh_snapshot: true, + ocr_inflight: true, + png_encode_inflight: true, + pending_self_capture_exception_window_ids_worker_refresh: true, + authoritative_frozen_capture_ready: true, + capture_windows_hidden: true, + loupe_activation_key_down: true, + keyboard_modifiers: ModifiersState::SHIFT, + left_mouse_button_down: true, + left_mouse_button_down_monitor: Some(test_monitor()), + left_mouse_button_down_global: Some(GlobalPoint::new(12, 34)), + hud_window_visible: true, + toolbar_window_visible: true, + toolbar_window_warmup_redraws_remaining: 3, + ..OverlaySession::default() + }; + + session.reset_for_start(); + + assert!(!session.window_list_refresh_inflight); + assert!(!session.drop_next_window_list_refresh_snapshot); + assert!(!session.ocr_inflight); + assert!(!session.png_encode_inflight); + assert!(!session.pending_self_capture_exception_window_ids_worker_refresh); + assert!(!session.authoritative_frozen_capture_ready); + assert!(!session.capture_windows_hidden); + assert!(!session.loupe_activation_key_down); + assert_eq!(session.keyboard_modifiers, ModifiersState::default()); + assert!(!session.left_mouse_button_down); + assert!(session.left_mouse_button_down_monitor.is_none()); + assert!(session.left_mouse_button_down_global.is_none()); + assert!(!session.hud_window_visible); + assert!(!session.toolbar_window_visible); + assert_eq!(session.toolbar_window_warmup_redraws_remaining, 0); +} + +#[cfg(target_os = "macos")] +#[test] +fn drain_external_scroll_input_events_through_advances_last_seen_seq() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let start = Instant::now(); + let events = Arc::new([ + (1, start, 150.0, 160.0, -4.0, true, false), + (2, start + Duration::from_millis(2), 150.0, 160.0, -4.0, false, true), + ]); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); + session.set_external_scroll_input_drain_reader(Arc::new({ + let events = Arc::clone(&events); + + move |after_seq, through| { + events + .iter() + .copied() + .filter(|event| event.0 > after_seq && event.1 <= through) + .collect() + } + })); + + session.drain_external_scroll_input_events_through(start); + + assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); + assert!(session.scroll_capture.input_gesture_active); + assert_eq!(session.scroll_capture.last_external_scroll_input_seq, 1); + + session.drain_external_scroll_input_events_through(start); + + assert_eq!(session.scroll_capture.last_external_scroll_input_seq, 1); + + session.drain_external_scroll_input_events_through(start + Duration::from_millis(2)); + + assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); + assert!(!session.scroll_capture.input_gesture_active); + assert_eq!(session.scroll_capture.last_external_scroll_input_seq, 2); +} + +#[cfg(target_os = "macos")] +#[test] +fn drain_external_scroll_input_events_through_uses_pairing_time_for_freshness() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let through = Instant::now(); + let recorded_at = through - SCROLL_CAPTURE_INPUT_FRESHNESS - Duration::from_millis(50); + let events = Arc::new([(1, recorded_at, 150.0, 160.0, -4.0, false, false)]); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); + session.set_external_scroll_input_drain_reader(Arc::new({ + let events = Arc::clone(&events); + + move |after_seq, paired_through| { + events + .iter() + .copied() + .filter(|event| event.0 > after_seq && event.1 <= paired_through) + .collect() + } + })); + + session.drain_external_scroll_input_events_through(through); + + assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); + assert_eq!(session.scroll_capture.input_direction_at, Some(through)); + assert_eq!(session.scroll_capture_observation_block_reason(), None); +} + +#[cfg(target_os = "macos")] +#[test] +fn replayed_stream_input_uses_frame_time_for_stale_gate_without_global_relaxation() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + [80, 0, 0, 255], + ]; + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let capture_rect = RectPoints::new(100, 120, 200, 240); + let through = Instant::now() - SCROLL_CAPTURE_INPUT_FRESHNESS - Duration::from_millis(50); + let recorded_at = through - Duration::from_millis(12); + let events = Arc::new([(1, recorded_at, 150.0, 160.0, -4.0, false, false)]); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(capture_rect); + session.scroll_capture.session = + Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); + session.set_external_scroll_input_drain_reader(Arc::new({ + let events = Arc::clone(&events); + + move |after_seq, paired_through| { + events + .iter() + .copied() + .filter(|event| event.0 > after_seq && event.1 <= paired_through) + .collect() + } + })); + + session.drain_external_scroll_input_events_through(through); + + assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); + assert_eq!(session.scroll_capture.input_direction_at, Some(through)); + assert_eq!(session.scroll_capture_observation_block_reason(), Some("stale_input")); + assert_eq!(session.scroll_capture_observation_block_reason_at(through), None); + assert_eq!( + session + .observe_scroll_capture_frame_at( + make_scroll_capture_window(&document, 3, 1, 5), + through, + ) + .transpose() + .unwrap(), + Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn replayed_downward_input_allows_bounded_stale_live_stream_frame() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + [80, 0, 0, 255], + ]; + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let capture_rect = RectPoints::new(100, 120, 200, 240); + let through = Instant::now(); + let events = + Arc::new([(7, through - Duration::from_millis(10), 150.0, 160.0, 4.0, false, false)]); + let stale_at = through + SCROLL_CAPTURE_INPUT_FRESHNESS + Duration::from_millis(1); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(capture_rect); + session.scroll_capture.session = + Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); + session.set_external_scroll_input_drain_reader(Arc::new({ + let events = Arc::clone(&events); + + move |after_seq, paired_through| { + events + .iter() + .copied() + .filter(|event| event.0 > after_seq && event.1 <= paired_through) + .collect() + } + })); + + session.drain_external_scroll_input_events_through(through); + + assert_eq!( + session.scroll_capture.live_stream_stale_grace, + Some(LiveStreamStaleGrace { + external_input_seq: 7, + remaining_stale_frames: SCROLL_CAPTURE_LIVE_STREAM_STALE_GRACE_FRAMES, + }) + ); + assert_eq!( + session + .observe_scroll_capture_frame_at( + make_scroll_capture_window(&document, 3, 1, 5), + stale_at, + ) + .transpose() + .unwrap(), + Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) + ); + assert_eq!(scroll_capture_export_height(&session), 6); +} + +#[cfg(target_os = "macos")] +#[test] +fn stale_live_stream_frame_is_observed_even_without_direction_freshness() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + [80, 0, 0, 255], + ]; + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let capture_rect = RectPoints::new(100, 120, 200, 240); + let through = Instant::now(); + let wheel_at = through + Duration::from_millis(10); + let events = + Arc::new([(7, through - Duration::from_millis(10), 150.0, 160.0, 4.0, false, false)]); + let stale_at = wheel_at + SCROLL_CAPTURE_INPUT_FRESHNESS + Duration::from_millis(1); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(capture_rect); + session.scroll_capture.session = + Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); + session.set_external_scroll_input_drain_reader(Arc::new({ + let events = Arc::clone(&events); + + move |after_seq, paired_through| { + events + .iter() + .copied() + .filter(|event| event.0 > after_seq && event.1 <= paired_through) + .collect() + } + })); + + session.drain_external_scroll_input_events_through(through); + session.record_scroll_capture_input_direction_from_overlay_wheel_at( + &MouseScrollDelta::LineDelta(0.0, -1.0), + wheel_at, + ); + + assert_eq!(session.scroll_capture.input_direction_at, Some(wheel_at)); + assert_eq!( + session.scroll_capture.live_stream_stale_grace, + Some(LiveStreamStaleGrace { + external_input_seq: 7, + remaining_stale_frames: SCROLL_CAPTURE_LIVE_STREAM_STALE_GRACE_FRAMES, + }) + ); + assert_eq!( + session + .observe_scroll_capture_frame_at( + make_scroll_capture_window(&document, 3, 1, 5), + stale_at, + ) + .transpose() + .unwrap(), + Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) + ); + assert_eq!(scroll_capture_export_height(&session), 6); +} + +#[cfg(target_os = "macos")] +#[test] +fn handle_scroll_capture_frame_passes_allow_stale_input_into_live_stream_gate() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + [80, 0, 0, 255], + ]; + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let capture_rect = RectPoints::new(100, 120, 200, 240); + let observed_at = Instant::now(); + let input_at = observed_at - SCROLL_CAPTURE_INPUT_FRESHNESS - Duration::from_millis(1); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(capture_rect); + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(input_at); + session.scroll_capture.session = + Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); + + assert_eq!( + session + .handle_scroll_capture_frame( + make_scroll_capture_window(&document, 3, 1, 5), + ScrollCaptureFrameSource::LiveStream { frame_seq: 143 }, + true, + observed_at, + ) + .transpose() + .unwrap(), + Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) + ); + assert_eq!(scroll_capture_export_height(&session), 6); +} + +#[cfg(target_os = "macos")] +#[test] +fn fresh_live_stream_frame_without_direction_metadata_fails_closed_as_no_change() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + ]; + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let capture_rect = RectPoints::new(100, 120, 200, 240); + let observed_at = Instant::now(); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(capture_rect); + session.scroll_capture.session = + Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); + + session.handle_scroll_capture_frame( + make_scroll_capture_window(&document, 3, 1, 5), + ScrollCaptureFrameSource::LiveStream { frame_seq: 143 }, + false, + observed_at, + ); + + assert_eq!(scroll_capture_export_height(&session), 5); +} + +#[test] +fn downward_frame_motion_commits_even_with_legacy_upward_input_direction() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + ]; + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.session = + Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; + + assert_eq!( + session + .observe_scroll_capture_frame(make_scroll_capture_window(&document, 3, 1, 5)) + .transpose() + .unwrap(), + Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) + ); + + let height_after_first_append = + session.scroll_capture.session.as_ref().unwrap().export_image().height(); + + session.scroll_capture.input_direction = Some(ScrollDirection::Up); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; + + assert_eq!( + session + .observe_scroll_capture_frame(make_scroll_capture_window(&document, 3, 2, 5)) + .transpose() + .unwrap(), + Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) + ); + assert_eq!( + session.scroll_capture.session.as_ref().unwrap().export_image().height(), + height_after_first_append + 1 + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn positive_pixel_delta_maps_to_upward_scroll_capture() { + assert_eq!( + OverlaySession::scroll_capture_direction_from_wheel_delta(&MouseScrollDelta::PixelDelta( + PhysicalPosition::new(0.0, 2.0) + )), + Some(ScrollDirection::Up) + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_scroll_wheel_events_use_hid_system_source_state() { + assert_eq!( + super::macos_hid_event_source_state_id(), + super::KCG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn pixel_delta_residuals_accumulate_until_whole_pixels_emit() { + let mut residual = MacOSScrollPixelResidual::default(); + let first = OverlaySession::normalize_macos_scroll_wheel_delta( + &MouseScrollDelta::PixelDelta(PhysicalPosition::new(0.4, -0.4)), + &mut residual, + ); + let second = OverlaySession::normalize_macos_scroll_wheel_delta( + &MouseScrollDelta::PixelDelta(PhysicalPosition::new(0.7, -0.8)), + &mut residual, + ); + + assert_eq!(first.units, KCG_SCROLL_EVENT_UNIT_PIXEL); + assert_eq!(first.posted_x, 0); + assert_eq!(first.posted_y, 0); + assert!((first.residual.x - 0.4).abs() < f64::EPSILON); + assert!((first.residual.y + 0.4).abs() < f64::EPSILON); + assert_eq!(second.posted_x, 1); + assert_eq!(second.posted_y, -1); + assert!((second.residual.x - 0.1).abs() < 1e-9); + assert!((second.residual.y + 0.2).abs() < 1e-9); +} + +#[test] +fn frozen_toolbar_mode_tools_are_identifiable() { + assert!(FrozenToolbarTool::Pointer.is_mode_tool()); + assert!(FrozenToolbarTool::Pen.is_mode_tool()); + assert!(FrozenToolbarTool::Text.is_mode_tool()); + assert!(FrozenToolbarTool::Mosaic.is_mode_tool()); +} + +#[test] +fn frozen_toolbar_action_tools_are_not_mode_tools() { + assert!(!FrozenToolbarTool::Undo.is_mode_tool()); + assert!(!FrozenToolbarTool::Redo.is_mode_tool()); + assert!(!FrozenToolbarTool::AutoCenter.is_mode_tool()); + assert!(!FrozenToolbarTool::Scroll.is_mode_tool()); + assert!(!FrozenToolbarTool::Copy.is_mode_tool()); + assert!(!FrozenToolbarTool::Save.is_mode_tool()); + #[cfg(target_os = "macos")] + assert!(!FrozenToolbarTool::Ocr.is_mode_tool()); +} + +#[test] +fn frozen_toolbar_scroll_tool_uses_scroll_specific_iconography() { + assert_eq!(FrozenToolbarTool::Scroll.label(), "Scroll Capture"); + assert_eq!(FrozenToolbarTool::Scroll.icon(), regular::MOUSE_SCROLL); +} + +#[test] +fn frozen_toolbar_export_tools_require_final_capture() { + assert!(!FrozenToolbarTool::Pointer.requires_final_capture()); + assert!(!FrozenToolbarTool::Pen.requires_final_capture()); + assert!(!FrozenToolbarTool::Text.requires_final_capture()); + assert!(!FrozenToolbarTool::Mosaic.requires_final_capture()); + assert!(!FrozenToolbarTool::Undo.requires_final_capture()); + assert!(!FrozenToolbarTool::Redo.requires_final_capture()); + assert!(!FrozenToolbarTool::AutoCenter.requires_final_capture()); + assert!(FrozenToolbarTool::Scroll.requires_final_capture()); + assert!(FrozenToolbarTool::Copy.requires_final_capture()); + assert!(FrozenToolbarTool::Save.requires_final_capture()); + #[cfg(target_os = "macos")] + assert!(FrozenToolbarTool::Ocr.requires_final_capture()); +} + +#[test] +fn frozen_toolbar_selected_mode_uses_fill_without_border() { + for theme in [HudTheme::Dark, HudTheme::Light] { + let style = WindowRenderer::frozen_toolbar_button_style(theme, true, false, true); + + assert!(style.bg_color.a() > 0); + assert_eq!(style.border_color, None); + } +} + +#[test] +fn toolbar_window_hides_until_frozen_pixels_exist() { + let monitor = test_monitor(); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + + assert!(session.should_hide_toolbar_window(monitor)); + + session.pending_freeze_capture = Some(monitor); + + assert!(session.should_hide_toolbar_window(monitor)); + + session.pending_freeze_capture = None; + session.inflight_freeze_capture = Some(monitor); + + assert!(session.should_hide_toolbar_window(monitor)); +} + +#[test] +fn toolbar_window_stays_visible_while_final_capture_is_pending() { + let monitor = test_monitor(); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + assert!(!session.should_hide_toolbar_window(monitor)); + + session.pending_freeze_capture = Some(monitor); + + assert!(!session.should_hide_toolbar_window(monitor)); + + session.pending_freeze_capture = None; + session.inflight_freeze_capture = Some(monitor); + + assert!(!session.should_hide_toolbar_window(monitor)); +} + +#[test] +fn force_pending_hud_and_loupe_moves_only_during_frozen_transition() { + let monitor = test_monitor(); + let mut session = OverlaySession::new(); + + assert!(!session.should_force_pending_hud_and_loupe_moves()); + + session.state.begin_freeze(monitor); + + assert!(session.should_force_pending_hud_and_loupe_moves()); + + session.state.finish_freeze(monitor, test_frozen_image()); + + session.authoritative_frozen_capture_ready = true; + + assert!(!session.should_force_pending_hud_and_loupe_moves()); + + session.inflight_freeze_capture = Some(monitor); + + assert!(session.should_force_pending_hud_and_loupe_moves()); + + session.state.mode = OverlayMode::Live; + + assert!(!session.should_force_pending_hud_and_loupe_moves()); +} + +#[test] +fn tinted_hud_body_fill_amount_zero_keeps_base_fill() { + for theme in [HudTheme::Dark, HudTheme::Light] { + let base_fill = hud_helpers::hud_body_fill_srgba8(theme, false); + let no_tint = WindowRenderer::tinted_hud_body_fill(theme, false, false, 1.0, 0.0, 0.585); + + assert_eq!(no_tint.r(), base_fill[0]); + assert_eq!(no_tint.g(), base_fill[1]); + assert_eq!(no_tint.b(), base_fill[2]); + assert_eq!(no_tint.a(), 255); + } +} + +#[test] +fn tinted_hud_body_fill_100pct_tint_is_visibly_blue() { + let dark_min_delta: u16 = 57; + let light_min_delta: u16 = 24; + let sky_tint = 0.585; + + for theme in [HudTheme::Dark, HudTheme::Light] { + let base_fill = + WindowRenderer::tinted_hud_body_fill(theme, false, false, 1.0, 0.0, sky_tint); + let tinted_fill = + WindowRenderer::tinted_hud_body_fill(theme, false, false, 1.0, 1.0, sky_tint); + let rgb_delta = u16::from(base_fill.r()).abs_diff(u16::from(tinted_fill.r())) + + u16::from(base_fill.g()).abs_diff(u16::from(tinted_fill.g())) + + u16::from(base_fill.b()).abs_diff(u16::from(tinted_fill.b())); + let min_delta = + if matches!(theme, HudTheme::Dark) { dark_min_delta } else { light_min_delta }; + + assert!( + rgb_delta >= min_delta, + "expected minimum tint delta >= {min_delta}, got {rgb_delta}" + ); + } +} + +#[test] +fn tinted_hud_body_fill_preserves_alpha() { + for theme in [HudTheme::Dark, HudTheme::Light] { + let tint_hue = 0.585; + let opaque = WindowRenderer::tinted_hud_body_fill(theme, false, true, 0.25, 1.0, tint_hue); + let translucent = + WindowRenderer::tinted_hud_body_fill(theme, false, false, 0.33, 1.0, tint_hue); + + assert_eq!(opaque.a(), 255); + assert_eq!(translucent.a(), (0.33_f32 * 255.0).round().clamp(0.0, 255.0) as u8); + } +} + +#[test] +fn tinted_hud_body_fill_blur_active_enforces_min_opacity() { + for theme in [HudTheme::Dark, HudTheme::Light] { + let tint_hue = 0.585; + let fill = WindowRenderer::tinted_hud_body_fill(theme, true, false, 0.0, 0.0, tint_hue); + let expected = + (hud_helpers::hud_blur_tint_alpha(theme) * 255.0).round().clamp(0.0, 255.0) as u8; + + assert_eq!(fill.a(), expected); + } +} + +#[test] +fn frozen_toolbar_clamps_floating_position() { + let monitor = Rect::from_min_size(Pos2::new(-200.0, -100.0), Vec2::new(500.0, 400.0)); + let toolbar_size = Vec2::new(220.0, 42.0); + let clamped = WindowRenderer::clamp_toolbar_position( + monitor, + toolbar_size, + Pos2::new(-400.0, -240.0), + TOOLBAR_SCREEN_MARGIN_PX, + TOOLBAR_SCREEN_MARGIN_PX, + ); + + assert_eq!(clamped.x, monitor.min.x + TOOLBAR_SCREEN_MARGIN_PX); + assert_eq!(clamped.y, monitor.min.y + TOOLBAR_SCREEN_MARGIN_PX); +} + +#[test] +fn interactive_repaint_fps_uses_known_lower_monitor_refresh() { + assert_eq!(OverlaySession::interactive_repaint_fps(Some(60.0), Some(144.0)), 60.0); + assert_eq!(OverlaySession::interactive_repaint_fps(Some(75.0), Some(120.0)), 75.0); +} + +#[test] +fn interactive_repaint_fps_caps_known_higher_refresh_to_contract_limit() { + assert_eq!(OverlaySession::interactive_repaint_fps(Some(144.0), Some(60.0)), 120.0); + assert_eq!(OverlaySession::interactive_repaint_fps(Some(240.0), None), 120.0); +} + +#[test] +fn interactive_repaint_fps_falls_back_to_known_or_default_cap() { + assert_eq!(OverlaySession::interactive_repaint_fps(None, Some(90.0)), 90.0); + assert_eq!(OverlaySession::interactive_repaint_fps(None, Some(144.0)), 120.0); + assert_eq!(OverlaySession::interactive_repaint_fps(None, None), 120.0); +} + +#[test] +fn occluded_surface_skip_requests_redraw_until_retry_window_expires() { + let now = Instant::now(); + let mut retry_until = None; + + assert!(overlay::should_request_overlay_redraw_after_surface_skip( + SurfaceFrameSkipReason::Occluded, + now, + &mut retry_until, + )); + assert_eq!(retry_until, Some(now + OCCLUDED_FRAME_REDRAW_RETRY_WINDOW)); + assert!(overlay::should_request_overlay_redraw_after_surface_skip( + SurfaceFrameSkipReason::Occluded, + now + Duration::from_millis(500), + &mut retry_until, + )); + assert!(!overlay::should_request_overlay_redraw_after_surface_skip( + SurfaceFrameSkipReason::Occluded, + now + OCCLUDED_FRAME_REDRAW_RETRY_WINDOW, + &mut retry_until, + )); + assert_eq!(retry_until, None); +} + +#[test] +fn timeout_surface_skip_always_requests_redraw_without_touching_occluded_retry_window() { + let now = Instant::now(); + let retry_deadline = now + Duration::from_millis(250); + let mut retry_until = Some(retry_deadline); + + assert!(overlay::should_request_overlay_redraw_after_surface_skip( + SurfaceFrameSkipReason::Timeout, + now, + &mut retry_until, + )); + assert_eq!(retry_until, Some(retry_deadline)); +} diff --git a/packages/rsnap-overlay/src/overlay/tests/live_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/live_runtime.rs new file mode 100644 index 00000000..04fd037b --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/tests/live_runtime.rs @@ -0,0 +1,791 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +#[cfg(target_os = "macos")] +#[test] +fn apply_live_cursor_sample_updates_rgb_and_loupe_state() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let cursor = GlobalPoint::new(120, 180); + let patch = image::RgbaImage::from_pixel(3, 3, Rgba([10, 20, 30, 255])); + let mut session = OverlaySession::new(); + + session.cursor_monitor = Some(monitor); + session.state.cursor = Some(cursor); + session.state.alt_held = true; + + assert!( + session + .apply_live_cursor_sample_detail( + monitor, + cursor, + LiveCursorSample { rgb: Some(Rgb::new(10, 20, 30)), patch: Some(patch.clone()) }, + ) + .any_changed() + ); + assert_eq!(session.state.rgb, Some(Rgb::new(10, 20, 30))); + assert_eq!(session.state.loupe.as_ref().map(|loupe| loupe.center), Some(cursor)); + assert_eq!( + session.state.loupe.as_ref().map(|loupe| loupe.patch.dimensions()), + Some(patch.dimensions()) + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn apply_live_cursor_sample_detail_keeps_overlay_redraw_narrow_for_rgb_and_loupe_updates() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let cursor = GlobalPoint::new(120, 180); + let patch = image::RgbaImage::from_pixel(3, 3, Rgba([10, 20, 30, 255])); + let mut session = OverlaySession::new(); + + session.cursor_monitor = Some(monitor); + session.state.cursor = Some(cursor); + session.state.alt_held = true; + + let apply = session.apply_live_cursor_sample_detail( + monitor, + cursor, + LiveCursorSample { rgb: Some(Rgb::new(10, 20, 30)), patch: Some(patch) }, + ); + + assert_eq!( + apply, + LiveSampleApplyResult { overlay_changed: false, hud_changed: true, loupe_changed: true } + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn live_sample_request_redraw_intent_only_redraws_immediate_hover_changes() { + let session = OverlaySession::new(); + + assert_eq!( + session.live_sample_request_redraw_intent(false, true, true), + LiveSampleApplyResult::default() + ); + assert_eq!( + session.live_sample_request_redraw_intent(true, true, true), + LiveSampleApplyResult { overlay_changed: true, hud_changed: true, loupe_changed: false } + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn apply_loupe_activation_input_toggle_ignores_release_and_repeat() { + let mut session = OverlaySession::new(); + + session.config.alt_activation = AltActivationMode::Toggle; + + assert!(session.apply_loupe_activation_input(true, false)); + assert!(session.state.alt_held); + assert!(!session.apply_loupe_activation_input(true, true)); + assert!(session.state.alt_held); + assert!(!session.apply_loupe_activation_input(false, false)); + assert!(session.state.alt_held); + assert!(session.apply_loupe_activation_input(true, false)); + assert!(!session.state.alt_held); +} + +#[cfg(target_os = "macos")] +#[test] +fn apply_loupe_activation_input_hold_tracks_pressed_state() { + let mut session = OverlaySession::new(); + + session.config.alt_activation = AltActivationMode::Hold; + + assert!(session.apply_loupe_activation_input(true, false)); + assert!(session.state.alt_held); + assert!(!session.apply_loupe_activation_input(true, false)); + assert!(session.state.alt_held); + assert!(session.apply_loupe_activation_input(false, false)); + assert!(!session.state.alt_held); +} + +#[cfg(target_os = "macos")] +#[test] +fn plain_character_shortcut_available_blocks_loupe_activation_key_while_pressed() { + let mut session = OverlaySession::new(); + + session.config.alt_activation = AltActivationMode::Toggle; + + assert!(session.plain_character_shortcut_available()); + assert!(session.apply_loupe_activation_key_event(true, false)); + assert!(!session.plain_character_shortcut_available()); + assert!(!session.apply_loupe_activation_key_event(false, false)); + assert!(session.state.alt_held); + assert!(session.plain_character_shortcut_available()); +} + +#[cfg(target_os = "macos")] +#[test] +fn clear_loupe_activation_on_focus_loss_releases_hold_mode_state() { + let mut session = OverlaySession::new(); + + session.config.alt_activation = AltActivationMode::Hold; + + assert!(session.apply_loupe_activation_key_event(true, false)); + assert!(session.state.alt_held); + assert!(session.loupe_activation_key_down); + + session.clear_loupe_activation_on_focus_loss(); + + assert!(!session.state.alt_held); + assert!(!session.loupe_activation_key_down); + assert!(session.plain_character_shortcut_available()); +} + +#[cfg(target_os = "macos")] +#[test] +fn clear_loupe_activation_on_focus_loss_releases_toggle_press_without_toggling_off() { + let mut session = OverlaySession::new(); + + session.config.alt_activation = AltActivationMode::Toggle; + + assert!(session.apply_loupe_activation_key_event(true, false)); + assert!(session.state.alt_held); + assert!(session.loupe_activation_key_down); + + session.clear_loupe_activation_on_focus_loss(); + + assert!(session.state.alt_held); + assert!(!session.loupe_activation_key_down); + assert!(session.plain_character_shortcut_available()); +} + +#[cfg(target_os = "macos")] +#[test] +fn loupe_activation_shortcut_available_requires_plain_tab() { + let mut session = OverlaySession::new(); + + assert!(session.loupe_activation_shortcut_available()); + + session.keyboard_modifiers = ModifiersState::SHIFT; + + assert!(!session.loupe_activation_shortcut_available()); + + session.keyboard_modifiers = ModifiersState::ALT; + + assert!(!session.loupe_activation_shortcut_available()); + + session.keyboard_modifiers = ModifiersState::CONTROL; + + assert!(!session.loupe_activation_shortcut_available()); + + session.keyboard_modifiers = ModifiersState::SUPER; + + assert!(!session.loupe_activation_shortcut_available()); +} + +#[cfg(target_os = "macos")] +#[test] +fn apply_loupe_activation_key_event_tracks_modified_tab_without_activating_loupe() { + let mut session = OverlaySession::new(); + + session.keyboard_modifiers = ModifiersState::SHIFT; + + assert!(!session.apply_loupe_activation_key_event(true, false)); + assert!(session.loupe_activation_key_down); + assert!(!session.state.alt_held); + assert!(!session.plain_character_shortcut_available()); + + session.keyboard_modifiers = ModifiersState::default(); + + assert!(!session.plain_character_shortcut_available()); + assert!(!session.apply_loupe_activation_key_event(false, false)); + assert!(!session.loupe_activation_key_down); + assert!(session.plain_character_shortcut_available()); +} + +#[cfg(target_os = "macos")] +#[test] +fn pending_focus_loss_cleanup_does_not_clear_loupe_during_internal_focus_transfer() { + let overlay_window_id = WindowId::from(1); + let toolbar_window_id = WindowId::from(2); + let mut session = OverlaySession::new(); + + session.config.alt_activation = AltActivationMode::Hold; + + session.note_window_focus_change(overlay_window_id, true); + + assert!(session.apply_loupe_activation_key_event(true, false)); + assert!(session.state.alt_held); + + session.note_window_focus_change(overlay_window_id, false); + session.note_window_focus_change(toolbar_window_id, true); + session.maybe_clear_loupe_activation_after_focus_loss(); + + assert!(session.state.alt_held); + assert!(session.loupe_activation_key_down); +} + +#[cfg(target_os = "macos")] +#[test] +fn pending_focus_loss_cleanup_clears_loupe_after_all_overlay_windows_blur() { + let overlay_window_id = WindowId::from(1); + let mut session = OverlaySession::new(); + + session.config.alt_activation = AltActivationMode::Hold; + + session.note_window_focus_change(overlay_window_id, true); + + assert!(session.apply_loupe_activation_key_event(true, false)); + assert!(session.state.alt_held); + + session.note_window_focus_change(overlay_window_id, false); + session.maybe_clear_loupe_activation_after_focus_loss(); + + assert!(!session.state.alt_held); + assert!(!session.loupe_activation_key_down); + assert!(session.plain_character_shortcut_available()); +} + +#[cfg(target_os = "macos")] +#[test] +fn live_loupe_keeps_a_dedicated_window_during_live_alt() { + let mut session = OverlaySession::new(); + + session.state.mode = OverlayMode::Frozen; + + assert!(!session.live_loupe_uses_hud_window()); + assert!(!session.live_loupe_renders_in_hud_window()); + + session.state.mode = OverlayMode::Live; + + assert!(!session.live_loupe_uses_hud_window()); + assert!(!session.live_loupe_renders_in_hud_window()); + + session.state.alt_held = true; + + assert!(!session.live_loupe_renders_in_hud_window()); + + session.state.mode = OverlayMode::Frozen; + + assert!(!session.live_loupe_uses_hud_window()); + assert!(!session.live_loupe_renders_in_hud_window()); +} + +#[cfg(target_os = "macos")] +#[test] +fn hud_window_content_rect_stays_compact_for_live_alt() { + let hud_pill = HudPillGeometry { + rect: Rect::from_min_max(Pos2::new(14.0, 14.0), Pos2::new(200.0, 58.0)), + radius_points: f32::from(HUD_PILL_CORNER_RADIUS_POINTS), + }; + let loupe_tile = Rect::from_min_max(Pos2::new(14.0, 68.0), Pos2::new(246.0, 300.0)); + let live_rect = OverlaySession::hud_window_content_rect( + OverlayMode::Live, + true, + hud_pill, + Some(loupe_tile), + ); + + assert_eq!(live_rect, hud_pill.rect); + + let live_rect_without_hud_loupe = OverlaySession::hud_window_content_rect( + OverlayMode::Live, + false, + hud_pill, + Some(loupe_tile), + ); + + assert_eq!(live_rect_without_hud_loupe, hud_pill.rect); + + let frozen_rect = OverlaySession::hud_window_content_rect( + OverlayMode::Frozen, + true, + hud_pill, + Some(loupe_tile), + ); + + assert_eq!(frozen_rect, hud_pill.rect); +} + +#[cfg(target_os = "macos")] +#[test] +fn live_alt_loupe_window_redraw_is_not_skipped() { + let mut session = OverlaySession::new(); + + session.state.mode = OverlayMode::Live; + session.state.alt_held = true; + + assert!(!session.should_skip_loupe_redraw()); + + session.state.alt_held = false; + + assert!(session.should_skip_loupe_redraw()); +} + +#[test] +fn live_overlay_selection_flow_repaint_active_only_for_hovered_window() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let mut session = OverlaySession::new(); + + session.state.mode = OverlayMode::Live; + session.cursor_monitor = Some(monitor); + session.state.cursor = Some(GlobalPoint::new(120, 180)); + + assert!(!session.live_overlay_selection_flow_repaint_active()); + + session.state.hovered_window_rect = Some(MonitorRectPoints { + monitor_id: monitor.id, + rect: RectPoints::new(100, 120, 240, 320), + }); + + assert!(session.live_overlay_selection_flow_repaint_active()); + + session.config.selection_flow_enabled = false; + + assert!(!session.live_overlay_selection_flow_repaint_active()); + + session.config.selection_flow_enabled = true; + session.state.hovered_window_rect = Some(MonitorRectPoints { + monitor_id: monitor.id + 1, + rect: RectPoints::new(100, 120, 240, 320), + }); + + assert!(!session.live_overlay_selection_flow_repaint_active()); + + session.state.drag_rect = Some(MonitorRectPoints { + monitor_id: monitor.id, + rect: RectPoints::new(100, 120, 240, 320), + }); + + assert!(!session.live_overlay_selection_flow_repaint_active()); +} + +#[test] +fn live_drag_focus_rect_uses_large_drag_on_active_monitor() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(1_000.0, 800.0)); + let mut state = crate::state::OverlayState::new(); + + state.drag_rect = Some(MonitorRectPoints { + monitor_id: monitor.id, + rect: RectPoints::new(100, 120, 240, 320), + }); + + assert_eq!( + WindowRenderer::live_drag_focus_rect(&state, monitor, screen_rect), + Some(Rect::from_min_size(Pos2::new(100.0, 120.0), Vec2::new(240.0, 320.0))) + ); + + state.drag_rect = Some(MonitorRectPoints { + monitor_id: monitor.id + 1, + rect: RectPoints::new(100, 120, 240, 320), + }); + + assert_eq!(WindowRenderer::live_drag_focus_rect(&state, monitor, screen_rect), None); +} + +#[cfg(target_os = "macos")] +#[test] +fn sync_live_sample_attempt_does_not_leave_pending_request() { + let mut session = OverlaySession::new(); + + session.note_live_cursor_sample_request_started(7); + + assert!(session.live_sample_request_pending()); + + session.finish_sync_live_cursor_sample_attempt(7); + + assert!(!session.live_sample_request_pending()); + assert_eq!(session.latest_live_cursor_sample_request_id, Some(7)); + assert_eq!(session.applied_live_cursor_sample_request_id, Some(7)); +} + +#[test] +fn monitor_for_cursor_in_rects_finds_matching_monitor_without_windows() { + let monitor_a = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let monitor_b = MonitorRect { + id: 2, + origin: GlobalPoint::new(1_000, 0), + width: 1_200, + height: 900, + scale_factor_x1000: 2_000, + }; + + assert_eq!( + OverlaySession::monitor_for_cursor_in_rects( + &[monitor_a, monitor_b], + GlobalPoint::new(42, 88) + ), + Some(monitor_a) + ); + assert_eq!( + OverlaySession::monitor_for_cursor_in_rects( + &[monitor_a, monitor_b], + GlobalPoint::new(1_240, 120) + ), + Some(monitor_b) + ); + assert_eq!( + OverlaySession::monitor_for_cursor_in_rects( + &[monitor_a, monitor_b], + GlobalPoint::new(2_400, 1_200) + ), + None + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn startup_live_rgb_plan_keeps_focus_independent_from_seed_monitor() { + let monitor = MonitorRect { + id: 2, + origin: GlobalPoint::new(1_000, 0), + width: 1_200, + height: 900, + scale_factor_x1000: 2_000, + }; + + assert_eq!( + OverlaySession::startup_live_rgb_plan(None), + StartupLiveRgbPlan { focus_window: true, seed_monitor: None } + ); + assert_eq!( + OverlaySession::startup_live_rgb_plan(Some(monitor)), + StartupLiveRgbPlan { focus_window: true, seed_monitor: Some(monitor) } + ); +} + +#[test] +fn initialize_cursor_state_for_cursor_preserves_preseeded_live_rgb() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let cursor = GlobalPoint::new(120, 180); + let mut session = OverlaySession::new(); + + session.state.mode = OverlayMode::Live; + session.state.rgb = Some(Rgb::new(10, 20, 30)); + + session.initialize_cursor_state_for_cursor(cursor, Some(monitor)); + + assert_eq!(session.state.cursor, Some(cursor)); + assert_eq!(session.cursor_monitor, Some(monitor)); + assert_eq!(session.state.rgb, Some(Rgb::new(10, 20, 30))); +} + +#[test] +fn initialize_cursor_state_for_cursor_clears_rgb_when_no_monitor_matches() { + let cursor = GlobalPoint::new(2_400, 1_200); + let mut session = OverlaySession::new(); + + session.state.mode = OverlayMode::Live; + session.state.rgb = Some(Rgb::new(10, 20, 30)); + + session.initialize_cursor_state_for_cursor(cursor, None); + + assert_eq!(session.state.cursor, Some(cursor)); + assert_eq!(session.cursor_monitor, None); + assert_eq!(session.state.rgb, None); +} + +#[test] +fn live_overlay_redraw_needed_for_cursor_update_only_for_monitor_or_drag_changes() { + let monitor_a = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let monitor_b = MonitorRect { + id: 2, + origin: GlobalPoint::new(1_000, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let drag = Some(MonitorRectPoints { + monitor_id: monitor_a.id, + rect: RectPoints::new(100, 120, 240, 320), + }); + + assert!(!OverlaySession::live_overlay_redraw_needed_for_cursor_update( + Some(monitor_a), + monitor_a, + None, + None, + )); + assert!(OverlaySession::live_overlay_redraw_needed_for_cursor_update( + Some(monitor_a), + monitor_a, + None, + drag, + )); + assert!(OverlaySession::live_overlay_redraw_needed_for_cursor_update( + Some(monitor_a), + monitor_b, + None, + None, + )); +} + +#[test] +fn live_hud_redraw_needed_for_cursor_update_tracks_cursor_or_monitor_changes() { + let monitor_a = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let monitor_b = MonitorRect { + id: 2, + origin: GlobalPoint::new(1_000, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let cursor_a = GlobalPoint::new(120, 180); + let cursor_b = GlobalPoint::new(140, 200); + + assert!(!OverlaySession::live_hud_redraw_needed_for_cursor_update( + Some(cursor_a), + cursor_a, + Some(monitor_a), + monitor_a, + )); + assert!(OverlaySession::live_hud_redraw_needed_for_cursor_update( + Some(cursor_a), + cursor_b, + Some(monitor_a), + monitor_a, + )); + assert!(OverlaySession::live_hud_redraw_needed_for_cursor_update( + Some(cursor_a), + cursor_a, + Some(monitor_a), + monitor_b, + )); + assert!(OverlaySession::live_hud_redraw_needed_for_cursor_update( + None, cursor_a, None, monitor_a, + )); +} + +#[test] +fn live_hud_redraw_consumes_pending_move_without_size_change() { + let mut session = OverlaySession::new(); + + session.state.mode = OverlayMode::Live; + session.pending_hud_outer_pos = Some(GlobalPoint::new(120, 180)); + + assert!(session.should_try_pending_hud_window_move_on_redraw(&HudRedrawSummary::default())); +} + +#[test] +fn frozen_hud_redraw_does_not_consume_pending_move_without_size_change() { + let mut session = OverlaySession::new(); + + session.state.mode = OverlayMode::Frozen; + session.pending_hud_outer_pos = Some(GlobalPoint::new(120, 180)); + + assert!(!session.should_try_pending_hud_window_move_on_redraw(&HudRedrawSummary::default())); + assert!(session.should_try_pending_hud_window_move_on_redraw(&HudRedrawSummary { + position_update_elapsed: Some(Duration::from_micros(1)), + ..HudRedrawSummary::default() + })); +} + +#[test] +fn live_cursor_update_tries_pending_follow_window_moves() { + let mut session = OverlaySession::new(); + + session.state.mode = OverlayMode::Live; + + assert!(!session.should_try_pending_follow_window_move_on_live_cursor_update()); + + session.pending_hud_outer_pos = Some(GlobalPoint::new(120, 180)); + + assert!(session.should_try_pending_follow_window_move_on_live_cursor_update()); + + session.pending_hud_outer_pos = None; + session.pending_loupe_outer_pos = Some(GlobalPoint::new(140, 220)); + + assert!(session.should_try_pending_follow_window_move_on_live_cursor_update()); +} + +#[test] +fn frozen_cursor_update_does_not_try_pending_follow_window_moves() { + let mut session = OverlaySession::new(); + + session.state.mode = OverlayMode::Frozen; + session.pending_hud_outer_pos = Some(GlobalPoint::new(120, 180)); + session.pending_loupe_outer_pos = Some(GlobalPoint::new(140, 220)); + + assert!(!session.should_try_pending_follow_window_move_on_live_cursor_update()); +} + +#[cfg(target_os = "macos")] +#[test] +fn apply_live_cursor_sample_clears_existing_loupe_when_alt_is_released() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let cursor = GlobalPoint::new(120, 180); + let mut session = OverlaySession::new(); + + session.cursor_monitor = Some(monitor); + session.state.cursor = Some(cursor); + session.state.alt_held = true; + + let _ = session.apply_live_cursor_sample_detail( + monitor, + cursor, + LiveCursorSample { + rgb: Some(Rgb::new(10, 20, 30)), + patch: Some(image::RgbaImage::from_pixel(3, 3, Rgba([10, 20, 30, 255]))), + }, + ); + + session.state.alt_held = false; + + assert!( + session + .apply_live_cursor_sample_detail( + monitor, + cursor, + LiveCursorSample { rgb: None, patch: None }, + ) + .any_changed() + ); + assert!(session.state.loupe.is_none()); +} + +#[cfg(target_os = "macos")] +#[test] +fn stabilized_live_hud_inner_size_keeps_live_width_from_shrinking() { + let mut session = OverlaySession::new(); + + session.state.mode = OverlayMode::Live; + session.hud_inner_size_points = Some((826, 44)); + + assert_eq!( + OverlaySession::stabilized_live_hud_inner_size( + OverlayMode::Live, + session.hud_inner_size_points, + (810, 44), + ), + (826, 44) + ); + assert_eq!( + OverlaySession::stabilized_live_hud_inner_size( + OverlayMode::Live, + session.hud_inner_size_points, + (780, 44), + ), + (826, 44) + ); + + session.state.mode = OverlayMode::Frozen; + + assert_eq!( + OverlaySession::stabilized_live_hud_inner_size( + OverlayMode::Frozen, + session.hud_inner_size_points, + (810, 44), + ), + (810, 44) + ); +} + +#[test] +fn live_hud_position_text_uses_stable_monitor_width() { + let monitor = MonitorRect { + id: 5, + origin: GlobalPoint::new(0, 0), + width: 3_008, + height: 1_692, + scale_factor_x1000: 2_000, + }; + let short = hud_helpers::format_live_hud_position_text(monitor, GlobalPoint::new(842, 846)); + let long = hud_helpers::format_live_hud_position_text(monitor, GlobalPoint::new(1_504, 1_320)); + + assert_eq!(short.len(), long.len()); + assert_eq!(short, "x= 842, y= 846"); + assert_eq!(long, "x=1504, y=1320"); +} + +#[test] +fn live_hud_rgb_text_uses_fixed_width_placeholders() { + let (missing_hex, missing_rgb) = hud_helpers::format_live_hud_rgb_text(None); + let (hex, rgb) = hud_helpers::format_live_hud_rgb_text(Some(Rgb::new(7, 128, 255))); + + assert_eq!(missing_hex.len(), hex.len()); + assert_eq!(missing_rgb.len(), rgb.len()); + assert_eq!(missing_hex, "#??????"); + assert_eq!(missing_rgb, "RGB(???, ???, ???)"); + assert_eq!(rgb, "RGB( 7, 128, 255)"); +} + +#[test] +fn stable_live_loupe_side_prefers_configured_patch_side() { + let mut state = crate::state::OverlayState::new(); + + state.loupe_patch_side_px = 21; + state.loupe = Some(LoupeSample { + center: GlobalPoint::new(100, 120), + patch: RgbaImage::from_pixel(17, 19, image::Rgba([0, 0, 0, 255])), + }); + + assert_eq!(hud_helpers::stable_live_loupe_side_px(&state), 21); +} + +#[test] +fn stable_live_loupe_side_ignores_larger_runtime_patch() { + let mut state = crate::state::OverlayState::new(); + + state.loupe_patch_side_px = 21; + state.loupe = Some(LoupeSample { + center: GlobalPoint::new(100, 120), + patch: RgbaImage::from_pixel(25, 25, image::Rgba([0, 0, 0, 255])), + }); + + assert_eq!(hud_helpers::stable_live_loupe_side_px(&state), 21); +} + +#[test] +fn stable_live_loupe_window_inner_size_matches_runtime_target() { + assert_eq!(hud_helpers::stable_live_loupe_window_inner_size_points(21), (232, 232)); + assert_eq!(hud_helpers::stable_live_loupe_window_inner_size_points(1), (32, 32)); +} diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs new file mode 100644 index 00000000..33923695 --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs @@ -0,0 +1,1761 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +#[test] +fn pending_freeze_capture_dispatches_even_with_seeded_preview() { + let monitor = test_monitor(); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + session.pending_freeze_capture = Some(monitor); + + assert!(session.should_dispatch_pending_freeze_capture(monitor)); +} + +#[cfg(not(target_os = "macos"))] +#[test] +fn pending_freeze_capture_waits_for_empty_frozen_image_off_macos() { + let monitor = test_monitor(); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + session.pending_freeze_capture = Some(monitor); + + assert!(!session.should_dispatch_pending_freeze_capture(monitor)); +} + +#[test] +fn frozen_final_capture_ready_requires_no_pending_or_inflight_capture() { + let monitor = test_monitor(); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + + assert!(!session.frozen_final_capture_ready()); + + session.state.finish_freeze(monitor, test_frozen_image()); + + session.authoritative_frozen_capture_ready = true; + + assert!(session.frozen_final_capture_ready()); + + session.pending_freeze_capture = Some(monitor); + + assert!(!session.frozen_final_capture_ready()); + + session.pending_freeze_capture = None; + session.inflight_freeze_capture = Some(monitor); + + assert!(!session.frozen_final_capture_ready()); +} + +#[test] +fn frozen_preview_does_not_become_final_ready_when_capture_tracking_clears_without_success() { + let monitor = test_monitor(); + let capture_rect = RectPoints::new(100, 120, 220, 180); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + session.state.frozen_capture_rect = Some(capture_rect); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + session.inflight_freeze_capture = Some(monitor); + + assert!(!session.frozen_final_capture_ready()); + assert!(!session.scroll_capture_selection_is_ready()); + + // Emulate a preview-first failure where the authoritative capture tracking clears. + session.inflight_freeze_capture = None; + + assert!(!session.frozen_final_capture_ready()); + assert!(!session.scroll_capture_selection_is_ready()); + + session.begin_png_action(PngAction::Copy); + + assert_eq!(session.pending_png_action, None); + assert!(session.pending_encode_png.is_none()); + assert_eq!(session.state.error_message.as_deref(), Some("Preparing capture...")); +} + +#[test] +fn unrelated_worker_errors_do_not_clear_pending_freeze_capture_state() { + let monitor = test_monitor(); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + + session.pending_freeze_capture = Some(monitor); + session.pending_freeze_capture_armed = true; + session.pending_window_freeze_capture = Some(crate::overlay::WindowFreezeCaptureTarget { + monitor, + window_id: 42, + rect: RectPoints::new(10, 20, 30, 40), + }); + + let control = session.maybe_tick_worker_response_limiter(WorkerResponse::Error { + source: WorkerErrorSource::RefreshWindowList, + message: String::from("window refresh failed"), + }); + + assert!(matches!(control, OverlayControl::Continue)); + assert_eq!(session.pending_freeze_capture, Some(monitor)); + assert!(session.pending_freeze_capture_armed); + assert!(session.inflight_freeze_capture.is_none()); + assert!(session.pending_window_freeze_capture.is_some()); + assert_eq!(session.state.error_message.as_deref(), Some("window refresh failed")); +} + +#[test] +fn frozen_selection_drag_starts_only_for_drag_region_inside_capture_rect() { + let monitor = test_monitor(); + let capture_rect = RectPoints::new(100, 120, 200, 240); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + session.state.frozen_capture_rect = Some(capture_rect); + + assert!(!session.begin_frozen_selection_drag(GlobalPoint::new(150, 180))); + + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + + assert!(!session.begin_frozen_selection_drag(GlobalPoint::new(50, 80))); + assert!(session.begin_frozen_selection_drag(GlobalPoint::new(150, 180))); + assert_eq!( + session.frozen_selection_drag, + FrozenSelectionDragState { active: true, pointer_offset_x: 50, pointer_offset_y: 60 } + ); + + session.stop_frozen_selection_drag(); + + session.state.frozen_capture_rect = Some(RectPoints::new(0, 120, 200, 240)); + + assert!(!session.begin_frozen_selection_drag(GlobalPoint::new(-1, 180))); +} + +#[test] +fn frozen_selection_drag_updates_capture_rect_and_toolbar_position() { + let monitor = test_monitor(); + let capture_rect = RectPoints::new(100, 120, 200, 240); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + session.state.frozen_capture_rect = Some(capture_rect); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + + session.seed_frozen_toolbar_default_position(monitor, capture_rect); + + assert!(session.begin_frozen_selection_drag(GlobalPoint::new(110, 130))); + assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(260, 310))); + + let expected_rect = RectPoints::new(250, 300, 200, 240); + let expected_toolbar_pos = + session.frozen_toolbar_default_position_for_capture_rect(monitor, expected_rect); + + assert_eq!(session.state.frozen_capture_rect, Some(expected_rect)); + assert_eq!(session.toolbar_state.floating_position, Some(expected_toolbar_pos)); +} + +#[test] +fn frozen_selection_drag_clamps_capture_rect_to_monitor_bounds() { + let monitor = test_monitor(); + let capture_rect = RectPoints::new(100, 120, 200, 240); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + session.state.frozen_capture_rect = Some(capture_rect); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + + assert!(session.begin_frozen_selection_drag(GlobalPoint::new(110, 130))); + assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(-200, -300))); + assert_eq!(session.state.frozen_capture_rect, Some(RectPoints::new(0, 0, 200, 240))); + assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(1_500, 1_400))); + assert_eq!(session.state.frozen_capture_rect, Some(RectPoints::new(800, 560, 200, 240))); +} + +#[test] +fn cropped_frozen_capture_image_uses_moved_capture_rect() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 4, + height: 3, + scale_factor_x1000: 1_000, + }; + let image = RgbaImage::from_fn(4, 3, |x, y| Rgba([x as u8, y as u8, 0, 255])); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, image); + + session.state.frozen_capture_rect = Some(RectPoints::new(0, 0, 2, 1)); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + + assert!(session.begin_frozen_selection_drag(GlobalPoint::new(0, 0))); + assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(1, 1))); + + let cropped = session.cropped_frozen_capture_image().expect("moved frozen crop"); + + assert_eq!(cropped.width(), 2); + assert_eq!(cropped.height(), 1); + assert_eq!(cropped.get_pixel(0, 0), &Rgba([1, 1, 0, 255])); + assert_eq!(cropped.get_pixel(1, 0), &Rgba([2, 1, 0, 255])); +} + +#[test] +fn auto_center_frozen_capture_rect_recenters_detected_content() { + let monitor = test_monitor_with_scale(80, 60, 2_000); + let capture_rect = RectPoints::new(20, 16, 40, 24); + let mut image = RgbaImage::from_pixel(160, 120, Rgba([14, 16, 20, 255])); + let mut session = OverlaySession::new(); + + for y in 40..52 { + for x in 52..68 { + image.put_pixel(x, y, Rgba([228, 232, 240, 255])); + } + } + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, image); + + session.state.frozen_capture_rect = Some(capture_rect); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + + session.seed_frozen_toolbar_default_position(monitor, capture_rect); + + assert!(session.auto_center_frozen_capture_rect()); + + let expected_rect = RectPoints::new(10, 11, 40, 24); + let expected_toolbar_pos = + session.frozen_toolbar_default_position_for_capture_rect(monitor, expected_rect); + + assert_eq!(session.state.frozen_capture_rect, Some(expected_rect)); + assert_eq!(session.toolbar_state.floating_position, Some(expected_toolbar_pos)); +} + +#[test] +fn frozen_toolbar_default_position_centers_on_capture_rect_midpoint() { + let monitor = test_monitor_with_scale(400, 300, 2_000); + let capture_rect = RectPoints::new(150, 100, 100, 60); + let session = OverlaySession::new(); + let toolbar_size = WindowRenderer::frozen_toolbar_size(&session.toolbar_state); + let toolbar_pos = + session.frozen_toolbar_default_position_for_capture_rect(monitor, capture_rect); + let toolbar_midpoint_x = toolbar_pos.x + toolbar_size.x * 0.5; + let capture_midpoint_x = capture_rect.x as f32 + capture_rect.width as f32 * 0.5; + + assert_eq!(toolbar_midpoint_x, capture_midpoint_x); +} + +#[test] +fn auto_center_frozen_capture_rect_noops_for_uniform_crop() { + let monitor = test_monitor_with_scale(80, 60, 1_000); + let capture_rect = RectPoints::new(20, 16, 40, 24); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, RgbaImage::from_pixel(80, 60, Rgba([24, 24, 28, 255]))); + + session.state.frozen_capture_rect = Some(capture_rect); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + + assert!(!session.auto_center_frozen_capture_rect()); + assert_eq!(session.state.frozen_capture_rect, Some(capture_rect)); +} + +#[test] +fn global_left_release_stops_frozen_selection_drag() { + let mut session = OverlaySession::new(); + + session.frozen_selection_drag = + FrozenSelectionDragState { active: true, pointer_offset_x: 12, pointer_offset_y: 34 }; + + session + .maybe_stop_frozen_selection_drag_for_mouse_input(ElementState::Pressed, MouseButton::Left); + + assert!(session.frozen_selection_drag.active); + + session.maybe_stop_frozen_selection_drag_for_mouse_input( + ElementState::Released, + MouseButton::Right, + ); + + assert!(session.frozen_selection_drag.active); + + session.maybe_stop_frozen_selection_drag_for_mouse_input( + ElementState::Released, + MouseButton::Left, + ); + + assert_eq!(session.frozen_selection_drag, FrozenSelectionDragState::default()); +} + +#[test] +fn scroll_capture_and_export_wait_for_authoritative_frozen_capture() { + let monitor = test_monitor(); + let capture_rect = RectPoints::new(100, 120, 220, 180); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + session.authoritative_frozen_capture_ready = true; + session.state.frozen_capture_rect = Some(capture_rect); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + + assert!(session.scroll_capture_selection_is_ready()); + + session.inflight_freeze_capture = Some(monitor); + + assert!(!session.scroll_capture_selection_is_ready()); + + session.begin_png_action(PngAction::Copy); + + assert_eq!(session.pending_png_action, None); + assert!(session.pending_encode_png.is_none()); + assert_eq!(session.state.error_message.as_deref(), Some("Preparing capture...")); +} + +#[test] +fn frozen_selection_scrim_rects_frame_focus_rect_without_covering_it() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 80.0)); + let focus_rect = Rect::from_min_size(Pos2::new(20.0, 10.0), Vec2::new(40.0, 30.0)); + let scrim_rects = WindowRenderer::frozen_selection_scrim_rects(screen_rect, focus_rect); + + assert_eq!( + scrim_rects, + [ + Rect::from_min_max(Pos2::new(0.0, 0.0), Pos2::new(100.0, 10.0)), + Rect::from_min_max(Pos2::new(0.0, 40.0), Pos2::new(100.0, 80.0)), + Rect::from_min_max(Pos2::new(0.0, 10.0), Pos2::new(20.0, 40.0)), + Rect::from_min_max(Pos2::new(60.0, 10.0), Pos2::new(100.0, 40.0)), + ] + ); + assert!(scrim_rects.into_iter().all(|rect| !rect.contains(focus_rect.center()))); +} + +#[test] +fn frozen_selection_scrim_rects_leave_zero_area_regions_at_screen_edges() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 80.0)); + let focus_rect = Rect::from_min_size(Pos2::new(0.0, 10.0), Vec2::new(40.0, 30.0)); + let scrim_rects = WindowRenderer::frozen_selection_scrim_rects(screen_rect, focus_rect); + let non_empty = + scrim_rects.iter().filter(|rect| rect.width() > 0.0 && rect.height() > 0.0).count(); + + assert_eq!(scrim_rects[2].width(), 0.0); + assert_eq!(non_empty, 3); +} + +#[test] +fn frozen_selection_scrim_rects_are_empty_for_fullscreen_rect() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 80.0)); + let scrim_rects = WindowRenderer::frozen_selection_scrim_rects(screen_rect, screen_rect); + + assert!(scrim_rects.into_iter().all(|rect| rect.width() <= 0.0 || rect.height() <= 0.0)); +} + +#[test] +fn selection_dashed_border_rect_is_absent_for_fullscreen_rect() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 80.0)); + let border_outset = + WindowRenderer::selection_dashed_border_outset(SELECTION_DASHED_BORDER_WIDTH_PX, 1.0); + + assert_eq!( + WindowRenderer::selection_dashed_border_rect(screen_rect, screen_rect, border_outset,), + None + ); +} + +#[test] +fn selection_dashed_border_rect_expands_focus_rect_outward() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 80.0)); + let focus_rect = Rect::from_min_size(Pos2::new(20.0, 10.0), Vec2::new(40.0, 30.0)); + let border_outset = + WindowRenderer::selection_dashed_border_outset(SELECTION_DASHED_BORDER_WIDTH_PX, 1.0); + + assert_eq!( + WindowRenderer::selection_dashed_border_rect(screen_rect, focus_rect, border_outset,), + Some(Rect::from_min_max(Pos2::new(18.5, 8.5), Pos2::new(61.5, 41.5),)) + ); +} + +#[test] +fn selection_dashed_border_rect_can_extend_beyond_screen_edge() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 80.0)); + let focus_rect = Rect::from_min_size(Pos2::new(0.0, 10.0), Vec2::new(40.0, 30.0)); + let border_outset = + WindowRenderer::selection_dashed_border_outset(SELECTION_DASHED_BORDER_WIDTH_PX, 1.0); + + assert_eq!( + WindowRenderer::selection_dashed_border_rect(screen_rect, focus_rect, border_outset,), + Some(Rect::from_min_max(Pos2::new(-1.5, 8.5), Pos2::new(41.5, 41.5),)) + ); +} + +#[test] +fn selection_dashed_border_dash_ranges_distribute_remainder_evenly() { + const EPSILON: f32 = 1e-4; + + let rect = Rect::from_min_max(Pos2::new(18.5, 8.5), Pos2::new(61.5, 41.5)); + let perimeter = WindowRenderer::selection_dashed_border_perimeter(rect); + let ranges = WindowRenderer::selection_dashed_border_dash_ranges( + perimeter, + SELECTION_DASHED_BORDER_DASH_LENGTH_PX, + SELECTION_DASHED_BORDER_GAP_LENGTH_PX, + ); + + assert_eq!(ranges.len(), 15); + + let dash_length = ranges[0].1 - ranges[0].0; + let gap_length = ranges[1].0 - ranges[0].1; + + assert!((dash_length - SELECTION_DASHED_BORDER_DASH_LENGTH_PX).abs() < EPSILON); + + for window in ranges.windows(2) { + let current_dash_length = window[0].1 - window[0].0; + let current_gap_length = window[1].0 - window[0].1; + + assert!((current_dash_length - dash_length).abs() < EPSILON); + assert!((current_gap_length - gap_length).abs() < EPSILON); + } + + let seam_gap_length = perimeter - ranges.last().unwrap().1 + ranges[0].0; + + assert!((seam_gap_length - gap_length).abs() < EPSILON); +} + +#[test] +fn selection_dashed_border_segments_split_at_square_corners() { + let rect = Rect::from_min_max(Pos2::new(18.5, 8.5), Pos2::new(38.5, 18.5)); + + assert_eq!( + WindowRenderer::selection_dashed_border_segments(rect, 25.0, 5.0), + vec![ + [Pos2::new(18.5, 8.5), Pos2::new(38.5, 8.5)], + [Pos2::new(38.5, 8.5), Pos2::new(38.5, 13.5)], + [Pos2::new(38.5, 18.5), Pos2::new(18.5, 18.5)], + [Pos2::new(18.5, 18.5), Pos2::new(18.5, 13.5)], + ] + ); +} + +#[test] +fn selection_dashed_border_cache_reuses_geometry_for_same_rect() { + let rect = Rect::from_min_max(Pos2::new(18.5, 8.5), Pos2::new(61.5, 41.5)); + let other_rect = Rect::from_min_max(Pos2::new(18.5, 8.5), Pos2::new(41.5, 41.5)); + let sentinel = [Pos2::new(-1.0, -1.0), Pos2::new(-2.0, -2.0)]; + let mut cache = SelectionDashedBorderCache::default(); + let initial = WindowRenderer::selection_dashed_border_cached_segments( + &mut cache, + rect, + SELECTION_DASHED_BORDER_DASH_LENGTH_PX, + SELECTION_DASHED_BORDER_GAP_LENGTH_PX, + ) + .to_vec(); + + assert!(!initial.is_empty()); + + cache.segments[0] = sentinel; + + let cached = WindowRenderer::selection_dashed_border_cached_segments( + &mut cache, + rect, + SELECTION_DASHED_BORDER_DASH_LENGTH_PX, + SELECTION_DASHED_BORDER_GAP_LENGTH_PX, + ); + + assert_eq!(cached[0], sentinel); + + let rebuilt = WindowRenderer::selection_dashed_border_cached_segments( + &mut cache, + other_rect, + SELECTION_DASHED_BORDER_DASH_LENGTH_PX, + SELECTION_DASHED_BORDER_GAP_LENGTH_PX, + ); + + assert_ne!(rebuilt[0], sentinel); +} + +#[test] +fn selection_dashed_border_outset_accounts_for_feathering() { + assert_eq!( + WindowRenderer::selection_dashed_border_outset(SELECTION_DASHED_BORDER_WIDTH_PX, 1.0), + 1.5 + ); + assert_eq!( + WindowRenderer::selection_dashed_border_outset(SELECTION_DASHED_BORDER_WIDTH_PX, 2.0), + 1.25 + ); +} + +#[test] +fn selection_dashed_border_metrics_track_physical_pixels() { + assert_eq!( + WindowRenderer::selection_dashed_border_metrics(1.0), + SelectionDashedBorderMetrics { stroke_width: 2.0, dash_length: 6.0, gap_length: 4.0 } + ); + assert_eq!( + WindowRenderer::selection_dashed_border_metrics(2.0), + SelectionDashedBorderMetrics { stroke_width: 1.0, dash_length: 3.0, gap_length: 2.0 } + ); + assert_eq!( + WindowRenderer::selection_dashed_border_metrics(1.5), + SelectionDashedBorderMetrics { + stroke_width: 2.0 / 1.5, + dash_length: 6.0 / 1.5, + gap_length: 4.0 / 1.5, + } + ); +} + +#[test] +fn frozen_selection_scrim_is_stronger_than_live_drag_scrim_in_light_theme() { + let frozen_scrim = WindowRenderer::frozen_selection_scrim_color(HudTheme::Light); + let drag_scrim = WindowRenderer::live_drag_selection_scrim_color(HudTheme::Light); + + assert!(frozen_scrim.a() > drag_scrim.a()); +} + +#[test] +fn selection_flow_palette_tracks_hud_theme() { + assert_eq!( + WindowRenderer::selection_flow_palette(HudTheme::Dark), + &crate::overlay::SELECTION_FLOW_PALETTE + ); + assert_eq!( + WindowRenderer::selection_flow_palette(HudTheme::Light), + &crate::overlay::SELECTION_FLOW_LIGHT_PALETTE + ); +} + +#[test] +fn selection_flow_color_can_share_theme_rgb() { + let dark = WindowRenderer::selection_flow_color(0.17, HudTheme::Dark, 0.4, 1.0); + let light = WindowRenderer::selection_flow_color(0.17, HudTheme::Light, 0.4, 1.0); + + assert_eq!((dark.r(), dark.g(), dark.b()), (light.r(), light.g(), light.b())); + assert_eq!(dark.a(), light.a()); +} + +#[test] +fn frozen_toolbar_default_position_fits_below_capture_rect() { + let monitor = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); + let capture_rect = Rect::from_min_size(Pos2::new(50.0, 100.0), Vec2::new(300.0, 200.0)); + let toolbar_size = Vec2::new(460.0, 54.0); + let pos = WindowRenderer::frozen_toolbar_default_pos( + monitor, + capture_rect, + toolbar_size, + ToolbarPlacement::Bottom, + ); + let expected_x = (capture_rect.center().x - toolbar_size.x / 2.0).clamp( + TOOLBAR_SCREEN_MARGIN_PX, + (monitor.max.x - toolbar_size.x - TOOLBAR_SCREEN_MARGIN_PX).max(TOOLBAR_SCREEN_MARGIN_PX), + ); + + assert!((pos.x - expected_x).abs() < f32::EPSILON); + assert_eq!(pos.y, capture_rect.max.y + TOOLBAR_CAPTURE_GAP_PX); +} + +#[test] +fn frozen_toolbar_default_position_falls_inside_when_no_space_below_capture_rect() { + let monitor = Rect::from_min_size(Pos2::ZERO, Vec2::new(500.0, 600.0)); + let toolbar_size = Vec2::new(460.0, 54.0); + let capture_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(500.0, 560.0)); + let pos = WindowRenderer::frozen_toolbar_default_pos( + monitor, + capture_rect, + toolbar_size, + ToolbarPlacement::Bottom, + ); + let expected_x = (capture_rect.center().x - toolbar_size.x / 2.0).clamp( + TOOLBAR_SCREEN_MARGIN_PX, + (monitor.max.x - toolbar_size.x - TOOLBAR_SCREEN_MARGIN_PX).max(TOOLBAR_SCREEN_MARGIN_PX), + ); + let expected_y = capture_rect.max.y - TOOLBAR_SCREEN_MARGIN_PX - toolbar_size.y; + + assert_eq!(pos.x, expected_x); + assert_eq!(pos.y, capture_rect.max.y - TOOLBAR_SCREEN_MARGIN_PX - toolbar_size.y); + assert_eq!(pos.y, expected_y); +} + +#[test] +fn frozen_toolbar_top_default_position_fits_above_capture_rect() { + let monitor = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); + let capture_rect = Rect::from_min_size(Pos2::new(50.0, 180.0), Vec2::new(300.0, 200.0)); + let toolbar_size = Vec2::new(460.0, 54.0); + let pos = WindowRenderer::frozen_toolbar_default_pos( + monitor, + capture_rect, + toolbar_size, + ToolbarPlacement::Top, + ); + let expected_x = (capture_rect.center().x - toolbar_size.x / 2.0).clamp( + TOOLBAR_SCREEN_MARGIN_PX, + (monitor.max.x - toolbar_size.x - TOOLBAR_SCREEN_MARGIN_PX).max(TOOLBAR_SCREEN_MARGIN_PX), + ); + + assert_eq!(pos.x, expected_x); + assert_eq!(pos.y, capture_rect.min.y - TOOLBAR_CAPTURE_GAP_PX - toolbar_size.y); +} + +#[test] +fn frozen_toolbar_top_default_position_falls_inside_when_no_space_above_capture_rect() { + let monitor = Rect::from_min_size(Pos2::ZERO, Vec2::new(500.0, 600.0)); + let capture_rect = Rect::from_min_size(Pos2::new(0.0, 20.0), Vec2::new(500.0, 400.0)); + let toolbar_size = Vec2::new(460.0, 54.0); + let pos = WindowRenderer::frozen_toolbar_default_pos( + monitor, + capture_rect, + toolbar_size, + ToolbarPlacement::Top, + ); + let expected_x = (capture_rect.center().x - toolbar_size.x / 2.0).clamp( + TOOLBAR_SCREEN_MARGIN_PX, + (monitor.max.x - toolbar_size.x - TOOLBAR_SCREEN_MARGIN_PX).max(TOOLBAR_SCREEN_MARGIN_PX), + ); + + assert_eq!(pos.x, expected_x); + assert_eq!(pos.y, capture_rect.min.y + TOOLBAR_SCREEN_MARGIN_PX); +} + +#[test] +fn selection_size_badge_rect_fits_below_capture_rect() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); + let capture_rect = Rect::from_min_size(Pos2::new(120.0, 160.0), Vec2::new(320.0, 240.0)); + let badge_rect = + WindowRenderer::selection_size_badge_rect(screen_rect, capture_rect, Vec2::new(92.0, 26.0)); + + assert_eq!(badge_rect.max.x, capture_rect.max.x); + assert_eq!(badge_rect.min.y, capture_rect.max.y + SELECTION_SIZE_BADGE_GAP_PX); +} + +#[test] +fn selection_size_badge_rect_falls_inside_when_no_space_below() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); + let capture_rect = Rect::from_min_size(Pos2::new(120.0, 420.0), Vec2::new(320.0, 160.0)); + let badge_rect = + WindowRenderer::selection_size_badge_rect(screen_rect, capture_rect, Vec2::new(92.0, 26.0)); + + assert_eq!(badge_rect.max.x, capture_rect.max.x); + assert_eq!(badge_rect.max.y, capture_rect.max.y - SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX); + assert!(badge_rect.max.y <= screen_rect.max.y - SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX); +} + +#[test] +fn selection_size_badge_rect_clamps_narrow_left_capture_into_viewport() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); + let capture_rect = Rect::from_min_size(Pos2::new(0.0, 160.0), Vec2::new(40.0, 120.0)); + let badge_rect = + WindowRenderer::selection_size_badge_rect(screen_rect, capture_rect, Vec2::new(92.0, 26.0)); + + assert_eq!(badge_rect.min.x, screen_rect.min.x); + assert!(badge_rect.max.x > capture_rect.max.x); +} + +#[test] +fn selection_size_badge_rect_clamps_near_left_narrow_capture_into_viewport() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); + let capture_rect = Rect::from_min_size(Pos2::new(20.0, 160.0), Vec2::new(40.0, 120.0)); + let badge_rect = + WindowRenderer::selection_size_badge_rect(screen_rect, capture_rect, Vec2::new(92.0, 26.0)); + + assert_eq!(badge_rect.min.x, screen_rect.min.x); + assert!(badge_rect.max.x > capture_rect.max.x); +} + +#[test] +fn selection_size_badge_rect_keeps_tiny_bottom_capture_visible() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); + let capture_rect = Rect::from_min_size(Pos2::new(120.0, 588.0), Vec2::new(140.0, 12.0)); + let badge_rect = + WindowRenderer::selection_size_badge_rect(screen_rect, capture_rect, Vec2::new(92.0, 26.0)); + + assert_eq!(badge_rect.max.y, screen_rect.max.y); + assert!(badge_rect.min.y < capture_rect.min.y); + assert!(badge_rect.min.y >= screen_rect.min.y); +} + +#[test] +fn frozen_selection_size_badge_falls_inside_when_default_bottom_toolbar_slot_overlaps() { + let monitor = test_monitor(); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let capture_rect_points = RectPoints::new(200, 180, 200, 300); + let capture_rect = Rect::from_min_size( + Pos2::new(capture_rect_points.x as f32, capture_rect_points.y as f32), + Vec2::new(capture_rect_points.width as f32, capture_rect_points.height as f32), + ); + let mut state = OverlayState::new(); + + state.mode = OverlayMode::Frozen; + state.monitor = Some(monitor); + state.frozen_capture_rect = Some(capture_rect_points); + + let toolbar_state = FrozenToolbarState { visible: true, ..FrozenToolbarState::default() }; + let reserved_rect = WindowRenderer::frozen_toolbar_reserved_rect( + &state, + monitor, + screen_rect, + ToolbarPlacement::Bottom, + &toolbar_state, + ) + .expect("default bottom toolbar slot should be reserved"); + let badge_rect = WindowRenderer::selection_size_badge_rect_with_reserved_rect( + screen_rect, + capture_rect, + Vec2::new(92.0, 26.0), + Some(reserved_rect), + ); + + assert_eq!(reserved_rect.min.y, capture_rect.max.y + TOOLBAR_CAPTURE_GAP_PX); + assert_eq!(badge_rect.max.x, capture_rect.max.x); + assert_eq!(badge_rect.max.y, capture_rect.max.y - SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX); + assert!(!badge_rect.intersects(reserved_rect)); +} + +#[test] +fn frozen_selection_size_badge_keeps_below_placement_after_toolbar_leaves_default_slot() { + let monitor = test_monitor(); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let capture_rect_points = RectPoints::new(200, 180, 200, 300); + let capture_rect = Rect::from_min_size( + Pos2::new(capture_rect_points.x as f32, capture_rect_points.y as f32), + Vec2::new(capture_rect_points.width as f32, capture_rect_points.height as f32), + ); + let mut state = OverlayState::new(); + + state.mode = OverlayMode::Frozen; + state.monitor = Some(monitor); + state.frozen_capture_rect = Some(capture_rect_points); + + let default_toolbar_pos = WindowRenderer::frozen_toolbar_default_pos( + screen_rect, + capture_rect, + WindowRenderer::frozen_toolbar_size(&FrozenToolbarState::default()), + ToolbarPlacement::Bottom, + ); + let toolbar_state = FrozenToolbarState { + visible: true, + floating_position: Some(default_toolbar_pos + Vec2::new(0.0, 24.0)), + ..FrozenToolbarState::default() + }; + let reserved_rect = WindowRenderer::frozen_toolbar_reserved_rect( + &state, + monitor, + screen_rect, + ToolbarPlacement::Bottom, + &toolbar_state, + ); + let badge_rect = WindowRenderer::selection_size_badge_rect_with_reserved_rect( + screen_rect, + capture_rect, + Vec2::new(92.0, 26.0), + reserved_rect, + ); + + assert!(reserved_rect.is_none()); + assert_eq!(badge_rect.max.x, capture_rect.max.x); + assert_eq!(badge_rect.min.y, capture_rect.max.y + SELECTION_SIZE_BADGE_GAP_PX); +} + +#[test] +fn frozen_top_toolbar_reserved_rect_uses_inside_fallback_slot() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 400, + height: 160, + scale_factor_x1000: 1_000, + }; + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let capture_rect_points = RectPoints::new(40, 20, 240, 110); + let capture_rect = Rect::from_min_size( + Pos2::new(capture_rect_points.x as f32, capture_rect_points.y as f32), + Vec2::new(capture_rect_points.width as f32, capture_rect_points.height as f32), + ); + let mut state = OverlayState::new(); + + state.mode = OverlayMode::Frozen; + state.monitor = Some(monitor); + state.frozen_capture_rect = Some(capture_rect_points); + + let toolbar_state = FrozenToolbarState::default(); + let reserved_rect = WindowRenderer::frozen_toolbar_reserved_rect( + &state, + monitor, + screen_rect, + ToolbarPlacement::Top, + &toolbar_state, + ) + .expect("top fallback slot should still be reserved"); + + assert_eq!(reserved_rect.min.y, capture_rect.min.y + TOOLBAR_SCREEN_MARGIN_PX); + assert_eq!(reserved_rect.height(), WindowRenderer::frozen_toolbar_size(&toolbar_state).y); +} + +#[test] +fn overlay_session_computes_frozen_toolbar_reserved_rect_without_inline_toolbar_state() { + let monitor = test_monitor(); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let mut session = OverlaySession::new(); + + session.state.mode = OverlayMode::Frozen; + session.state.monitor = Some(monitor); + session.state.frozen_capture_rect = Some(RectPoints::new(200, 180, 200, 300)); + + let reserved_rect = session + .frozen_size_badge_toolbar_reserved_rect(monitor, screen_rect, true) + .expect("overlay redraw should reserve the default toolbar slot"); + + assert_eq!(reserved_rect.min.y, 480.0 + TOOLBAR_CAPTURE_GAP_PX); + assert_eq!( + reserved_rect.height(), + WindowRenderer::frozen_toolbar_size(&session.toolbar_state).y + ); +} + +#[test] +fn frozen_toolbar_reserved_rect_uses_overlay_viewport_size() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 400, + height: 260, + scale_factor_x1000: 1_000, + }; + let overlay_screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(400.0, 120.0)); + let toolbar_window_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(92.0, 26.0)); + let capture_rect_points = RectPoints::new(60, 40, 220, 60); + let capture_rect = Rect::from_min_size( + Pos2::new(capture_rect_points.x as f32, capture_rect_points.y as f32), + Vec2::new(capture_rect_points.width as f32, capture_rect_points.height as f32), + ); + let mut session = OverlaySession::new(); + + session.state.mode = OverlayMode::Frozen; + session.state.monitor = Some(monitor); + session.state.frozen_capture_rect = Some(capture_rect_points); + session.toolbar_state.layout_last_screen_size_points = Some(toolbar_window_rect.size()); + + let toolbar_size = WindowRenderer::frozen_toolbar_size(&session.toolbar_state); + let overlay_default_pos = WindowRenderer::frozen_toolbar_default_pos( + overlay_screen_rect, + capture_rect.intersect(overlay_screen_rect), + toolbar_size, + session.config.toolbar_placement, + ); + let toolbar_window_default_pos = WindowRenderer::frozen_toolbar_default_pos( + toolbar_window_rect, + capture_rect.intersect(toolbar_window_rect), + toolbar_size, + session.config.toolbar_placement, + ); + + session.toolbar_state.floating_position = Some(overlay_default_pos); + + let reserved_rect = session + .frozen_size_badge_toolbar_reserved_rect(monitor, overlay_screen_rect, true) + .expect("overlay viewport-aligned toolbar slot should still be reserved"); + + assert_ne!(overlay_default_pos, toolbar_window_default_pos); + assert_eq!(reserved_rect.min, overlay_default_pos); + assert_eq!(reserved_rect.size(), toolbar_size); +} + +#[test] +fn frozen_toolbar_reserved_rect_skips_hidden_toolbar_slot() { + let monitor = test_monitor(); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let mut session = OverlaySession::new(); + + session.state.mode = OverlayMode::Frozen; + session.state.monitor = Some(monitor); + session.state.frozen_capture_rect = Some(RectPoints::new(200, 180, 200, 300)); + + assert_eq!(session.frozen_size_badge_toolbar_reserved_rect(monitor, screen_rect, false), None); +} + +#[test] +fn frozen_toolbar_reserved_rect_waits_for_toolbar_birth_readiness() { + let monitor = test_monitor(); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let mut session = OverlaySession::new(); + + session.state.mode = OverlayMode::Frozen; + session.state.monitor = Some(monitor); + session.state.frozen_capture_rect = Some(RectPoints::new(200, 180, 200, 300)); + session.toolbar_state.layout_last_screen_size_points = Some(screen_rect.size()); + session.toolbar_state.layout_stable_frames = 0; + + assert!(!session.frozen_toolbar_ready_for_draw(screen_rect)); + assert_eq!( + session.frozen_size_badge_toolbar_reserved_rect( + monitor, + screen_rect, + session.frozen_toolbar_ready_for_draw(screen_rect) + ), + None + ); + + session.toolbar_state.layout_stable_frames = 1; + + assert!(session.frozen_toolbar_ready_for_draw(screen_rect)); + assert!( + session + .frozen_size_badge_toolbar_reserved_rect( + monitor, + screen_rect, + session.frozen_toolbar_ready_for_draw(screen_rect) + ) + .is_some() + ); +} + +#[test] +fn frozen_toolbar_ready_for_draw_ignores_preseeded_position_until_viewport_stabilizes() { + let monitor = test_monitor(); + let capture_rect = RectPoints::new(200, 180, 200, 300); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let mut session = OverlaySession::new(); + + session.begin_frozen_capture_with_rect(monitor, Some(capture_rect), None, None); + + assert!(session.toolbar_state.floating_position.is_some()); + assert_eq!(session.toolbar_state.layout_last_screen_size_points, None); + assert_eq!(session.toolbar_state.layout_stable_frames, 0); + assert!(!session.frozen_toolbar_ready_for_draw(screen_rect)); + assert_eq!( + session.frozen_size_badge_toolbar_reserved_rect( + monitor, + screen_rect, + session.frozen_toolbar_ready_for_draw(screen_rect) + ), + None + ); +} + +#[test] +fn frozen_toolbar_ready_for_draw_recovers_after_preseeded_position_is_sampled() { + let monitor = test_monitor(); + let capture_rect = RectPoints::new(200, 180, 200, 300); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let mut session = OverlaySession::new(); + + session.begin_frozen_capture_with_rect(monitor, Some(capture_rect), None, None); + + assert!(!session.advance_frozen_toolbar_readiness_sample(screen_rect)); + assert_eq!(session.toolbar_state.layout_last_screen_size_points, Some(screen_rect.size())); + assert_eq!(session.toolbar_state.layout_stable_frames, 0); + assert!(!session.frozen_toolbar_ready_for_draw(screen_rect)); + assert!(!session.advance_frozen_toolbar_readiness_sample(screen_rect)); + assert_eq!(session.toolbar_state.layout_stable_frames, 1); + assert!(session.frozen_toolbar_ready_for_draw(screen_rect)); +} + +#[test] +fn render_frozen_toolbar_ui_waits_for_readiness_before_first_visible_frame() { + let ctx = test_egui_context(); + let monitor = test_monitor(); + let capture_rect = RectPoints::new(200, 180, 200, 300); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let mut session = OverlaySession::new(); + let toolbar_placement = session.config.toolbar_placement; + + session.begin_frozen_capture_with_rect(monitor, Some(capture_rect), None, None); + + assert!(session.toolbar_state.visible); + assert_eq!(session.toolbar_state.layout_last_screen_size_points, None); + assert_eq!(session.toolbar_state.layout_stable_frames, 0); + + for frame in 0..2 { + let state = &session.state; + let toolbar_state = &mut session.toolbar_state; + let mut hud_pill = None; + let _ = ctx.run_ui( + egui::RawInput { screen_rect: Some(screen_rect), ..Default::default() }, + |ui| { + WindowRenderer::render_frozen_toolbar_ui( + ui.ctx(), + state, + monitor, + HudTheme::Dark, + toolbar_placement, + false, + false, + 1.0, + 0.0, + 0.0, + Some(toolbar_state), + None, + &mut hud_pill, + ); + }, + ); + + assert!( + hud_pill.is_none(), + "frame {frame} should not draw the toolbar before readiness stabilizes" + ); + } + + let state = &session.state; + let toolbar_state = &mut session.toolbar_state; + let mut hud_pill = None; + let _ = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen_rect), ..Default::default() }, |ui| { + WindowRenderer::render_frozen_toolbar_ui( + ui.ctx(), + state, + monitor, + HudTheme::Dark, + toolbar_placement, + false, + false, + 1.0, + 0.0, + 0.0, + Some(toolbar_state), + None, + &mut hud_pill, + ); + }); + + assert!(hud_pill.is_some(), "third frame should draw the stabilized toolbar"); +} + +#[test] +fn frozen_toolbar_reserved_rect_restores_near_default_slot() { + let monitor = test_monitor(); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let capture_rect = Rect::from_min_size(Pos2::new(200.0, 180.0), Vec2::new(200.0, 300.0)); + let mut state = OverlayState::new(); + let mut toolbar_state = FrozenToolbarState::default(); + let toolbar_size = WindowRenderer::frozen_toolbar_size(&toolbar_state); + let default_pos = WindowRenderer::frozen_toolbar_default_pos( + screen_rect, + capture_rect, + toolbar_size, + ToolbarPlacement::Bottom, + ); + let restored_pos = default_pos + Vec2::new(0.4, -0.35); + + state.mode = OverlayMode::Frozen; + state.monitor = Some(monitor); + state.frozen_capture_rect = Some(RectPoints::new(200, 180, 200, 300)); + toolbar_state.visible = true; + toolbar_state.floating_position = Some(restored_pos); + + assert_eq!( + WindowRenderer::frozen_toolbar_reserved_rect( + &state, + monitor, + screen_rect, + ToolbarPlacement::Bottom, + &toolbar_state, + ), + Some(Rect::from_min_size(restored_pos, toolbar_size)) + ); +} + +#[test] +fn frozen_toolbar_overlay_viewport_sample_recovers_from_toolbar_window_pollution() { + let monitor = test_monitor(); + let overlay_screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let toolbar_window_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(92.0, 26.0)); + let mut session = OverlaySession::new(); + + session.state.mode = OverlayMode::Frozen; + session.state.monitor = Some(monitor); + session.state.frozen_capture_rect = Some(RectPoints::new(200, 180, 200, 300)); + session.toolbar_state.layout_last_screen_size_points = Some(toolbar_window_rect.size()); + session.toolbar_state.layout_stable_frames = 1; + + assert!(!session.frozen_toolbar_ready_for_draw(overlay_screen_rect)); + assert!(!session.advance_frozen_toolbar_readiness_sample(overlay_screen_rect)); + assert_eq!( + session.toolbar_state.layout_last_screen_size_points, + Some(overlay_screen_rect.size()) + ); + assert_eq!(session.toolbar_state.layout_stable_frames, 0); + assert_eq!( + session.frozen_size_badge_toolbar_reserved_rect( + monitor, + overlay_screen_rect, + session.frozen_toolbar_ready_for_draw(overlay_screen_rect) + ), + None + ); + assert!(!session.advance_frozen_toolbar_readiness_sample(overlay_screen_rect)); + assert_eq!(session.toolbar_state.layout_stable_frames, 1); + assert_eq!( + session.frozen_size_badge_toolbar_reserved_rect( + monitor, + overlay_screen_rect, + session.frozen_toolbar_ready_for_draw(overlay_screen_rect) + ), + Some( + WindowRenderer::frozen_toolbar_reserved_rect( + &session.state, + monitor, + overlay_screen_rect, + session.config.toolbar_placement, + &session.toolbar_state, + ) + .expect("reserved rect after overlay viewport stabilization") + ) + ); +} + +#[test] +fn selection_size_badge_reserved_rect_prefers_upper_band_when_bottom_space_is_reserved() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(320.0, 220.0)); + let capture_rect = Rect::from_min_size(Pos2::new(40.0, 40.0), Vec2::new(200.0, 150.0)); + let reserved_rect = Rect::from_min_size(Pos2::new(80.0, 140.0), Vec2::new(120.0, 40.0)); + let badge_rect = WindowRenderer::selection_size_badge_rect_with_reserved_rect( + screen_rect, + capture_rect, + Vec2::new(92.0, 26.0), + Some(reserved_rect), + ); + + assert_eq!( + badge_rect.min.y, + reserved_rect.min.y - SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX - 26.0 + ); + assert!(!badge_rect.intersects(reserved_rect)); +} + +#[test] +fn selection_size_badge_reserved_rect_keeps_preferred_inside_when_top_space_is_clear() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(320.0, 200.0)); + let capture_rect = Rect::from_min_size(Pos2::new(40.0, 20.0), Vec2::new(200.0, 150.0)); + let reserved_rect = Rect::from_min_size(Pos2::new(80.0, 28.0), Vec2::new(120.0, 40.0)); + let badge_rect = WindowRenderer::selection_size_badge_rect_with_reserved_rect( + screen_rect, + capture_rect, + Vec2::new(92.0, 26.0), + Some(reserved_rect), + ); + + assert_eq!(badge_rect.max.y, capture_rect.max.y - SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX); + assert!(!badge_rect.intersects(reserved_rect)); +} + +#[test] +fn selection_size_badge_reserved_rect_falls_above_capture_when_inside_space_is_exhausted() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(320.0, 220.0)); + let capture_rect = Rect::from_min_size(Pos2::new(40.0, 170.0), Vec2::new(120.0, 50.0)); + let reserved_rect = Rect::from_min_size(Pos2::new(40.0, 178.0), Vec2::new(120.0, 40.0)); + let badge_rect = WindowRenderer::selection_size_badge_rect_with_reserved_rect( + screen_rect, + capture_rect, + Vec2::new(92.0, 26.0), + Some(reserved_rect), + ); + + assert_eq!(badge_rect.max.x, capture_rect.max.x); + assert_eq!(badge_rect.max.y, capture_rect.min.y - SELECTION_SIZE_BADGE_GAP_PX); + assert!(!badge_rect.intersects(reserved_rect)); +} + +#[test] +fn selection_size_badge_reserved_rect_uses_above_slot_at_top_edge_when_visible() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(320.0, 112.0)); + let capture_rect = Rect::from_min_size(Pos2::new(40.0, 34.0), Vec2::new(120.0, 50.0)); + let reserved_rect = Rect::from_min_size(Pos2::new(40.0, 42.0), Vec2::new(120.0, 40.0)); + let badge_rect = WindowRenderer::selection_size_badge_rect_with_reserved_rect( + screen_rect, + capture_rect, + Vec2::new(92.0, 26.0), + Some(reserved_rect), + ); + + assert_eq!(badge_rect.min.y, screen_rect.min.y); + assert_eq!(badge_rect.max.y, capture_rect.min.y - SELECTION_SIZE_BADGE_GAP_PX); + assert!(!badge_rect.intersects(reserved_rect)); +} + +#[test] +fn selection_size_badge_reserved_rect_accepts_overlap_when_no_non_overlapping_slot_exists() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(320.0, 52.0)); + let capture_rect = Rect::from_min_size(Pos2::new(40.0, 20.0), Vec2::new(120.0, 32.0)); + let reserved_rect = Rect::from_min_size(Pos2::new(40.0, 22.0), Vec2::new(120.0, 24.0)); + let badge_rect = WindowRenderer::selection_size_badge_rect_with_reserved_rect( + screen_rect, + capture_rect, + Vec2::new(92.0, 26.0), + Some(reserved_rect), + ); + + assert_eq!(badge_rect.max.x, capture_rect.max.x); + assert_eq!(badge_rect.min.y, capture_rect.min.y); + assert!(badge_rect.intersects(reserved_rect)); +} + +#[test] +fn selection_size_badge_text_uses_monitor_pixel_dimensions() { + let monitor = test_monitor_with_scale(1_000, 800, 2_000); + + assert_eq!( + WindowRenderer::selection_size_badge_text(monitor, RectPoints::new(10, 20, 120, 80)), + "240x160" + ); +} + +#[test] +fn selection_size_badge_layout_keeps_visual_bounds_within_right_edge_rect() { + let ctx = test_egui_context(); + let layout = WindowRenderer::selection_size_badge_layout(&ctx, "240x160", HudTheme::Light, 1.0); + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); + let capture_rect = Rect::from_min_size(Pos2::new(760.0, 160.0), Vec2::new(40.0, 120.0)); + let badge_rect = + WindowRenderer::selection_size_badge_rect(screen_rect, capture_rect, layout.badge_size); + let text_anchor = WindowRenderer::selection_size_badge_text_anchor(badge_rect, layout, 1.0); + let visual_bounds = + WindowRenderer::selection_size_badge_visual_bounds(text_anchor, layout.text_size, 1.0); + + assert_eq!(badge_rect.max.x, capture_rect.max.x); + assert!(visual_bounds.min.x >= badge_rect.min.x); + assert!(visual_bounds.max.x <= badge_rect.max.x); +} + +#[test] +fn selection_size_badge_layout_keeps_visual_bounds_within_bottom_fallback_rect() { + let ctx = test_egui_context(); + let layout = WindowRenderer::selection_size_badge_layout(&ctx, "240x160", HudTheme::Light, 1.0); + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); + let capture_rect = Rect::from_min_size(Pos2::new(120.0, 588.0), Vec2::new(140.0, 12.0)); + let badge_rect = + WindowRenderer::selection_size_badge_rect(screen_rect, capture_rect, layout.badge_size); + let text_anchor = WindowRenderer::selection_size_badge_text_anchor(badge_rect, layout, 1.0); + let visual_bounds = + WindowRenderer::selection_size_badge_visual_bounds(text_anchor, layout.text_size, 1.0); + + assert_eq!(badge_rect.max.y, screen_rect.max.y); + assert!(visual_bounds.min.y >= badge_rect.min.y); + assert!(visual_bounds.max.y <= badge_rect.max.y); +} + +#[test] +fn live_capture_size_badge_target_prefers_drag_then_hover_then_fullscreen() { + let monitor = test_monitor(); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let mut state = OverlayState::new(); + + state.mode = OverlayMode::Live; + state.cursor = Some(GlobalPoint::new(320, 260)); + state.hovered_window_rect = Some(MonitorRectPoints { + monitor_id: monitor.id, + rect: RectPoints::new(120, 140, 300, 220), + }); + + assert_eq!( + WindowRenderer::live_capture_size_badge_target(&state, monitor, screen_rect, true), + Some(SelectionSizeBadgeTarget { + rect: Rect::from_min_size(Pos2::new(120.0, 140.0), Vec2::new(300.0, 220.0)), + size_points: RectPoints::new(120, 140, 300, 220), + }) + ); + + state.drag_rect = Some(MonitorRectPoints { + monitor_id: monitor.id, + rect: RectPoints::new(180, 200, 260, 180), + }); + + assert_eq!( + WindowRenderer::live_capture_size_badge_target(&state, monitor, screen_rect, true), + Some(SelectionSizeBadgeTarget { + rect: Rect::from_min_size(Pos2::new(180.0, 200.0), Vec2::new(260.0, 180.0)), + size_points: RectPoints::new(180, 200, 260, 180), + }) + ); + + state.drag_rect = None; + state.hovered_window_rect = None; + + assert_eq!( + WindowRenderer::live_capture_size_badge_target(&state, monitor, screen_rect, true), + Some(SelectionSizeBadgeTarget { + rect: screen_rect, + size_points: RectPoints::new(0, 0, monitor.width, monitor.height), + }) + ); +} + +#[test] +fn live_capture_size_badge_target_skips_fullscreen_fallback_while_primary_down() { + let monitor = test_monitor(); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let mut state = OverlayState::new(); + + state.mode = OverlayMode::Live; + state.cursor = Some(GlobalPoint::new(320, 260)); + + assert_eq!( + WindowRenderer::live_capture_size_badge_target(&state, monitor, screen_rect, false), + None + ); +} + +#[test] +fn frozen_capture_size_badge_target_uses_frozen_rect() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(1_000.0, 800.0)); + let mut state = OverlayState::new(); + + state.mode = OverlayMode::Frozen; + state.frozen_capture_rect = Some(RectPoints::new(140, 180, 320, 240)); + + assert_eq!( + WindowRenderer::frozen_capture_size_badge_target(&state, screen_rect), + Some(SelectionSizeBadgeTarget { + rect: Rect::from_min_size(Pos2::new(140.0, 180.0), Vec2::new(320.0, 240.0)), + size_points: RectPoints::new(140, 180, 320, 240), + }) + ); +} + +#[test] +fn frozen_capture_size_badge_target_keeps_tiny_frozen_rect() { + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(1_000.0, 800.0)); + let mut state = OverlayState::new(); + + state.mode = OverlayMode::Frozen; + state.frozen_capture_rect = Some(RectPoints::new(140, 180, 2, 1)); + + assert_eq!( + WindowRenderer::frozen_capture_size_badge_target(&state, screen_rect), + Some(SelectionSizeBadgeTarget { + rect: Rect::from_min_size(Pos2::new(140.0, 180.0), Vec2::new(2.0, 1.0)), + size_points: RectPoints::new(140, 180, 2, 1), + }) + ); +} + +#[test] +fn render_frozen_capture_affordance_keeps_tiny_frozen_badge_path() { + let ctx = test_egui_context(); + let monitor = test_monitor(); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let mut state = OverlayState::new(); + let mut selection_flow_geometry_cache = SelectionFlowGeometryCache::default(); + let mut selection_dashed_border_cache = SelectionDashedBorderCache::default(); + + state.mode = OverlayMode::Frozen; + state.monitor = Some(monitor); + state.frozen_capture_rect = Some(RectPoints::new(140, 180, 2, 1)); + + assert!(WindowRenderer::render_frozen_capture_affordance( + &ctx, + &state, + monitor, + screen_rect, + HudTheme::Dark, + None, + false, + true, + 1.0, + &mut selection_flow_geometry_cache, + &mut selection_dashed_border_cache, + )); +} + +#[test] +fn render_live_capture_affordances_keep_hover_scrim_when_flow_disabled() { + let ctx = test_egui_context(); + let layer = + egui::LayerId::new(egui::Order::Foreground, egui::Id::new("live-hover-flow-disabled")); + let painter = ctx.layer_painter(layer); + let monitor = test_monitor(); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let selection_dashed_border_cache = SelectionDashedBorderCache::default(); + let mut state = OverlayState::new(); + let mut selection_flow_geometry_cache = SelectionFlowGeometryCache::default(); + + state.mode = OverlayMode::Live; + state.hovered_window_rect = Some(MonitorRectPoints { + monitor_id: monitor.id, + rect: RectPoints::new(100, 120, 240, 320), + }); + + assert!(WindowRenderer::render_live_capture_affordances( + &ctx, + &painter, + &state, + monitor, + screen_rect, + HudTheme::Light, + false, + 1.0, + &mut selection_flow_geometry_cache, + )); + assert_eq!(selection_dashed_border_cache.key, None); +} + +#[test] +fn live_capture_size_badge_target_keeps_tiny_drag_rect() { + let monitor = test_monitor(); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let mut state = OverlayState::new(); + + state.mode = OverlayMode::Live; + state.drag_rect = + Some(MonitorRectPoints { monitor_id: monitor.id, rect: RectPoints::new(180, 200, 2, 1) }); + + assert_eq!( + WindowRenderer::live_capture_size_badge_target(&state, monitor, screen_rect, false), + Some(SelectionSizeBadgeTarget { + rect: Rect::from_min_size(Pos2::new(180.0, 200.0), Vec2::new(2.0, 1.0)), + size_points: RectPoints::new(180, 200, 2, 1), + }) + ); +} + +#[test] +fn live_loupe_default_position_hangs_below_hud_strip_when_space_exists() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 800, + height: 600, + scale_factor_x1000: 1_000, + }; + let hud_outer = GlobalPoint::new(220, 120); + let pos = OverlaySession::live_loupe_default_position( + monitor, + Some(GlobalPoint::new(100, 100)), + Some(hud_outer), + Some(52), + 232, + 232, + ) + .unwrap(); + + assert_eq!(pos.x, hud_outer.x); + assert_eq!(pos.y, hud_outer.y + 52 + HUD_LOUPE_STRIP_GAP_POINTS); +} + +#[test] +fn live_loupe_default_position_falls_above_hud_strip_when_below_overflows() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 800, + height: 500, + scale_factor_x1000: 1_000, + }; + let hud_outer = GlobalPoint::new(220, 300); + let pos = OverlaySession::live_loupe_default_position( + monitor, + Some(GlobalPoint::new(100, 100)), + Some(hud_outer), + Some(52), + 232, + 232, + ) + .unwrap(); + + assert_eq!(pos.x, hud_outer.x); + assert_eq!(pos.y, hud_outer.y - HUD_LOUPE_STRIP_GAP_POINTS - 232); +} + +#[test] +fn scroll_toolbar_compacts_to_two_buttons() { + let frozen_toolbar_size = WindowRenderer::frozen_toolbar_size(&FrozenToolbarState::default()); + let scroll_toolbar_size = WindowRenderer::frozen_toolbar_size(&FrozenToolbarState { + scroll_capture_active: true, + ..FrozenToolbarState::default() + }); + + assert!(scroll_toolbar_size.x < frozen_toolbar_size.x); + assert_eq!(scroll_toolbar_size.y, frozen_toolbar_size.y); +} + +#[cfg(target_os = "macos")] +#[test] +fn drag_region_toolbar_size_stays_stable_while_final_capture_readiness_changes() { + let monitor = test_monitor(); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + session.state.frozen_capture_rect = Some(RectPoints::new(120, 160, 320, 240)); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + + session.sync_frozen_toolbar_state(); + + let pending_toolbar_size = WindowRenderer::frozen_toolbar_size(&session.toolbar_state); + let pending_tools = WindowRenderer::frozen_toolbar_tools(&session.toolbar_state); + + assert!(!session.toolbar_state.final_capture_ready); + assert!(pending_tools.contains(&FrozenToolbarTool::AutoCenter)); + assert!(pending_tools.contains(&FrozenToolbarTool::Scroll)); + + session.authoritative_frozen_capture_ready = true; + + session.sync_frozen_toolbar_state(); + + let ready_toolbar_size = WindowRenderer::frozen_toolbar_size(&session.toolbar_state); + let ready_tools = WindowRenderer::frozen_toolbar_tools(&session.toolbar_state); + + assert!(session.toolbar_state.final_capture_ready); + assert!(ready_tools.contains(&FrozenToolbarTool::AutoCenter)); + assert!(ready_tools.contains(&FrozenToolbarTool::Scroll)); + assert_eq!(pending_toolbar_size, ready_toolbar_size); +} + +#[cfg(target_os = "macos")] +#[test] +fn drag_region_toolbar_recenters_when_auto_center_appears_after_preview_commit() { + let monitor = test_monitor(); + let capture_rect = RectPoints::new(120, 160, 320, 240); + let mut session = OverlaySession::new(); + + session.begin_frozen_capture_with_rect(monitor, Some(capture_rect), None, None); + + let seeded_pos = session + .toolbar_state + .floating_position + .expect("toolbar should seed before frozen preview is ready"); + let seeded_size = WindowRenderer::frozen_toolbar_size(&session.toolbar_state); + let capture_midpoint_x = capture_rect.x as f32 + capture_rect.width as f32 * 0.5; + + assert!(!session.toolbar_state.auto_center_available); + assert_eq!(seeded_pos.x + seeded_size.x * 0.5, capture_midpoint_x); + + session.commit_frozen_preview(monitor, test_frozen_image(), None); + session.sync_frozen_toolbar_state(); + + let ready_size = WindowRenderer::frozen_toolbar_size(&session.toolbar_state); + + assert!(session.toolbar_state.auto_center_available); + assert!(ready_size.x > seeded_size.x); + assert!(session.maybe_recenter_frozen_toolbar_default_slot(monitor)); + + let recentered_pos = + session.toolbar_state.floating_position.expect("toolbar should keep a default position"); + + assert_eq!(recentered_pos.x + ready_size.x * 0.5, capture_midpoint_x); + assert_eq!(session.toolbar_state.default_slot_position, Some(recentered_pos)); +} + +#[cfg(target_os = "macos")] +#[test] +fn late_toolbar_width_change_preserves_manual_toolbar_move() { + let monitor = test_monitor(); + let capture_rect = RectPoints::new(120, 160, 320, 240); + let mut session = OverlaySession::new(); + + session.begin_frozen_capture_with_rect(monitor, Some(capture_rect), None, None); + + let seeded_default_pos = session + .toolbar_state + .floating_position + .expect("toolbar should seed before frozen preview is ready"); + let moved_pos = seeded_default_pos + Vec2::new(24.0, 0.0); + + session.toolbar_state.floating_position = Some(moved_pos); + + session.commit_frozen_preview(monitor, test_frozen_image(), None); + session.sync_frozen_toolbar_state(); + + assert!(!session.maybe_recenter_frozen_toolbar_default_slot(monitor)); + assert_eq!(session.toolbar_state.floating_position, Some(moved_pos)); + assert_eq!( + session.toolbar_state.default_slot_position, + Some(session.frozen_toolbar_default_position_for_capture_rect(monitor, capture_rect)) + ); +} +#[test] +fn auto_center_toolbar_tool_only_appears_when_available() { + let default_tools = WindowRenderer::frozen_toolbar_tools(&FrozenToolbarState::default()); + let auto_center_tools = WindowRenderer::frozen_toolbar_tools(&FrozenToolbarState { + auto_center_available: true, + ..FrozenToolbarState::default() + }); + + assert!(!default_tools.contains(&FrozenToolbarTool::AutoCenter)); + assert!(auto_center_tools.contains(&FrozenToolbarTool::AutoCenter)); + + #[cfg(target_os = "macos")] + { + assert!(default_tools.contains(&FrozenToolbarTool::Ocr)); + assert!(auto_center_tools.contains(&FrozenToolbarTool::Ocr)); + } +} + +#[test] +fn scroll_preview_prefers_right_side_when_space_exists() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_400, + height: 900, + scale_factor_x1000: 1_000, + }; + let mut session = OverlaySession::new(); + + session.state.frozen_capture_rect = Some(RectPoints::new(120, 160, 400, 320)); + + let preview = session.scroll_preview_local_rect(monitor); + + assert_eq!(preview.min.y, 160.0); + assert_eq!(preview.height(), 320.0); + assert!(preview.min.x >= 120.0 + 400.0); +} + +#[test] +fn scroll_preview_falls_back_to_left_when_right_side_is_tight() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 900, + scale_factor_x1000: 1_000, + }; + let mut session = OverlaySession::new(); + + session.state.frozen_capture_rect = Some(RectPoints::new(760, 180, 200, 260)); + + let preview = session.scroll_preview_local_rect(monitor); + + assert_eq!(preview.min.y, 180.0); + assert_eq!(preview.height(), 260.0); + assert!(preview.max.x <= 760.0); +} + +#[test] +fn scroll_preview_grows_with_render_height_until_monitor_limit() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_400, + height: 900, + scale_factor_x1000: 1_000, + }; + let mut session = OverlaySession::new(); + + session.state.frozen_capture_rect = Some(RectPoints::new(120, 160, 400, 320)); + session.scroll_capture.preview_display_image = Some(RgbaImage::new(320, 960)); + + let preview = session.scroll_preview_local_rect(monitor); + + assert_eq!(preview.min.y, 160.0); + assert_eq!(preview.height(), 724.0); +} + +#[test] +fn current_scroll_preview_render_image_prefers_committed_export_during_scroll_capture() { + let mut session = OverlaySession::new(); + let base = make_scroll_capture_test_image(3, &[[10, 0, 0, 255]; 8]); + let grown = make_scroll_capture_test_image(3, &[[20, 0, 0, 255]; 12]); + let mismatched_preview = RgbaImage::from_pixel(320, 40, Rgba([99, 0, 0, 255])); + let mut scroll_session = ScrollSession::new(base, 320).expect("scroll session"); + let _ = scroll_session.observe_downward_sample(grown).expect("observe"); + let expected_export = scroll_session.export_image().clone(); + + session.scroll_capture.active = true; + session.scroll_capture.session = Some(scroll_session); + session.scroll_capture.preview_display_image = Some(mismatched_preview.clone()); + + assert_eq!(session.current_scroll_preview_render_image().as_ref(), Some(&expected_export)); +} + +#[test] +fn current_scroll_preview_render_image_uses_preview_display_when_scroll_capture_is_inactive() { + let preview = RgbaImage::from_pixel(320, 64, Rgba([42, 0, 0, 255])); + let mut session = OverlaySession::new(); + + session.scroll_capture.preview_display_image = Some(preview.clone()); + + assert_eq!(session.current_scroll_preview_render_image().as_ref(), Some(&preview)); +} + +#[test] +fn scroll_capture_preview_dimensions_follow_render_authority_during_scroll_capture() { + let mut session = OverlaySession::new(); + let base = make_scroll_capture_test_image(3, &[[10, 0, 0, 255]; 8]); + let grown = make_scroll_capture_test_image(3, &[[20, 0, 0, 255]; 12]); + let mismatched_preview = RgbaImage::from_pixel(320, 40, Rgba([99, 0, 0, 255])); + let mut scroll_session = ScrollSession::new(base, 320).expect("scroll session"); + let _ = scroll_session.observe_downward_sample(grown).expect("observe"); + let expected_export = scroll_session.export_image().clone(); + + session.scroll_capture.active = true; + session.scroll_capture.session = Some(scroll_session); + session.scroll_capture.preview_display_image = Some(mismatched_preview.clone()); + + assert_eq!( + session.scroll_capture_preview_dimensions(), + Some([expected_export.width(), expected_export.height()]) + ); +} + +#[test] +fn refresh_scroll_preview_display_image_uses_export_sized_render_buffer_during_active_capture() { + let mut session = OverlaySession::new(); + let base = make_scroll_capture_test_image(3, &[[10, 0, 0, 255]; 8]); + let grown = make_scroll_capture_test_image(3, &[[20, 0, 0, 255]; 12]); + let mut scroll_session = ScrollSession::new(base, 320).expect("scroll session"); + let _ = scroll_session.observe_downward_sample(grown).expect("observe"); + let expected_committed = scroll_session.export_image().clone(); + let expected_render = scroll_session.export_image().clone(); + + session.scroll_capture.active = true; + session.scroll_capture.session = Some(scroll_session); + + session.refresh_scroll_preview_committed_image(); + session.refresh_scroll_preview_display_image(); + + assert_eq!(session.scroll_capture.preview_committed_image.as_ref(), Some(&expected_committed)); + assert_eq!(session.scroll_capture.preview_display_image.as_ref(), Some(&expected_render)); + assert_eq!(session.scroll_capture.last_overlay_preview_provisional_motion_rows_hint, None); + assert_eq!(session.scroll_capture.last_overlay_preview_existing_candidate_height, None); + assert_eq!( + session.scroll_capture.last_overlay_preview_existing_candidate_motion_rows_hint, + None + ); + assert_eq!(session.scroll_capture.last_overlay_preview_ledger_candidate_height, None); + assert_eq!(session.scroll_capture.last_overlay_preview_ledger_candidate_motion_rows_hint, None); + assert_eq!(session.scroll_capture.last_overlay_preview_retained_candidate_height, None); + assert_eq!( + session.scroll_capture.last_overlay_preview_retained_candidate_motion_rows_hint, + None + ); + assert!(!session.scroll_capture.last_overlay_preview_retained_hint_matches_motion_rows); + assert!(!session.scroll_capture.last_overlay_preview_fresh_latest_frame_can_drive); + assert!(!session.scroll_capture.last_overlay_preview_strong_unresolved_registration); + assert!(!session.scroll_capture.last_overlay_preview_latest_frame_present); + assert!(!session.scroll_capture.last_overlay_preview_used_provisional); + assert_eq!( + session.scroll_capture_preview_dimensions(), + Some([expected_render.width(), expected_render.height()]) + ); +} diff --git a/packages/rsnap-overlay/src/overlay/tests/scroll_input_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/scroll_input_runtime.rs new file mode 100644 index 00000000..fa934341 --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/tests/scroll_input_runtime.rs @@ -0,0 +1,473 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +#[cfg(target_os = "macos")] +#[test] +fn wrapped_pixel_delta_normalizes_back_to_signed_values() { + assert_eq!(OverlaySession::normalize_macos_scroll_pixel_component(4_294_967_294.0), -2.0); + assert_eq!(OverlaySession::normalize_macos_scroll_pixel_component(4_294_967_290.0), -6.0); +} + +#[test] +fn positive_vertical_wheel_delta_maps_to_upward_scroll_capture() { + assert_eq!( + OverlaySession::scroll_capture_direction_from_wheel_delta(&MouseScrollDelta::LineDelta( + 0.0, 1.0 + )), + Some(ScrollDirection::Up) + ); +} + +#[test] +fn negative_vertical_wheel_delta_maps_to_downward_scroll_capture() { + assert_eq!( + OverlaySession::scroll_capture_direction_from_wheel_delta(&MouseScrollDelta::LineDelta( + 0.0, -1.0 + )), + Some(ScrollDirection::Down) + ); +} + +#[test] +fn external_scroll_input_inside_capture_rect_uses_upward_observation_for_positive_delta() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); + + session.handle_external_scroll_input_delta_y(150.0, 160.0, 4.0, true, false); + + assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Up)); + assert!(session.scroll_capture.input_direction_at.is_some()); + assert!(session.scroll_capture.input_gesture_active); + assert_eq!(session.scroll_capture.downward_motion_rows_pending, 0.0); +} + +#[test] +fn external_scroll_input_inside_capture_rect_uses_downward_observation_for_negative_delta() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); + + session.handle_external_scroll_input_delta_y(150.0, 160.0, -4.0, true, false); + + assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); + assert!(session.scroll_capture.input_direction_at.is_some()); + assert!(session.scroll_capture.input_gesture_active); +} + +#[test] +fn upward_external_scroll_input_clears_existing_downward_motion_backlog() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); + session.scroll_capture.downward_motion_rows_pending = 128.0; + + session.handle_external_scroll_input_delta_y(150.0, 160.0, 12.0, true, false); + + assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Up)); + assert_eq!(session.scroll_capture.downward_motion_rows_pending, 0.0); +} + +#[test] +#[cfg(target_os = "macos")] +fn external_scroll_input_outside_capture_rect_on_same_monitor_is_still_consumed() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); + + session.handle_external_scroll_input_delta_y(50.0, 50.0, -4.0, true, false); + + assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); + assert!(session.scroll_capture.input_direction_at.is_some()); + assert!(session.scroll_capture.input_gesture_active); + assert_eq!(session.scroll_capture.downward_motion_rows_pending, 4.0); +} + +#[test] +#[cfg(not(target_os = "macos"))] +fn external_scroll_input_outside_capture_rect_is_ignored() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); + + session.handle_external_scroll_input_delta_y(50.0, 50.0, 4.0, true, false); + + assert_eq!(session.scroll_capture.input_direction, None); + assert!(session.scroll_capture.input_direction_at.is_none()); +} + +#[test] +fn external_scroll_input_outside_scroll_monitor_is_ignored() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(1_000, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); + + session.handle_external_scroll_input_delta_y(50.0, 50.0, 4.0, true, false); + + assert_eq!(session.scroll_capture.input_direction, None); + assert!(session.scroll_capture.input_direction_at.is_none()); + assert_eq!(session.scroll_capture.downward_motion_rows_pending, 0.0); +} + +#[test] +fn external_scroll_input_terminal_event_preserves_last_direction_for_freshness() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; + + session.handle_external_scroll_input_delta_y(150.0, 160.0, 0.0, false, true); + + assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); + assert!(session.scroll_capture.input_direction_at.is_some()); + assert!(!session.scroll_capture.input_gesture_active); + assert!(session.scroll_capture_input_allows_growth()); +} + +#[cfg(target_os = "macos")] +#[test] +fn scroll_overlay_mouse_passthrough_window_arms_and_expires() { + let now = Instant::now(); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + + session.arm_scroll_overlay_mouse_passthrough_window(now, "test"); + + assert!(session.scroll_capture.overlay_mouse_passthrough_active); + assert_eq!( + session.scroll_capture.overlay_mouse_passthrough_until, + Some(now + SCROLL_CAPTURE_MOUSE_PASSTHROUGH_IDLE_GRACE) + ); + + session.sync_scroll_overlay_mouse_passthrough_window( + now + SCROLL_CAPTURE_MOUSE_PASSTHROUGH_IDLE_GRACE / 2, + ); + + assert!(session.scroll_capture.overlay_mouse_passthrough_active); + + session.sync_scroll_overlay_mouse_passthrough_window( + now + SCROLL_CAPTURE_MOUSE_PASSTHROUGH_IDLE_GRACE + Duration::from_millis(1), + ); + + assert!(!session.scroll_capture.overlay_mouse_passthrough_active); + assert!(session.scroll_capture.overlay_mouse_passthrough_until.is_none()); +} + +#[cfg(target_os = "macos")] +#[test] +fn scroll_capture_start_enables_persistent_passthrough() { + let mut session = OverlaySession::new(); + + seed_ready_scroll_capture_selection(&mut session); + + let control = session.start_scroll_capture(); + + assert!(matches!(control, OverlayControl::Continue)); + assert!(session.scroll_capture.active); + assert!(session.scroll_capture.overlay_mouse_passthrough_active); + assert!(session.scroll_capture.overlay_mouse_passthrough_persistent); + assert!(session.scroll_capture.overlay_mouse_passthrough_until.is_none()); +} + +#[cfg(target_os = "macos")] +#[test] +fn scroll_capture_pause_and_resume_toggle_persistent_passthrough() { + let mut session = OverlaySession::new(); + + seed_ready_scroll_capture_selection(&mut session); + + let _ = session.start_scroll_capture(); + + session.toggle_scroll_capture_paused(); + + assert!(session.scroll_capture.paused); + assert!(!session.scroll_capture.overlay_mouse_passthrough_active); + assert!(!session.scroll_capture.overlay_mouse_passthrough_persistent); + + session.toggle_scroll_capture_paused(); + + assert!(!session.scroll_capture.paused); + assert!(session.scroll_capture.overlay_mouse_passthrough_active); + assert!(session.scroll_capture.overlay_mouse_passthrough_persistent); + assert!(session.scroll_capture.overlay_mouse_passthrough_until.is_none()); +} + +#[cfg(target_os = "macos")] +#[test] +fn external_scroll_input_extends_passthrough_window_inside_capture_rect() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let earlier = Instant::now() - Duration::from_millis(20); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); + + session.arm_scroll_overlay_mouse_passthrough_window(earlier, "test"); + + let first_deadline = session.scroll_capture.overlay_mouse_passthrough_until; + + session.handle_external_scroll_input_delta_y(150.0, 160.0, 4.0, true, false); + + assert!(session.scroll_capture.overlay_mouse_passthrough_active); + assert!(session.scroll_capture.overlay_mouse_passthrough_until > first_deadline); +} + +#[test] +fn terminal_positive_scroll_event_sets_upward_observation_before_finishing() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); + + session.handle_external_scroll_input_delta_y(150.0, 160.0, 4.0, false, true); + + assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Up)); + assert!(session.scroll_capture.input_direction_at.is_some()); + assert!(!session.scroll_capture.input_gesture_active); + assert!(session.scroll_capture_input_allows_growth()); +} + +#[test] +fn terminal_negative_scroll_event_still_allows_downward_growth() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); + + session.handle_external_scroll_input_delta_y(150.0, 160.0, -4.0, false, true); + + assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); + assert!(session.scroll_capture.input_direction_at.is_some()); + assert!(!session.scroll_capture.input_gesture_active); + assert!(session.scroll_capture_input_allows_growth()); +} + +#[cfg(target_os = "macos")] +#[test] +fn overlay_wheel_fallback_records_direction_with_drain_reader_present() { + let observed_at = Instant::now(); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + + session.set_external_scroll_input_drain_reader(Arc::new(|_, _| Vec::new())); + session.record_scroll_capture_input_direction_from_overlay_wheel_at( + &MouseScrollDelta::LineDelta(0.0, 1.0), + observed_at, + ); + + assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Up)); + assert_eq!(session.scroll_capture.input_direction_at, Some(observed_at)); + assert!(!session.scroll_capture.input_gesture_active); +} + +#[test] +fn missing_scroll_direction_does_not_allow_growth() { + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + + assert!(!session.scroll_capture_input_allows_growth()); +} + +#[test] +fn fresh_upward_direction_still_allows_observation() { + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.input_direction = Some(ScrollDirection::Up); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; + + assert!(session.scroll_capture_input_allows_observation()); + assert!(session.scroll_capture_input_allows_growth()); +} + +#[test] +fn fresh_downward_direction_allows_growth_without_active_gesture() { + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = false; + + assert!(session.scroll_capture_input_allows_growth()); +} + +#[test] +fn upward_direction_still_allows_growth_gate() { + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.input_direction = Some(ScrollDirection::Up); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; + + assert!(session.scroll_capture_input_allows_growth()); +} + +#[test] +fn upward_input_does_not_dirty_later_downward_growth() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + [80, 0, 0, 255], + ]; + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.session = + Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); + + set_scroll_capture_input(&mut session, ScrollDirection::Down); + + assert_eq!( + observe_scroll_capture_frame(&mut session, make_scroll_capture_window(&document, 3, 1, 5),), + Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) + ); + assert_eq!( + observe_scroll_capture_frame(&mut session, make_scroll_capture_window(&document, 3, 2, 5),), + Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) + ); + + let height_after_second_append = scroll_capture_export_height(&session); + + set_scroll_capture_input(&mut session, ScrollDirection::Up); + + assert!(matches!( + observe_scroll_capture_frame(&mut session, make_scroll_capture_window(&document, 3, 0, 5),), + Some( + ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } + | ScrollObserveOutcome::PreviewUpdated + ) + )); + assert_eq!(scroll_capture_export_height(&session), height_after_second_append); + + set_scroll_capture_input(&mut session, ScrollDirection::Down); + + assert_eq!( + observe_scroll_capture_frame(&mut session, make_scroll_capture_window(&document, 3, 2, 5),), + Some(ScrollObserveOutcome::NoChange) + ); + assert_eq!(scroll_capture_export_height(&session), height_after_second_append); + + set_scroll_capture_input(&mut session, ScrollDirection::Up); + + assert!(matches!( + observe_scroll_capture_frame(&mut session, make_scroll_capture_window(&document, 3, 1, 5),), + Some( + ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } + | ScrollObserveOutcome::PreviewUpdated + | ScrollObserveOutcome::NoChange + ) + )); + assert_eq!(scroll_capture_export_height(&session), height_after_second_append); + + set_scroll_capture_input(&mut session, ScrollDirection::Down); + + assert_eq!( + observe_scroll_capture_frame(&mut session, make_scroll_capture_window(&document, 3, 2, 5),), + Some(ScrollObserveOutcome::NoChange) + ); + assert_eq!(scroll_capture_export_height(&session), height_after_second_append); + assert_eq!( + observe_scroll_capture_frame(&mut session, make_scroll_capture_window(&document, 3, 3, 5),), + Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) + ); +} diff --git a/packages/rsnap-overlay/src/overlay/tests/self_capture_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/self_capture_runtime.rs new file mode 100644 index 00000000..8192618a --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/tests/self_capture_runtime.rs @@ -0,0 +1,285 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +#[cfg(target_os = "macos")] +#[test] +fn apply_self_capture_exception_window_ids_to_active_streams_updates_live_stream_filters() { + let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + + session.window_list_snapshot = Some(Arc::new(WindowListSnapshot { + captured_at: Instant::now(), + windows: Arc::new(vec![WindowRect { + window_id: Some(9), + x: 10, + y: 12, + width: 30, + height: 40, + }]), + })); + + session.apply_self_capture_exception_window_ids_to_active_streams(); + + assert_eq!( + session.live_sample_stream.as_ref().unwrap().debug_self_capture_exception_window_ids(), + &[17] + ); + assert_eq!( + session + .scroll_capture + .live_stream + .as_ref() + .unwrap() + .debug_self_capture_exception_window_ids(), + &[17] + ); + assert_ne!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); + assert!(!session.pending_self_capture_exception_window_ids_worker_refresh); + assert!(session.window_list_snapshot.is_none()); + assert!( + session.last_window_list_refresh_request_at.elapsed() + >= session.window_list_refresh_interval + ); + assert_eq!(session.scroll_capture.last_stream_frame_seq, 0); + assert_eq!(session.scroll_capture.live_stream_stale_grace, None); +} + +#[cfg(target_os = "macos")] +#[test] +fn apply_self_capture_exception_window_ids_to_active_streams_keeps_scroll_live_stream_disabled_in_worker_mode() + { + let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + + enable_test_worker_scroll_capture_path(&mut session); + + session.test_push_scroll_capture_live_frame(ScrollCaptureLiveFrame { + frame_seq: 9, + captured_at: Instant::now(), + image: test_frozen_image(), + }); + + session.scroll_capture.last_stream_event_at = Some(Instant::now()); + session.scroll_capture.last_stream_poll_at = Some(Instant::now()); + + session.apply_self_capture_exception_window_ids_to_active_streams(); + + assert_eq!( + session.live_sample_stream.as_ref().unwrap().debug_self_capture_exception_window_ids(), + &[17] + ); + assert!(session.scroll_capture.live_stream.is_none()); + assert!(session.scroll_capture.live_stream_backlog.is_empty()); + assert!(session.scroll_capture.last_stream_event_at.is_none()); + assert!(session.scroll_capture.last_stream_poll_at.is_none()); + assert_ne!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); + assert!(!session.pending_self_capture_exception_window_ids_worker_refresh); +} + +#[cfg(target_os = "macos")] +#[test] +fn apply_self_capture_exception_window_ids_to_active_streams_defers_worker_refresh_while_freeze_is_inflight() + { + let monitor = test_monitor(); + let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + + session.inflight_freeze_capture = Some(monitor); + + session.apply_self_capture_exception_window_ids_to_active_streams(); + + assert_eq!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); + assert!(session.pending_self_capture_exception_window_ids_worker_refresh); + assert_eq!( + session.live_sample_stream.as_ref().unwrap().debug_self_capture_exception_window_ids(), + &[17] + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn apply_self_capture_exception_window_ids_to_active_streams_defers_worker_refresh_while_hit_test_is_inflight() + { + let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + + session.pending_click_hit_test_request_id = Some(7); + + session.apply_self_capture_exception_window_ids_to_active_streams(); + + assert_eq!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); + assert!(session.pending_self_capture_exception_window_ids_worker_refresh); +} + +#[cfg(target_os = "macos")] +#[test] +fn apply_self_capture_exception_window_ids_to_active_streams_defers_worker_refresh_while_window_list_refresh_is_inflight() + { + let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + + session.window_list_refresh_inflight = true; + + session.apply_self_capture_exception_window_ids_to_active_streams(); + + assert_eq!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); + assert!(session.pending_self_capture_exception_window_ids_worker_refresh); +} + +#[cfg(target_os = "macos")] +#[test] +fn apply_self_capture_exception_window_ids_to_active_streams_defers_worker_refresh_while_png_encode_is_inflight() + { + let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + + session.png_encode_inflight = true; + + session.apply_self_capture_exception_window_ids_to_active_streams(); + + assert_eq!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); + assert!(session.pending_self_capture_exception_window_ids_worker_refresh); +} + +#[cfg(target_os = "macos")] +#[test] +fn captured_freeze_response_applies_deferred_worker_refresh() { + let monitor = test_monitor(); + let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + + session.inflight_freeze_capture = Some(monitor); + session.pending_self_capture_exception_window_ids_worker_refresh = true; + + let control = session.maybe_tick_worker_response_limiter(WorkerResponse::CapturedFreeze { + monitor, + image: test_frozen_image(), + window_image: None, + captured_window_id: None, + }); + + assert!(matches!(control, super::OverlayControl::Continue)); + assert_ne!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); + assert!(!session.pending_self_capture_exception_window_ids_worker_refresh); +} + +#[cfg(target_os = "macos")] +#[test] +fn hit_test_response_applies_deferred_worker_refresh() { + let monitor = test_monitor(); + let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + + session.pending_click_hit_test_request_id = Some(11); + session.pending_self_capture_exception_window_ids_worker_refresh = true; + + let control = session.maybe_tick_worker_response_limiter(WorkerResponse::HitTestWindow { + monitor, + point: GlobalPoint::new(24, 36), + request_id: 11, + hit: None, + }); + + assert!(matches!(control, super::OverlayControl::Continue)); + assert_ne!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); + assert!(!session.pending_self_capture_exception_window_ids_worker_refresh); +} + +#[cfg(target_os = "macos")] +#[test] +fn window_list_refresh_response_applies_deferred_worker_refresh() { + let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + + session.window_list_refresh_inflight = true; + session.pending_self_capture_exception_window_ids_worker_refresh = true; + + let control = session.maybe_tick_worker_response_limiter(WorkerResponse::RefreshedWindowList { + snapshot: Arc::new(WindowListSnapshot { + captured_at: Instant::now(), + windows: Arc::new(vec![WindowRect { + window_id: Some(9), + x: 10, + y: 12, + width: 30, + height: 40, + }]), + }), + }); + + assert!(matches!(control, super::OverlayControl::Continue)); + assert_ne!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); + assert!(!session.pending_self_capture_exception_window_ids_worker_refresh); +} + +#[cfg(target_os = "macos")] +#[test] +fn stale_window_list_refresh_response_is_dropped_after_self_capture_filter_change() { + let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + + session.window_list_snapshot = Some(Arc::new(WindowListSnapshot { + captured_at: Instant::now(), + windows: Arc::new(vec![WindowRect { window_id: Some(4), x: 1, y: 2, width: 3, height: 4 }]), + })); + session.window_list_refresh_inflight = true; + + session.apply_self_capture_exception_window_ids_to_active_streams(); + + assert!(session.window_list_snapshot.is_none()); + assert!(session.drop_next_window_list_refresh_snapshot); + assert!(session.pending_self_capture_exception_window_ids_worker_refresh); + + let control = session.maybe_tick_worker_response_limiter(WorkerResponse::RefreshedWindowList { + snapshot: Arc::new(WindowListSnapshot { + captured_at: Instant::now(), + windows: Arc::new(vec![WindowRect { + window_id: Some(9), + x: 10, + y: 12, + width: 30, + height: 40, + }]), + }), + }); + + assert!(matches!(control, super::OverlayControl::Continue)); + assert!(session.window_list_snapshot.is_none()); + assert!(!session.window_list_refresh_inflight); + assert!(!session.drop_next_window_list_refresh_snapshot); + assert_ne!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); +} + +#[cfg(target_os = "macos")] +#[test] +fn png_error_response_applies_deferred_worker_refresh() { + let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + + session.png_encode_inflight = true; + session.pending_self_capture_exception_window_ids_worker_refresh = true; + + let control = session.maybe_tick_worker_response_limiter(WorkerResponse::Error { + source: WorkerErrorSource::EncodePng, + message: String::from("encode failed"), + }); + + assert!(matches!(control, super::OverlayControl::Continue)); + assert_ne!(session.worker.as_ref().unwrap().debug_id(), original_worker_debug_id); + assert!(!session.pending_self_capture_exception_window_ids_worker_refresh); +} + +#[cfg(target_os = "macos")] +#[test] +fn capture_monitor_region_error_clears_scroll_capture_inflight_and_pauses_session() { + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.inflight_request_id = Some(41); + session.scroll_capture.inflight_request_observation = Some(InflightScrollCaptureObservation { + was_observable: true, + external_input_seq: 9, + input_direction: Some(ScrollDirection::Down), + }); + + let control = session.maybe_tick_worker_response_limiter(WorkerResponse::Error { + source: WorkerErrorSource::CaptureMonitorRegion, + message: String::from("capture timed out"), + }); + + assert!(matches!(control, super::OverlayControl::Continue)); + assert_eq!(session.scroll_capture.inflight_request_id, None); + assert_eq!(session.scroll_capture.inflight_request_observation, None); + assert!(session.scroll_capture.paused); + assert_eq!(session.state.error_message.as_deref(), Some("capture timed out")); +} diff --git a/packages/rsnap-overlay/src/overlay/tests/stream_refresh_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/stream_refresh_runtime.rs new file mode 100644 index 00000000..5719fe66 --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/tests/stream_refresh_runtime.rs @@ -0,0 +1,308 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +#[cfg(target_os = "macos")] +#[test] +fn handle_scroll_input_ready_drains_input_and_polls_stream_fallback() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let rect = RectPoints::new(100, 120, 200, 240); + let handled_at = Instant::now(); + let event_at = handled_at - Duration::from_millis(1); + let events = Arc::new([(1, event_at, 150.0, 160.0, -4.0, true, false)]); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(rect); + session.scroll_capture.live_stream = Some(MacLiveFrameStream::new()); + session.set_external_scroll_input_drain_reader(Arc::new({ + let events = Arc::clone(&events); + + move |after_seq, through| { + events + .iter() + .copied() + .filter(|event| event.0 > after_seq && event.1 <= through) + .collect() + } + })); + + assert!(matches!(session.handle_scroll_input_ready(), OverlayControl::Continue)); + assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); + assert!(session.scroll_capture.input_gesture_active); + assert_eq!(session.scroll_capture.last_external_scroll_input_seq, 1); + assert!(matches!( + session.scroll_capture.live_stream.as_ref().unwrap().debug_last_request_kind(), + Some("ordered_rgba_regions_after_seq_nonblocking") + | Some("refresh_monitor_nonblocking_if_stale") + )); +} + +#[cfg(target_os = "macos")] +#[test] +fn drain_external_scroll_input_worker_path_does_not_arm_live_stream_stale_grace() { + let monitor = test_monitor(); + let rect = RectPoints::new(100, 120, 512, 640); + let through = Instant::now(); + let recorded_at = through - Duration::from_millis(1); + let events = Arc::new([(1, recorded_at, 150.0, 160.0, -4.0, false, false)]); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(rect); + session.scroll_capture.live_stream = Some(MacLiveFrameStream::new()); + + enable_test_worker_scroll_capture_path(&mut session); + + session.set_external_scroll_input_drain_reader(Arc::new({ + let events = Arc::clone(&events); + + move |after_seq, through| { + events + .iter() + .copied() + .filter(|event| event.0 > after_seq && event.1 <= through) + .collect() + } + })); + + session.drain_external_scroll_input_events_through(through); + + assert_eq!(session.scroll_capture.last_external_scroll_input_seq, 1); + assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); + assert!(!session.scroll_capture.input_gesture_active); + assert_eq!(session.scroll_capture.live_stream_stale_grace, None); +} + +#[cfg(target_os = "macos")] +#[test] +fn force_stream_refresh_stays_disabled_while_downward_gesture_is_still_active() { + let now = Instant::now(); + let mut session = OverlaySession::new(); + + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(now); + session.scroll_capture.input_gesture_active = true; + session.scroll_capture.downward_motion_rows_pending = 512.0; + + assert!(!session.scroll_capture_should_force_stream_refresh_at(now)); +} + +#[cfg(target_os = "macos")] +#[test] +fn stale_stream_refresh_stays_disabled_while_gesture_is_still_active() { + let now = Instant::now(); + let mut session = OverlaySession::new(); + + session.scroll_capture.input_gesture_active = true; + session.scroll_capture.last_stream_event_at = Some( + now - SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW + Duration::from_millis(1), + ); + + assert!(!session.scroll_capture_should_schedule_stale_stream_refresh_at(now)); +} + +#[cfg(target_os = "macos")] +#[test] +fn stale_stream_refresh_reenables_after_gesture_ends() { + let now = Instant::now(); + let mut session = OverlaySession::new(); + + session.scroll_capture.input_gesture_active = false; + + assert!(session.scroll_capture_should_schedule_stale_stream_refresh_at(now)); +} + +#[cfg(target_os = "macos")] +#[test] +fn stale_stream_refresh_reenables_during_gesture_after_stream_goes_dead() { + let now = Instant::now(); + let mut session = OverlaySession::new(); + + session.scroll_capture.input_gesture_active = true; + session.scroll_capture.last_stream_event_at = Some( + now - SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW - Duration::from_millis(1), + ); + + assert!(session.scroll_capture_should_schedule_stale_stream_refresh_at(now)); +} + +#[cfg(target_os = "macos")] +#[test] +fn post_stall_burst_search_stays_enabled_during_active_gesture_when_downward_backlog_is_fresh() { + let now = Instant::now(); + let mut session = OverlaySession::new(); + + session.scroll_capture.pending_post_stall_burst_after_seq = Some(80); + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(now); + session.scroll_capture.input_gesture_active = true; + session.scroll_capture.downward_motion_rows_pending = 512.0; + + assert!(session.scroll_capture_should_allow_post_stall_burst_search_at(81, now)); +} + +#[cfg(target_os = "macos")] +#[test] +fn force_stream_refresh_stays_enabled_for_fresh_pending_downward_motion_after_gesture_end() { + let now = Instant::now(); + let mut session = OverlaySession::new(); + + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = + Some(now - SCROLL_CAPTURE_INPUT_FRESHNESS + Duration::from_millis(50)); + session.scroll_capture.input_gesture_active = false; + session.scroll_capture.downward_motion_rows_pending = 512.0; + + assert!(session.scroll_capture_should_force_stream_refresh_at(now)); +} + +#[cfg(target_os = "macos")] +#[test] +fn force_stream_refresh_stops_after_downward_input_becomes_stale() { + let now = Instant::now(); + let mut session = OverlaySession::new(); + + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = + Some(now - SCROLL_CAPTURE_INPUT_FRESHNESS - Duration::from_millis(1)); + session.scroll_capture.input_gesture_active = false; + session.scroll_capture.downward_motion_rows_pending = 512.0; + + assert!(!session.scroll_capture_should_force_stream_refresh_at(now)); +} + +#[cfg(target_os = "macos")] +#[test] +fn post_stall_burst_search_stays_enabled_while_fresh_downward_backlog_remains() { + let now = Instant::now(); + let mut session = OverlaySession::new(); + + session.scroll_capture.pending_post_stall_burst_after_seq = Some(80); + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(now); + session.scroll_capture.input_gesture_active = false; + session.scroll_capture.downward_motion_rows_pending = 512.0; + + assert!(session.scroll_capture_should_allow_post_stall_burst_search_at(81, now)); + assert!(session.scroll_capture_should_allow_post_stall_burst_search_at( + 82, + now + Duration::from_millis(50) + )); +} + +#[cfg(target_os = "macos")] +#[test] +fn post_stall_burst_search_arms_for_large_capture_time_gap_even_when_frame_seq_is_contiguous() { + let now = Instant::now(); + let mut session = OverlaySession::new(); + + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(now); + session.scroll_capture.input_gesture_active = true; + session.scroll_capture.downward_motion_rows_pending = 512.0; + session.scroll_capture.last_consumed_stream_frame_captured_at = Some( + now - SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW - Duration::from_millis(1), + ); + + assert!(session.scroll_capture_should_arm_post_stall_burst_for_time_gap_at(now)); +} + +#[cfg(target_os = "macos")] +#[test] +fn consuming_live_frame_backlog_arms_time_gap_burst_after_draining_fresh_input() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + ]; + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let rect = RectPoints::new(100, 120, 200, 240); + let captured_at = Instant::now(); + let event_at = captured_at - Duration::from_millis(1); + let events = Arc::new([(1, event_at, 150.0, 160.0, -74.0, true, false)]); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(rect); + session.scroll_capture.session = + Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); + session.scroll_capture.last_consumed_stream_frame_captured_at = Some( + captured_at + - SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW + - Duration::from_millis(1), + ); + session.set_external_scroll_input_drain_reader(Arc::new({ + let events = Arc::clone(&events); + + move |after_seq, through| { + events + .iter() + .copied() + .filter(|event| event.0 > after_seq && event.1 <= through) + .collect() + } + })); + + session.test_push_scroll_capture_live_frame(ScrollCaptureLiveFrame { + frame_seq: 9, + captured_at, + image: make_scroll_capture_window(&document, 3, 0, 5), + }); + session.test_consume_scroll_capture_backlog(1); + + assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); + assert_eq!(session.scroll_capture.last_external_scroll_input_seq, 1); + assert_eq!(session.scroll_capture.pending_post_stall_burst_after_seq, Some(8)); +} + +#[cfg(target_os = "macos")] +#[test] +fn post_stall_burst_search_does_not_arm_for_small_capture_time_gap() { + let now = Instant::now(); + let mut session = OverlaySession::new(); + + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(now); + session.scroll_capture.input_gesture_active = true; + session.scroll_capture.downward_motion_rows_pending = 512.0; + session.scroll_capture.last_consumed_stream_frame_captured_at = Some( + now - SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW + Duration::from_millis(10), + ); + + assert!(!session.scroll_capture_should_arm_post_stall_burst_for_time_gap_at(now)); +} + +#[cfg(target_os = "macos")] +#[test] +fn post_stall_burst_search_stops_after_downward_backlog_goes_stale() { + let now = Instant::now(); + let mut session = OverlaySession::new(); + + session.scroll_capture.pending_post_stall_burst_after_seq = Some(80); + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = + Some(now - SCROLL_CAPTURE_INPUT_FRESHNESS - Duration::from_millis(1)); + session.scroll_capture.input_gesture_active = false; + session.scroll_capture.downward_motion_rows_pending = 512.0; + + assert!(!session.scroll_capture_should_allow_post_stall_burst_search_at(81, now)); +} diff --git a/packages/rsnap-overlay/src/overlay/tests/worker_observation_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/worker_observation_runtime.rs new file mode 100644 index 00000000..f0f954a7 --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/tests/worker_observation_runtime.rs @@ -0,0 +1,438 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +#[cfg(target_os = "macos")] +#[test] +fn stale_latched_worker_input_fails_closed_without_appending_growth() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + [80, 0, 0, 255], + ]; + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let capture_rect = RectPoints::new(100, 120, 200, 240); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(capture_rect); + session.scroll_capture.session = + Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; + + assert_eq!( + session + .observe_scroll_capture_frame(make_scroll_capture_window(&document, 3, 1, 5)) + .transpose() + .unwrap(), + Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) + ); + assert_eq!( + session + .observe_scroll_capture_frame(make_scroll_capture_window(&document, 3, 2, 5)) + .transpose() + .unwrap(), + Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) + ); + + let height_after_second_append = + session.scroll_capture.session.as_ref().unwrap().export_image().height(); + + session.scroll_capture.input_direction = Some(ScrollDirection::Up); + session.scroll_capture.input_direction_at = + Some(Instant::now() - SCROLL_CAPTURE_INPUT_FRESHNESS - Duration::from_millis(50)); + session.scroll_capture.input_gesture_active = false; + session.scroll_capture.last_external_scroll_input_seq = 7; + session.scroll_capture.inflight_request_id = Some(41); + session.scroll_capture.inflight_request_observation = Some(InflightScrollCaptureObservation { + was_observable: true, + external_input_seq: 7, + input_direction: Some(ScrollDirection::Down), + }); + + session.handle_captured_scroll_region( + monitor, + capture_rect, + 41, + make_scroll_capture_window(&document, 3, 1, 5), + ); + + assert_eq!(session.scroll_capture.inflight_request_id, None); + assert_eq!(session.scroll_capture.inflight_request_observation, None); + + let scroll_session_debug = format!("{:?}", session.scroll_capture.session.as_ref().unwrap()); + + assert!(scroll_session_debug.contains("resume_frontier_top_y: None"), "{scroll_session_debug}"); + assert!(scroll_session_debug.contains("observed_viewport_top_y: 2"), "{scroll_session_debug}"); + assert_eq!( + session.scroll_capture.session.as_ref().unwrap().export_image().height(), + height_after_second_append + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn newer_same_direction_input_keeps_latched_worker_observation_context() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let capture_rect = RectPoints::new(100, 120, 200, 240); + let base = make_sparse_worker_capture_window(512, 640, 0); + let next = make_sparse_worker_capture_window(512, 640, 90); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(capture_rect); + session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = false; + + let height_before_worker_frame = + session.scroll_capture.session.as_ref().unwrap().export_image().height(); + + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; + session.scroll_capture.last_external_scroll_input_seq = 8; + session.scroll_capture.inflight_request_id = Some(41); + session.scroll_capture.inflight_request_observation = Some(InflightScrollCaptureObservation { + was_observable: true, + external_input_seq: 7, + input_direction: Some(ScrollDirection::Down), + }); + + session.handle_captured_scroll_region(monitor, capture_rect, 41, next); + + assert_eq!(session.scroll_capture.inflight_request_id, None); + assert_eq!(session.scroll_capture.inflight_request_observation, None); + assert_eq!( + session.scroll_capture.session.as_ref().unwrap().export_image().height(), + height_before_worker_frame + 90 + ); + assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 90); +} + +#[cfg(target_os = "macos")] +#[test] +fn stale_same_direction_worker_frame_keeps_latched_worker_observation_context() { + let monitor = test_monitor(); + let capture_rect = RectPoints::new(100, 120, 512, 640); + let base = make_sparse_worker_capture_window(512, 640, 0); + let next = make_sparse_worker_capture_window(512, 640, 90); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(capture_rect); + session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = + Some(Instant::now() - SCROLL_CAPTURE_INPUT_FRESHNESS - Duration::from_millis(50)); + session.scroll_capture.input_gesture_active = false; + session.scroll_capture.last_external_scroll_input_seq = 8; + session.scroll_capture.inflight_request_id = Some(41); + session.scroll_capture.inflight_request_observation = Some(InflightScrollCaptureObservation { + was_observable: true, + external_input_seq: 7, + input_direction: Some(ScrollDirection::Down), + }); + + session.handle_captured_scroll_region(monitor, capture_rect, 41, next); + + assert_eq!(session.scroll_capture.inflight_request_id, None); + assert_eq!(session.scroll_capture.inflight_request_observation, None); + assert_eq!(session.scroll_capture.session.as_ref().unwrap().export_image().height(), 730); + assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 90); +} + +#[cfg(target_os = "macos")] +#[test] +fn worker_frame_without_fresh_or_latched_input_fails_closed_without_appending_growth() { + let monitor = test_monitor(); + let capture_rect = RectPoints::new(100, 120, 512, 640); + let base = make_sparse_worker_capture_window(512, 640, 0); + let next = make_sparse_worker_capture_window(512, 640, 90); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(capture_rect); + session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); + session.scroll_capture.inflight_request_id = Some(41); + session.scroll_capture.inflight_request_observation = Some(InflightScrollCaptureObservation { + was_observable: false, + external_input_seq: 7, + input_direction: Some(ScrollDirection::Down), + }); + + let export_height_before = + session.scroll_capture.session.as_ref().unwrap().export_image().height(); + let viewport_top_before = + session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(); + + session.handle_captured_scroll_region(monitor, capture_rect, 41, next); + + assert_eq!(session.scroll_capture.inflight_request_id, None); + assert_eq!(session.scroll_capture.inflight_request_observation, None); + assert_eq!( + session.scroll_capture.session.as_ref().unwrap().export_image().height(), + export_height_before + ); + assert_eq!( + session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), + viewport_top_before + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn newer_opposite_direction_supersedes_latched_worker_observation_context() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + [80, 0, 0, 255], + ]; + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let capture_rect = RectPoints::new(100, 120, 200, 240); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(capture_rect); + session.scroll_capture.session = + Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; + + assert_eq!( + session + .observe_scroll_capture_frame(make_scroll_capture_window(&document, 3, 1, 5)) + .transpose() + .unwrap(), + Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) + ); + assert_eq!( + session + .observe_scroll_capture_frame(make_scroll_capture_window(&document, 3, 2, 5)) + .transpose() + .unwrap(), + Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) + ); + + let height_after_second_append = + session.scroll_capture.session.as_ref().unwrap().export_image().height(); + + session.scroll_capture.input_direction = Some(ScrollDirection::Up); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; + session.scroll_capture.last_external_scroll_input_seq = 8; + session.scroll_capture.inflight_request_id = Some(41); + session.scroll_capture.inflight_request_observation = Some(InflightScrollCaptureObservation { + was_observable: true, + external_input_seq: 7, + input_direction: Some(ScrollDirection::Down), + }); + + session.handle_captured_scroll_region( + monitor, + capture_rect, + 41, + make_scroll_capture_window(&document, 3, 3, 5), + ); + + assert_eq!(session.scroll_capture.inflight_request_id, None); + assert_eq!(session.scroll_capture.inflight_request_observation, None); + assert_eq!( + session.scroll_capture.session.as_ref().unwrap().export_image().height(), + height_after_second_append + ); + assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 2); +} + +#[cfg(target_os = "macos")] +#[test] +fn successive_same_direction_worker_frames_do_not_stall_after_newer_input() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let capture_rect = RectPoints::new(100, 120, 200, 240); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(capture_rect); + session.scroll_capture.session = + Some(ScrollSession::new(make_sparse_worker_capture_window(512, 640, 0), 320).unwrap()); + + for (step, start_row) in [90_u32, 180, 270].into_iter().enumerate() { + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; + session.scroll_capture.last_external_scroll_input_seq = (step as u64) + 2; + session.scroll_capture.inflight_request_id = Some(41 + step as u64); + session.scroll_capture.inflight_request_observation = + Some(InflightScrollCaptureObservation { + was_observable: true, + external_input_seq: (step as u64) + 1, + input_direction: Some(ScrollDirection::Down), + }); + + session.handle_captured_scroll_region( + monitor, + capture_rect, + 41 + step as u64, + make_sparse_worker_capture_window(512, 640, start_row), + ); + + assert_eq!(session.scroll_capture.inflight_request_id, None); + assert_eq!(session.scroll_capture.inflight_request_observation, None); + assert_eq!( + session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), + start_row as i32 + ); + assert_eq!( + session.scroll_capture.session.as_ref().unwrap().export_image().height(), + 640 + start_row + ); + } +} + +#[cfg(target_os = "macos")] +#[test] +fn successive_browser_like_worker_frames_do_not_stall_after_newer_input() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let capture_rect = RectPoints::new(100, 120, 200, 240); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(capture_rect); + session.scroll_capture.session = Some( + ScrollSession::new(make_browser_like_worker_capture_window(512, 640, 0), 320).unwrap(), + ); + + for (step, start_row) in [84_u32, 168, 252].into_iter().enumerate() { + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; + session.scroll_capture.last_external_scroll_input_seq = (step as u64) + 12; + session.scroll_capture.inflight_request_id = Some(81 + step as u64); + session.scroll_capture.inflight_request_observation = + Some(InflightScrollCaptureObservation { + was_observable: true, + external_input_seq: (step as u64) + 11, + input_direction: Some(ScrollDirection::Down), + }); + + session.handle_captured_scroll_region( + monitor, + capture_rect, + 81 + step as u64, + make_browser_like_worker_capture_window(512, 640, start_row), + ); + + assert_eq!(session.scroll_capture.inflight_request_id, None); + assert_eq!(session.scroll_capture.inflight_request_observation, None); + assert_eq!( + session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), + start_row as i32 + ); + assert_eq!( + session.scroll_capture.session.as_ref().unwrap().export_image().height(), + 640 + start_row + ); + } +} + +#[cfg(target_os = "macos")] +#[test] +fn missing_worker_scroll_frame_clears_inflight_without_mutating_session() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + [80, 0, 0, 255], + ]; + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let capture_rect = RectPoints::new(100, 120, 200, 240); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(capture_rect); + session.scroll_capture.session = + Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; + session.scroll_capture.last_external_scroll_input_seq = 11; + session.scroll_capture.inflight_request_id = Some(41); + session.scroll_capture.inflight_request_observation = Some(InflightScrollCaptureObservation { + was_observable: true, + external_input_seq: 11, + input_direction: Some(ScrollDirection::Down), + }); + + let scroll_session_before = format!("{:?}", session.scroll_capture.session.as_ref().unwrap()); + + session.handle_missing_scroll_region(monitor, capture_rect, 41); + + assert_eq!(session.scroll_capture.inflight_request_id, None); + assert_eq!(session.scroll_capture.inflight_request_observation, None); + assert_eq!( + format!("{:?}", session.scroll_capture.session.as_ref().unwrap()), + scroll_session_before + ); +} diff --git a/packages/rsnap-overlay/src/overlay/tests/worker_tick_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/worker_tick_runtime.rs new file mode 100644 index 00000000..5944e379 --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/tests/worker_tick_runtime.rs @@ -0,0 +1,621 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +#[cfg(target_os = "macos")] +#[test] +fn maybe_tick_scroll_capture_stays_on_stream_path_without_worker_fallback() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(RectPoints::new(100, 120, 200, 240)); + session.scroll_capture.live_stream = Some(MacLiveFrameStream::new()); + + session.maybe_tick_scroll_capture(); + + assert!(!session.scroll_capture.paused); + assert!(session.state.error_message.is_none()); + assert_eq!(session.scroll_capture.inflight_request_id, None); + assert!(matches!( + session.scroll_capture.live_stream.as_ref().unwrap().debug_last_request_kind(), + Some("ordered_rgba_regions_after_seq_nonblocking") + | Some("refresh_monitor_nonblocking_if_stale") + )); +} + +#[cfg(target_os = "macos")] +#[test] +fn maybe_tick_scroll_capture_drains_external_input_without_a_new_stream_frame() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let rect = RectPoints::new(100, 120, 200, 240); + let tick_at = Instant::now(); + let event_at = tick_at - Duration::from_millis(1); + let events = Arc::new([(1, event_at, 150.0, 160.0, -4.0, true, false)]); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(rect); + session.scroll_capture.live_stream = Some(MacLiveFrameStream::new()); + session.set_external_scroll_input_drain_reader(Arc::new({ + let events = Arc::clone(&events); + + move |after_seq, through| { + events + .iter() + .copied() + .filter(|event| event.0 > after_seq && event.1 <= through) + .collect() + } + })); + + session.maybe_tick_scroll_capture(); + + assert_eq!(session.scroll_capture.input_direction, Some(ScrollDirection::Down)); + assert!(session.scroll_capture.input_gesture_active); + assert_eq!(session.scroll_capture.last_external_scroll_input_seq, 1); + assert!(session.state.error_message.is_none()); +} + +#[cfg(target_os = "macos")] +#[test] +fn maybe_tick_scroll_capture_does_not_synthesize_preview_growth_from_input_without_semantic_sample() +{ + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + [80, 0, 0, 255], + ]; + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let rect = RectPoints::new(100, 120, 200, 240); + let tick_at = Instant::now(); + let event_at = tick_at - Duration::from_millis(1); + let events = Arc::new([(1, event_at, 150.0, 160.0, -4.0, true, false)]); + let base_frame = make_scroll_capture_window(&document, 3, 0, 5); + let latest_frame = make_scroll_capture_window(&document, 3, 1, 5); + let scroll_session = ScrollSession::new(base_frame.clone(), 320).unwrap(); + let committed_preview = scroll_session.preview_image().clone(); + let mut session = OverlaySession::new(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(rect); + session.scroll_capture.live_stream = Some(MacLiveFrameStream::new()); + session.scroll_capture.session = Some(scroll_session); + session.scroll_capture.preview_committed_image = Some(committed_preview.clone()); + session.scroll_capture.preview_display_image = Some(committed_preview.clone()); + session.scroll_capture.preview_latest_frame = Some(latest_frame); + session.set_external_scroll_input_drain_reader(Arc::new({ + let events = Arc::clone(&events); + + move |after_seq, through| { + events + .iter() + .copied() + .filter(|event| event.0 > after_seq && event.1 <= through) + .collect() + } + })); + + session.maybe_tick_scroll_capture(); + + assert_eq!(session.scroll_capture.preview_display_image.as_ref(), Some(&committed_preview)); + assert_eq!(scroll_capture_export_height(&session), base_frame.height()); +} + +#[cfg(target_os = "macos")] +#[test] +fn maybe_tick_scroll_capture_does_not_double_count_preview_growth_from_same_latest_frame() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + ]; + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let rect = RectPoints::new(100, 120, 200, 240); + let tick_at = Instant::now(); + let event_at = tick_at - Duration::from_millis(1); + let events = Arc::new([(1, event_at, 150.0, 160.0, -4.0, true, false)]); + let base_frame = make_scroll_capture_window(&document, 3, 0, 5); + let moved_frame = make_scroll_capture_window(&document, 3, 1, 5); + let mut session = OverlaySession::new(); + let mut scroll_session = ScrollSession::new(base_frame, 320).unwrap(); + + assert!(matches!( + scroll_session.observe_downward_sample(moved_frame.clone()).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + )); + + let committed_preview = scroll_session.preview_image().clone(); + + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(rect); + session.scroll_capture.live_stream = Some(MacLiveFrameStream::new()); + session.scroll_capture.session = Some(scroll_session); + session.scroll_capture.preview_committed_image = Some(committed_preview.clone()); + session.scroll_capture.preview_display_image = Some(committed_preview.clone()); + session.scroll_capture.preview_latest_frame = Some(moved_frame); + session.set_external_scroll_input_drain_reader(Arc::new({ + let events = Arc::clone(&events); + + move |after_seq, through| { + events + .iter() + .copied() + .filter(|event| event.0 > after_seq && event.1 <= through) + .collect() + } + })); + + session.maybe_tick_scroll_capture(); + + assert_eq!(session.scroll_capture.preview_display_image.as_ref(), Some(&committed_preview)); + assert_eq!(scroll_capture_export_height(&session), committed_preview.height()); +} + +#[cfg(target_os = "macos")] +#[test] +fn maybe_tick_scroll_capture_worker_path_recovers_after_blocked_overshot_frame() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_000, + height: 800, + scale_factor_x1000: 1_000, + }; + let rect = RectPoints::new(100, 120, 512, 640); + let base = make_browser_like_worker_capture_window(512, 640, 0); + let blocked = make_browser_like_worker_capture_window(512, 640, 760); + let followup = make_browser_like_worker_capture_window(512, 640, 844); + let mut session = OverlaySession::new(); + + session.worker = Some(OverlayWorker::new( + Box::new(SequenceScrollCaptureBackend::new([Some(blocked), Some(followup)])), + None, + )); + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(rect); + session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); + + enable_test_worker_scroll_capture_path(&mut session); + set_scroll_capture_input(&mut session, ScrollDirection::Down); + + session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + + session.maybe_tick_scroll_capture(); + + assert!(session.scroll_capture.inflight_request_id.is_some()); + + drain_scroll_capture_worker_until_idle(&mut session); + + assert_eq!(scroll_capture_export_height(&session), 640); + assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 0); + + set_scroll_capture_input(&mut session, ScrollDirection::Down); + + session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + + session.maybe_tick_scroll_capture(); + + assert!(session.scroll_capture.inflight_request_id.is_some()); + + drain_scroll_capture_worker_until_idle(&mut session); + + assert_eq!(scroll_capture_export_height(&session), 724); + assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 84); +} + +#[cfg(target_os = "macos")] +#[test] +fn maybe_tick_scroll_capture_worker_path_retries_immediately_after_blocked_overshot_frame_during_fresh_downward_input() + { + let monitor = test_monitor(); + let rect = RectPoints::new(100, 120, 512, 640); + let base = make_browser_like_worker_capture_window(512, 640, 0); + let blocked = make_browser_like_worker_capture_window(512, 640, 760); + let followup = make_browser_like_worker_capture_window(512, 640, 844); + let mut session = OverlaySession::new(); + + session.worker = Some(OverlayWorker::new( + Box::new(SequenceScrollCaptureBackend::new([Some(blocked), Some(followup)])), + None, + )); + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(rect); + session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); + + enable_test_worker_scroll_capture_path(&mut session); + set_scroll_capture_input(&mut session, ScrollDirection::Down); + + session.scroll_capture.last_external_scroll_input_seq = 1; + session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + + session.maybe_tick_scroll_capture(); + + assert!(session.scroll_capture.inflight_request_id.is_some()); + + drain_scroll_capture_worker_until_idle(&mut session); + + assert_eq!(scroll_capture_export_height(&session), 640); + + session.scroll_capture.last_external_scroll_input_seq = 2; + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; + + session.maybe_tick_scroll_capture(); + + assert!( + session.scroll_capture.inflight_request_id.is_some(), + "fresh downward input after a blocked worker frame should retry immediately" + ); + + drain_scroll_capture_worker_until_idle(&mut session); + + assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 84); + assert_eq!(scroll_capture_export_height(&session), 724); +} + +#[cfg(target_os = "macos")] +#[test] +fn maybe_tick_scroll_capture_worker_path_recovers_across_interleaved_no_frame_and_blocked_browser_steps() + { + let monitor = test_monitor(); + let rect = RectPoints::new(100, 120, 512, 640); + let mut session = OverlaySession::new(); + + session.worker = Some(OverlayWorker::new( + Box::new(SequenceScrollCaptureBackend::new([ + None, + Some(make_browser_like_worker_capture_window(512, 640, 84)), + Some(make_browser_like_worker_capture_window(512, 640, 700)), + Some(make_browser_like_worker_capture_window(512, 640, 784)), + None, + Some(make_browser_like_worker_capture_window(512, 640, 868)), + ])), + None, + )); + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(rect); + session.scroll_capture.session = Some( + ScrollSession::new(make_browser_like_worker_capture_window(512, 640, 0), 320).unwrap(), + ); + + enable_test_worker_scroll_capture_path(&mut session); + + for expected_top_y in [84_i32, 168, 252] { + let mut attempts = 0_u8; + + while session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y() + < expected_top_y + { + attempts = attempts.saturating_add(1); + + assert!( + attempts <= 4, + "worker path failed to recover to expected_top_y={expected_top_y}" + ); + + set_scroll_capture_input(&mut session, ScrollDirection::Down); + + session.scroll_capture.last_external_scroll_input_seq = + session.scroll_capture.last_external_scroll_input_seq.saturating_add(1); + session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + + session.maybe_tick_scroll_capture(); + + assert!(session.scroll_capture.inflight_request_id.is_some()); + + session.scroll_capture.last_external_scroll_input_seq = + session.scroll_capture.last_external_scroll_input_seq.saturating_add(1); + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; + + drain_scroll_capture_worker_until_idle(&mut session); + } + + assert_eq!( + session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), + expected_top_y + ); + assert_eq!(scroll_capture_export_height(&session), 640 + expected_top_y as u32); + } +} + +#[cfg(target_os = "macos")] +#[test] +fn maybe_tick_scroll_capture_worker_path_keeps_same_direction_superseded_response() { + let monitor = test_monitor(); + let rect = RectPoints::new(100, 120, 512, 640); + let base = make_sparse_worker_capture_window(512, 640, 0); + let moved = make_sparse_worker_capture_window(512, 640, 180); + let mut session = OverlaySession::new(); + + session.worker = + Some(OverlayWorker::new(Box::new(SequenceScrollCaptureBackend::new([Some(moved)])), None)); + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(rect); + session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); + + enable_test_worker_scroll_capture_path(&mut session); + set_scroll_capture_input(&mut session, ScrollDirection::Down); + + session.scroll_capture.last_external_scroll_input_seq = 1; + session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + + session.maybe_tick_scroll_capture(); + + assert!(session.scroll_capture.inflight_request_id.is_some()); + + session.scroll_capture.last_external_scroll_input_seq = 2; + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + + drain_scroll_capture_worker_until_idle(&mut session); + + assert_eq!(scroll_capture_export_height(&session), 820); + assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 180); +} + +#[cfg(target_os = "macos")] +#[test] +fn maybe_tick_scroll_capture_worker_path_commits_successive_browser_like_frames_after_newer_same_direction_input() + { + let monitor = test_monitor(); + let rect = RectPoints::new(100, 120, 512, 640); + let mut session = OverlaySession::new(); + + session.worker = Some(OverlayWorker::new( + Box::new(SequenceScrollCaptureBackend::new([ + Some(make_browser_like_worker_capture_window(512, 640, 84)), + Some(make_browser_like_worker_capture_window(512, 640, 168)), + Some(make_browser_like_worker_capture_window(512, 640, 252)), + ])), + None, + )); + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(rect); + session.scroll_capture.session = Some( + ScrollSession::new(make_browser_like_worker_capture_window(512, 640, 0), 320).unwrap(), + ); + + enable_test_worker_scroll_capture_path(&mut session); + + for (step, expected_top_y) in [84_i32, 168, 252].into_iter().enumerate() { + set_scroll_capture_input(&mut session, ScrollDirection::Down); + + session.scroll_capture.last_external_scroll_input_seq = (step as u64) + 1; + session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + + session.maybe_tick_scroll_capture(); + + assert!(session.scroll_capture.inflight_request_id.is_some()); + + session.scroll_capture.last_external_scroll_input_seq = (step as u64) + 2; + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + + drain_scroll_capture_worker_until_idle(&mut session); + + assert_eq!(session.scroll_capture.inflight_request_id, None); + assert_eq!( + session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), + expected_top_y + ); + assert_eq!( + session.scroll_capture.session.as_ref().unwrap().export_image().height(), + 640 + expected_top_y as u32 + ); + } +} + +#[cfg(target_os = "macos")] +#[test] +fn maybe_tick_scroll_capture_worker_path_drops_opposite_direction_superseded_response() { + let monitor = test_monitor(); + let rect = RectPoints::new(100, 120, 512, 640); + let base = make_sparse_worker_capture_window(512, 640, 0); + let moved = make_sparse_worker_capture_window(512, 640, 180); + let mut session = OverlaySession::new(); + + session.worker = + Some(OverlayWorker::new(Box::new(SequenceScrollCaptureBackend::new([Some(moved)])), None)); + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(rect); + session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); + + enable_test_worker_scroll_capture_path(&mut session); + set_scroll_capture_input(&mut session, ScrollDirection::Down); + + session.scroll_capture.last_external_scroll_input_seq = 1; + session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + + session.maybe_tick_scroll_capture(); + + assert!(session.scroll_capture.inflight_request_id.is_some()); + + session.scroll_capture.last_external_scroll_input_seq = 2; + session.scroll_capture.input_direction = Some(ScrollDirection::Up); + + drain_scroll_capture_worker_until_idle(&mut session); + + assert_eq!(scroll_capture_export_height(&session), 640); + assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 0); +} + +#[cfg(target_os = "macos")] +#[test] +fn maybe_tick_scroll_capture_worker_path_retries_immediately_after_no_new_frame_during_fresh_downward_input() + { + let monitor = test_monitor(); + let rect = RectPoints::new(100, 120, 512, 640); + let base = make_browser_like_worker_capture_window(512, 640, 0); + let moved = make_browser_like_worker_capture_window(512, 640, 84); + let mut session = OverlaySession::new(); + + session.worker = Some(OverlayWorker::new( + Box::new(SequenceScrollCaptureBackend::new([None, Some(moved)])), + None, + )); + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(rect); + session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); + + enable_test_worker_scroll_capture_path(&mut session); + set_scroll_capture_input(&mut session, ScrollDirection::Down); + + session.scroll_capture.last_external_scroll_input_seq = 1; + session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + + session.maybe_tick_scroll_capture(); + + assert!(session.scroll_capture.inflight_request_id.is_some()); + + drain_scroll_capture_worker_until_idle(&mut session); + + assert_eq!(session.scroll_capture.inflight_request_id, None); + assert_eq!(scroll_capture_export_height(&session), 640); + + session.scroll_capture.last_external_scroll_input_seq = 2; + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; + + session.maybe_tick_scroll_capture(); + + assert!( + session.scroll_capture.inflight_request_id.is_some(), + "fresh downward input after a worker no-frame response should retry immediately" + ); + + drain_scroll_capture_worker_until_idle(&mut session); + + assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 84); + assert_eq!(scroll_capture_export_height(&session), 724); +} + +#[test] +fn scroll_capture_sample_interval_matches_platform_worker_sampling_strategy() { + #[cfg(target_os = "macos")] + assert_eq!(SCROLL_CAPTURE_SAMPLE_INTERVAL, Duration::from_millis(250)); + #[cfg(not(target_os = "macos"))] + assert_eq!(SCROLL_CAPTURE_SAMPLE_INTERVAL, Duration::from_millis(50)); +} + +#[cfg(target_os = "macos")] +#[test] +fn maybe_tick_scroll_capture_worker_path_backs_off_after_duplicate_committed_frame() { + let monitor = test_monitor(); + let rect = RectPoints::new(100, 120, 512, 640); + let base = make_browser_like_worker_capture_window(512, 640, 0); + let step_one = make_browser_like_worker_capture_window(512, 640, 84); + let step_two = make_browser_like_worker_capture_window(512, 640, 168); + let mut session = OverlaySession::new(); + + session.worker = Some(OverlayWorker::new( + Box::new(SequenceScrollCaptureBackend::new([ + Some(step_one.clone()), + Some(step_one), + Some(step_two), + ])), + None, + )); + session.scroll_capture.active = true; + session.scroll_capture.monitor = Some(monitor); + session.scroll_capture.capture_rect_pixels = Some(rect); + session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); + + enable_test_worker_scroll_capture_path(&mut session); + set_scroll_capture_input(&mut session, ScrollDirection::Down); + + session.scroll_capture.last_external_scroll_input_seq = 1; + session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + + session.maybe_tick_scroll_capture(); + + assert!(session.scroll_capture.inflight_request_id.is_some()); + + drain_scroll_capture_worker_until_idle(&mut session); + + assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 84); + assert_eq!(scroll_capture_export_height(&session), 724); + + session.scroll_capture.last_external_scroll_input_seq = 2; + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; + session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + + session.maybe_tick_scroll_capture(); + + assert!(session.scroll_capture.inflight_request_id.is_some()); + + drain_scroll_capture_worker_until_idle(&mut session); + + assert_eq!(session.scroll_capture.inflight_request_id, None); + assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 84); + assert_eq!(scroll_capture_export_height(&session), 724); + + session.maybe_tick_scroll_capture(); + + assert!( + session.scroll_capture.inflight_request_id.is_none(), + "duplicate committed worker frame should back off instead of immediately re-requesting" + ); + + session.scroll_capture.last_external_scroll_input_seq = 3; + session.scroll_capture.input_direction = Some(ScrollDirection::Down); + session.scroll_capture.input_direction_at = Some(Instant::now()); + session.scroll_capture.input_gesture_active = true; + session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); + + session.maybe_tick_scroll_capture(); + + assert!(session.scroll_capture.inflight_request_id.is_some()); + + drain_scroll_capture_worker_until_idle(&mut session); + + assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 168); + assert_eq!(scroll_capture_export_height(&session), 808); +} diff --git a/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs b/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs new file mode 100644 index 00000000..95274406 --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs @@ -0,0 +1,325 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +impl OverlaySession { + pub(super) fn handle_toolbar_cursor_moved( + &mut self, + window_id: WindowId, + position: PhysicalPosition, + ) -> OverlayControl { + let Some(toolbar_window) = self.toolbar_window.as_ref() else { + return OverlayControl::Continue; + }; + + if toolbar_window.window.id() != window_id + || !matches!(self.state.mode, OverlayMode::Frozen) + || !self.toolbar_state.visible + { + return OverlayControl::Continue; + } + + let scale = toolbar_window.window.scale_factor().max(1.0); + let cursor_local = Pos2::new((position.x / scale) as f32, (position.y / scale) as f32); + + self.toolbar_pointer_local = Some(cursor_local); + + if self.frozen_selection_drag.active { + if let Some(global_cursor) = + self.toolbar_cursor_global_position(toolbar_window, cursor_local) + { + self.update_frozen_selection_drag_rect(global_cursor); + } + + return OverlayControl::Continue; + } + + let monitor = match self.state.monitor.or_else(|| self.active_cursor_monitor()) { + Some(monitor) => monitor, + None => return OverlayControl::Continue, + }; + let global_cursor = self.toolbar_cursor_global_position(toolbar_window, cursor_local); + let drag_monitor = + global_cursor.and_then(|cursor| self.monitor_at(cursor)).unwrap_or(monitor); + let mut mouse_drag = self.toolbar_left_button_down && self.toolbar_state.dragging; + + if self.toolbar_left_button_down && self.toolbar_state.drag_anchor.is_none() { + self.toolbar_state.drag_anchor = Some(cursor_local); + } + if !mouse_drag && let Some(drag_anchor) = self.toolbar_state.drag_anchor { + let dx = cursor_local.x - drag_anchor.x; + let dy = cursor_local.y - drag_anchor.y; + let threshold_sq = TOOLBAR_DRAG_START_THRESHOLD_PX * TOOLBAR_DRAG_START_THRESHOLD_PX; + + if dx * dx + dy * dy >= threshold_sq { + let toolbar_outer_pos = self.toolbar_outer_pos.or_else(|| { + self.toolbar_state.floating_position.map(|floating_position| { + GlobalPoint::new( + monitor.origin.x.saturating_add(floating_position.x.round() as i32), + monitor.origin.y.saturating_add(floating_position.y.round() as i32), + ) + }) + }); + + if let (Some(global_cursor), Some(toolbar_outer_pos)) = + (global_cursor, toolbar_outer_pos) + { + self.toolbar_state.drag_offset = Vec2::new( + global_cursor.x as f32 - toolbar_outer_pos.x as f32, + global_cursor.y as f32 - toolbar_outer_pos.y as f32, + ); + self.toolbar_state.dragging = true; + self.toolbar_state.drag_anchor = None; + mouse_drag = true; + } + } + } + if mouse_drag && global_cursor.is_none() { + mouse_drag = false; + } + if mouse_drag && let Some(global_cursor) = global_cursor { + let desired_global = Pos2::new( + global_cursor.x as f32 - self.toolbar_state.drag_offset.x, + global_cursor.y as f32 - self.toolbar_state.drag_offset.y, + ); + let desired_local = Pos2::new( + desired_global.x - drag_monitor.origin.x as f32, + desired_global.y - drag_monitor.origin.y as f32, + ); + let _ = self.update_toolbar_outer_position(drag_monitor, desired_local); + } + + self.request_redraw_toolbar_window(); + + OverlayControl::Continue + } + + pub(super) fn toolbar_cursor_global_position( + &self, + toolbar_window: &HudOverlayWindow, + cursor_local: Pos2, + ) -> Option { + let toolbar_scale = toolbar_window.window.scale_factor().max(1.0); + let outer_position = toolbar_window.window.outer_position().ok()?; + let global_cursor = Pos2::new( + (outer_position.x as f64 / toolbar_scale) as f32 + cursor_local.x, + (outer_position.y as f64 / toolbar_scale) as f32 + cursor_local.y, + ); + + Some(GlobalPoint::new(global_cursor.x.round() as i32, global_cursor.y.round() as i32)) + } + + pub(super) fn handle_toolbar_window_resized( + &mut self, + size: PhysicalSize, + ) -> OverlayControl { + let Some(toolbar_window) = self.toolbar_window.as_mut() else { + return OverlayControl::Continue; + }; + + match toolbar_window.renderer.resize(size) { + Ok(()) => OverlayControl::Continue, + Err(err) => self.exit(OverlayExit::Error(format!("{err:#}"))), + } + } + + pub(super) fn handle_toolbar_window_scale_factor_changed( + &mut self, + window_id: WindowId, + ) -> OverlayControl { + let Some(toolbar_window) = self + .toolbar_window + .as_mut() + .filter(|toolbar_window| toolbar_window.window.id() == window_id) + else { + return OverlayControl::Continue; + }; + let size = toolbar_window.window.inner_size(); + + match toolbar_window.renderer.resize(size) { + Ok(()) => { + let window = Arc::clone(&toolbar_window.window); + + self.configure_hud_window_common( + window.as_ref(), + Some(f64::from(HUD_PILL_CORNER_RADIUS_POINTS)), + ); + + OverlayControl::Continue + }, + Err(err) => self.exit(OverlayExit::Error(format!("{err:#}"))), + } + } + + pub(super) fn should_hide_toolbar_window(&self, monitor: MonitorRect) -> bool { + !matches!(self.state.mode, OverlayMode::Frozen) + || !self.toolbar_state.visible + || self.state.frozen_image.is_none() + || self.state.monitor != Some(monitor) + } + + pub(super) fn set_toolbar_window_hidden(&mut self) { + if let Some(toolbar_window) = self.toolbar_window.as_ref() { + toolbar_window.window.set_visible(false); + } + + self.toolbar_window_visible = false; + self.toolbar_window_warmup_redraws_remaining = 0; + self.last_present_at = Instant::now(); + } + + pub(super) fn draw_toolbar_window_frame( + &mut self, + monitor: MonitorRect, + toolbar_input: Option, + ) -> Result<()> { + self.sync_frozen_toolbar_state(); + + if self.maybe_recenter_frozen_toolbar_default_slot(monitor) { + self.request_redraw_for_monitor(monitor); + } + + #[cfg(not(target_os = "macos"))] + { + let _ = (&monitor, &toolbar_input); + let Some(toolbar_window) = self.toolbar_window.as_ref() else { + return Ok(()); + }; + + toolbar_window.window.set_visible(false); + + self.last_present_at = Instant::now(); + + Ok(()) + } + #[cfg(target_os = "macos")] + { + let should_focus_frozen_keyboard = !self.toolbar_window_visible + && matches!(self.state.mode, OverlayMode::Frozen) + && !self.scroll_capture.active; + let Some(gpu) = self.gpu.as_ref() else { + return Ok(()); + }; + let Some(toolbar_window) = self.toolbar_window.as_ref() else { + return Ok(()); + }; + + toolbar_window.window.set_visible(true); + + if !self.toolbar_window_visible { + self.toolbar_window_visible = true; + self.toolbar_window_warmup_redraws_remaining = TOOLBAR_WINDOW_WARMUP_REDRAWS; + } + if should_focus_frozen_keyboard { + self.focus_frozen_keyboard_window(); + } + + let previous_floating_position = self.toolbar_state.floating_position; + + self.toolbar_state.floating_position = Some(Pos2::ZERO); + + let Some(toolbar_window) = self.toolbar_window.as_mut() else { + return Ok(()); + }; + let draw_result = toolbar_window.renderer.draw( + gpu, + &self.state, + monitor, + false, + Some(Pos2::ZERO), + false, + HudAnchor::Cursor, + self.config.toolbar_placement, + self.config.show_alt_hint_keycap, + false, + self.config.hud_opaque, + self.config.hud_opacity, + self.config.hud_fog_amount, + self.config.hud_milk_amount, + self.config.hud_tint_hue, + self.config.theme_mode, + self.config.selection_flow_enabled, + self.config.selection_flow_stroke_width_px, + false, + false, + self.frozen_capture_source == FrozenCaptureSource::FullscreenFallback, + None, + Some(&mut self.toolbar_state), + toolbar_input, + ); + + self.toolbar_state.floating_position = previous_floating_position; + + draw_result?; + + let desired_inner_size = toolbar_window.renderer.hud_pill.map(|hud_pill| { + ( + hud_pill.rect.width().ceil().max(1.0) as u32, + hud_pill.rect.height().ceil().max(1.0) as u32, + ) + }); + let toolbar_window = Arc::clone(&toolbar_window.window); + + if let Some(desired) = desired_inner_size + && self.toolbar_inner_size_points != Some(desired) + { + self.toolbar_inner_size_points = Some(desired); + + let _ = toolbar_window.request_inner_size(LogicalSize::new( + f64::from(desired.0), + f64::from(desired.1), + )); + } + + Ok(()) + } + } + + pub(super) fn handle_toolbar_window_redraw_requested(&mut self) -> OverlayControl { + self.event_loop_last_progress_window_id = + self.toolbar_window.as_ref().map(|toolbar_window| toolbar_window.window.id()); + self.event_loop_last_progress_monitor_id = self.state.monitor.map(|monitor| monitor.id); + + self.maybe_log_event_loop_stall(Instant::now()); + self.mark_progress(OverlayEventLoopPhase::ToolbarRedraw); + + let Some(monitor) = self.state.monitor else { + return OverlayControl::Continue; + }; + let toolbar_input = self.toolbar_pointer_state(monitor, self.toolbar_pointer_local); + let should_hide_toolbar_window = self.should_hide_toolbar_window(monitor); + + if should_hide_toolbar_window { + self.set_toolbar_window_hidden(); + + return OverlayControl::Continue; + } + + if let Err(err) = self.draw_toolbar_window_frame(monitor, toolbar_input) { + return self.exit(OverlayExit::Error(format!("{err:#}"))); + } + + self.update_scroll_toolbar_default_position(monitor); + + if let Some(toolbar_pos) = self.toolbar_state.floating_position { + let _ = self.update_toolbar_outer_position(monitor, toolbar_pos); + } + if let Some(action) = self.toolbar_state.pending_action.take() { + let control = self.handle_toolbar_action(action); + + if !matches!(control, OverlayControl::Continue) { + return control; + } + } + + self.last_present_at = Instant::now(); + + if self.toolbar_state.needs_redraw { + self.toolbar_state.needs_redraw = false; + + self.request_redraw_toolbar_window(); + } + + OverlayControl::Continue + } +} diff --git a/packages/rsnap-overlay/src/overlay/window_position_runtime.rs b/packages/rsnap-overlay/src/overlay/window_position_runtime.rs new file mode 100644 index 00000000..0a104985 --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/window_position_runtime.rs @@ -0,0 +1,252 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +impl OverlaySession { + pub(super) fn update_hud_window_position(&mut self, monitor: MonitorRect, cursor: GlobalPoint) { + if self.live_loupe_uses_hud_window() + && matches!(self.state.mode, OverlayMode::Live) + && self.state.alt_held + { + let _ = self.update_loupe_window_position(monitor); + + return; + } + + let Some(hud_window) = self.hud_window.as_ref() else { + return; + }; + let scale = hud_window.window.scale_factor().max(1.0); + let size = hud_window.window.inner_size(); + let hud_w_points = ((size.width as f64) / scale).ceil().max(1.0) as i32; + let hud_h_points = ((size.height as f64) / scale).ceil().max(1.0) as i32; + let monitor_right = monitor.origin.x.saturating_add_unsigned(monitor.width); + let monitor_bottom = monitor.origin.y.saturating_add_unsigned(monitor.height); + let offset_x = 48; + let offset_y = 24; + let mut x = cursor.x.saturating_add(offset_x); + let mut y = cursor.y.saturating_add(offset_y); + + if x.saturating_add(hud_w_points) > monitor_right { + x = cursor.x.saturating_sub(offset_x.saturating_add(hud_w_points)); + } + if y.saturating_add(hud_h_points) > monitor_bottom { + y = cursor.y.saturating_sub(offset_y.saturating_add(hud_h_points)); + } + + x = x.clamp( + monitor.origin.x, + monitor_right.saturating_sub(hud_w_points).max(monitor.origin.x), + ); + y = y.clamp( + monitor.origin.y, + monitor_bottom.saturating_sub(hud_h_points).max(monitor.origin.y), + ); + + let desired = GlobalPoint::new(x, y); + + if self.hud_outer_pos == Some(desired) { + if self.state.alt_held { + let _ = self.update_loupe_window_position(monitor); + } + + return; + } + + self.hud_outer_pos = Some(desired); + self.pending_hud_outer_pos = Some(desired); + + if self.state.alt_held { + let _ = self.update_loupe_window_position(monitor); + } + } + + pub(super) fn update_loupe_window_position(&mut self, monitor: MonitorRect) -> bool { + if !self.state.alt_held { + self.pending_loupe_outer_pos = None; + + return false; + } + + let Some(loupe_window) = self.loupe_window.as_ref() else { + return false; + }; + let loupe_scale = loupe_window.window.scale_factor().max(1.0); + let loupe_size = loupe_window.window.inner_size(); + let loupe_w_points = ((loupe_size.width as f64) / loupe_scale).ceil().max(1.0) as i32; + let loupe_h_points = ((loupe_size.height as f64) / loupe_scale).ceil().max(1.0) as i32; + let monitor_right = monitor.origin.x.saturating_add_unsigned(monitor.width); + let monitor_bottom = monitor.origin.y.saturating_add_unsigned(monitor.height); + let max_x = monitor_right.saturating_sub(loupe_w_points).max(monitor.origin.x); + let max_y = monitor_bottom.saturating_sub(loupe_h_points).max(monitor.origin.y); + let gap = HUD_LOUPE_STRIP_GAP_POINTS; + let (mut x, mut y) = if matches!(self.state.mode, OverlayMode::Live) { + let hud_height_points = self.hud_window.as_ref().map(|hud_window| { + let hud_scale = hud_window.window.scale_factor().max(1.0); + let hud_size = hud_window.window.inner_size(); + + ((hud_size.height as f64) / hud_scale).ceil().max(1.0) as i32 + }); + let Some(desired) = Self::live_loupe_default_position( + monitor, + self.state.cursor, + self.hud_outer_pos, + hud_height_points, + loupe_w_points, + loupe_h_points, + ) else { + return false; + }; + + (desired.x, desired.y) + } else { + let Some(hud_window) = self.hud_window.as_ref() else { + return false; + }; + let Some(hud_outer) = self.hud_outer_pos else { + return false; + }; + let hud_scale = hud_window.window.scale_factor().max(1.0); + let hud_size = hud_window.window.inner_size(); + let hud_h_points = ((hud_size.height as f64) / hud_scale).ceil().max(1.0) as i32; + let below_y = hud_outer.y.saturating_add(hud_h_points + gap); + let above_y = hud_outer.y.saturating_sub(gap.saturating_add(loupe_h_points)); + + ( + hud_outer.x, + if below_y.saturating_add(loupe_h_points) <= monitor_bottom { + below_y + } else { + above_y + }, + ) + }; + + x = x.clamp(monitor.origin.x, max_x); + y = y.clamp(monitor.origin.y, max_y); + + let desired = GlobalPoint::new(x, y); + + if self.loupe_outer_pos == Some(desired) { + self.pending_loupe_outer_pos = Some(desired); + + return true; + } + + self.loupe_outer_pos = Some(desired); + self.pending_loupe_outer_pos = Some(desired); + + true + } + + pub(super) fn live_loupe_default_position( + monitor: MonitorRect, + cursor: Option, + hud_outer: Option, + hud_height_points: Option, + loupe_w_points: i32, + loupe_h_points: i32, + ) -> Option { + let monitor_right = monitor.origin.x.saturating_add_unsigned(monitor.width); + let monitor_bottom = monitor.origin.y.saturating_add_unsigned(monitor.height); + let max_x = monitor_right.saturating_sub(loupe_w_points).max(monitor.origin.x); + let max_y = monitor_bottom.saturating_sub(loupe_h_points).max(monitor.origin.y); + let gap = HUD_LOUPE_STRIP_GAP_POINTS; + let (mut x, mut y) = + if let (Some(hud_outer), Some(hud_height_points)) = (hud_outer, hud_height_points) { + let below_y = hud_outer.y.saturating_add(hud_height_points + gap); + let above_y = hud_outer.y.saturating_sub(gap.saturating_add(loupe_h_points)); + + ( + hud_outer.x, + if below_y.saturating_add(loupe_h_points) <= monitor_bottom { + below_y + } else { + above_y + }, + ) + } else { + let cursor = cursor?; + let offset_x = 48; + let offset_y = 32; + let mut x = cursor.x.saturating_add(offset_x); + let mut y = cursor.y.saturating_add(offset_y); + + if x.saturating_add(loupe_w_points) > monitor_right { + x = cursor.x.saturating_sub(offset_x.saturating_add(loupe_w_points)); + } + if y.saturating_add(loupe_h_points) > monitor_bottom { + y = cursor.y.saturating_sub(offset_y.saturating_add(loupe_h_points)); + } + + (x, y) + }; + + x = x.clamp(monitor.origin.x, max_x); + y = y.clamp(monitor.origin.y, max_y); + + Some(GlobalPoint::new(x, y)) + } + + pub(super) fn update_toolbar_outer_position( + &mut self, + monitor: MonitorRect, + local_pos: Pos2, + ) -> bool { + let Some(toolbar_window) = self.toolbar_window.as_ref() else { + return false; + }; + let toolbar_scale = toolbar_window.window.scale_factor().max(1.0); + let toolbar_size = if let Some((width, height)) = self.toolbar_inner_size_points { + Vec2::new(width as f32, height as f32) + } else { + let size = toolbar_window.window.inner_size(); + let toolbar_w = ((size.width as f64) / toolbar_scale).ceil().max(1.0) as f32; + let toolbar_h = ((size.height as f64) / toolbar_scale).ceil().max(1.0) as f32; + + Vec2::new(toolbar_w, toolbar_h) + }; + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let clamped_local_pos = WindowRenderer::clamp_toolbar_position( + screen_rect, + toolbar_size, + local_pos, + TOOLBAR_SCREEN_MARGIN_PX, + TOOLBAR_SCREEN_MARGIN_PX, + ); + let desired = GlobalPoint::new( + monitor.origin.x.saturating_add(clamped_local_pos.x.round() as i32), + monitor.origin.y.saturating_add(clamped_local_pos.y.round() as i32), + ); + + if self.toolbar_outer_pos == Some(desired) { + return false; + } + + self.toolbar_outer_pos = Some(desired); + self.toolbar_state.floating_position = Some(clamped_local_pos); + + let started_at = Instant::now(); + + toolbar_window + .window + .set_outer_position(LogicalPosition::new(desired.x as f64, desired.y as f64)); + self.slow_op_logger.warn_if_slow( + "overlay.toolbar_window_set_outer_position", + started_at.elapsed(), + SLOW_OP_WARN_OUTER_POSITION, + || { + format!( + "window_id={:?} pos=({}, {})", + toolbar_window.window.id(), + desired.x, + desired.y + ) + }, + ); + toolbar_window.window.request_redraw(); + + true + } +} diff --git a/packages/rsnap-overlay/src/overlay/worker_runtime.rs b/packages/rsnap-overlay/src/overlay/worker_runtime.rs new file mode 100644 index 00000000..d0443a92 --- /dev/null +++ b/packages/rsnap-overlay/src/overlay/worker_runtime.rs @@ -0,0 +1,771 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +impl OverlaySession { + pub(super) fn drain_worker_responses(&mut self) -> OverlayControl { + #[cfg(target_os = "macos")] + if self.worker.is_none() && self.live_sample_worker.is_none() { + return OverlayControl::Continue; + } + #[cfg(not(target_os = "macos"))] + if self.worker.is_none() { + return OverlayControl::Continue; + } + + #[cfg(target_os = "macos")] + while let Some(resp) = self.live_sample_worker.as_ref().and_then(|worker| worker.try_recv()) + { + let control = self.maybe_tick_worker_response_limiter(resp); + + if !matches!(control, OverlayControl::Continue) { + return control; + } + } + + #[cfg(not(target_os = "macos"))] + let queued_recognize_text = false; + #[cfg(target_os = "macos")] + let queued_recognize_text = self.pending_recognize_text.is_some(); + + #[cfg(target_os = "macos")] + if !self.ocr_inflight + && let Some(request) = self.pending_recognize_text.take() + { + if let Some(worker) = self.worker.as_ref() { + if let Err((request_id, image)) = + worker.request_recognize_text(request.request_id, request.image) + { + self.pending_recognize_text = + Some(PendingRecognizeTextRequest { request_id, image }); + } else { + self.ocr_inflight = true; + } + } else { + self.pending_recognize_text = Some(request); + } + } + if !queued_recognize_text && let Some(image) = self.pending_encode_png.take() { + if let Some(worker) = self.worker.as_ref() { + if let Err(image) = worker.request_encode_png(image) { + self.pending_encode_png = Some(image); + } else { + #[cfg(target_os = "macos")] + { + self.png_encode_inflight = true; + } + } + } else { + self.pending_encode_png = Some(image); + } + } + + while let Some(resp) = + self.worker.as_ref().and_then(|worker| worker.try_recv_captured_monitor_region()) + { + match resp.result { + CapturedMonitorRegionResult::Image(image) => { + self.handle_captured_scroll_region( + resp.monitor, + resp.rect_px, + resp.request_id, + image, + ); + }, + CapturedMonitorRegionResult::NoNewFrame => { + self.handle_missing_scroll_region(resp.monitor, resp.rect_px, resp.request_id); + }, + } + } + while let Some(resp) = self.worker.as_ref().and_then(|worker| worker.try_recv()) { + let control = self.maybe_tick_worker_response_limiter(resp); + + if !matches!(control, OverlayControl::Continue) { + return control; + } + } + + OverlayControl::Continue + } + + pub(super) fn request_live_samples_for_cursor( + &mut self, + monitor: MonitorRect, + cursor: GlobalPoint, + ) -> bool { + if self.pending_click_hit_test_request_id.is_some() { + return false; + } + + let is_dragging_window = matches!(self.state.mode, OverlayMode::Live) + && self.left_mouse_button_down + && self.left_mouse_button_down_monitor == Some(monitor); + let had_snapshot_update = if is_dragging_window || self.state.alt_held { + false + } else { + self.apply_live_hover_cache_state(monitor, cursor) + }; + let sample_updated = self.request_live_cursor_sample(monitor, cursor, self.state.alt_held); + + if !is_dragging_window && !self.state.alt_held { + let _ = self.request_live_window_list_refresh_if_needed(); + } + + let apply = self.live_sample_request_redraw_intent( + had_snapshot_update, + sample_updated, + self.state.alt_held || self.loupe_window_visible, + ); + + if apply.any_changed() { + self.request_redraw_live_sample_targets(monitor, apply); + } + + sample_updated + } + + pub(super) fn request_live_window_list_refresh_if_needed(&mut self) -> bool { + #[cfg(target_os = "macos")] + if self.window_list_refresh_inflight { + return false; + } + + let now = Instant::now(); + let needs_refresh = self.window_list_snapshot.as_ref().is_none_or(|snapshot| { + now.duration_since(snapshot.captured_at) > self.window_list_refresh_interval + || self.state.alt_held + }); + let throttled = now.duration_since(self.last_window_list_refresh_request_at) + < self.window_list_refresh_interval; + + if !needs_refresh || throttled { + return false; + } + + let Some(worker) = self.worker.as_ref() else { + return false; + }; + + if !worker.request_refresh_window_list() { + return false; + } + + self.last_window_list_refresh_request_at = now; + #[cfg(target_os = "macos")] + { + self.window_list_refresh_inflight = true; + } + + true + } + + fn log_live_sample_apply_timing( + &self, + path: &'static str, + monitor: MonitorRect, + point: GlobalPoint, + request_id: u64, + elapsed: Duration, + apply: LiveSampleApplyResult, + ) { + tracing::trace!( + op = "overlay.live_sample_apply_phase", + path, + request_id, + monitor_id = monitor.id, + point = ?point, + latency_us = elapsed.as_micros(), + alt_held = self.state.alt_held, + overlay_changed = apply.overlay_changed, + hud_changed = apply.hud_changed, + loupe_changed = apply.loupe_changed, + "Live sample apply phase timing." + ); + + if elapsed >= Duration::from_millis(12) { + tracing::debug!( + op = "overlay.live_sample_apply_latency", + path, + request_id, + monitor_id = monitor.id, + point = ?point, + latency_ms = elapsed.as_millis(), + alt_held = self.state.alt_held, + overlay_changed = apply.overlay_changed, + hud_changed = apply.hud_changed, + loupe_changed = apply.loupe_changed, + "Live cursor sample apply latency exceeded the target frame budget." + ); + } + } + + pub(super) fn request_live_cursor_sample( + &mut self, + monitor: MonitorRect, + cursor: GlobalPoint, + want_patch: bool, + ) -> bool { + if !monitor.contains(cursor) { + return false; + } + + #[cfg(target_os = "macos")] + { + let Some(stream) = self.live_sample_stream.as_ref() else { + return false; + }; + let request_id = self.live_cursor_sample_request_id.wrapping_add(1); + let patch_width_px = if want_patch { self.loupe_patch_width_px } else { 0 }; + let patch_height_px = if want_patch { self.loupe_patch_height_px } else { 0 }; + let Some((x_px, y_px)) = monitor.local_u32_pixels(cursor) else { + return false; + }; + let sample = stream.latest_cursor_sample( + monitor, + CursorSampleRequest::with_optional_patch( + x_px, + y_px, + want_patch, + patch_width_px, + patch_height_px, + ), + ); + + self.note_live_cursor_sample_request_started(request_id); + + let Some(sample) = sample else { + self.finish_sync_live_cursor_sample_attempt(request_id); + + return false; + }; + + self.finish_sync_live_cursor_sample_attempt(request_id); + + let apply = self.apply_live_cursor_sample_detail(monitor, cursor, sample); + let sample_latency = self + .latest_live_cursor_sample_requested_at + .take() + .map_or(Duration::ZERO, |requested_at| requested_at.elapsed()); + + self.log_live_sample_apply_timing( + "macos_stream", + monitor, + cursor, + request_id, + sample_latency, + apply, + ); + + if apply.any_changed() { + self.request_redraw_live_sample_targets(monitor, apply); + } + + true + } + #[cfg(not(target_os = "macos"))] + { + if self.live_sample_request_pending() { + return false; + } + + let Some(worker) = self.worker.as_ref() else { + return false; + }; + let request_id = self.live_cursor_sample_request_id.wrapping_add(1); + let patch_width_px = if want_patch { self.loupe_patch_width_px } else { 0 }; + let patch_height_px = if want_patch { self.loupe_patch_height_px } else { 0 }; + + match worker.request_sample_live_cursor( + monitor, + cursor, + request_id, + want_patch, + patch_width_px, + patch_height_px, + ) { + Ok(()) => { + self.note_live_cursor_sample_request_started(request_id); + + true + }, + Err(WorkerRequestSendError::Full) => { + tracing::debug!( + request_id, + monitor_id = monitor.id, + point = ?cursor, + "Live cursor sample request dropped: worker queue full." + ); + + false + }, + Err(WorkerRequestSendError::Disconnected) => { + tracing::debug!( + request_id, + monitor_id = monitor.id, + point = ?cursor, + "Live cursor sample request dropped: worker queue disconnected." + ); + + false + }, + } + } + } + + pub(super) fn apply_live_cursor_sample_detail( + &mut self, + monitor: MonitorRect, + point: GlobalPoint, + sample: LiveCursorSample, + ) -> LiveSampleApplyResult { + if !matches!(self.state.mode, OverlayMode::Live) { + return LiveSampleApplyResult::default(); + } + if self.active_cursor_monitor() != Some(monitor) { + return LiveSampleApplyResult::default(); + } + + let is_dragging_window = self.left_mouse_button_down + && self.left_mouse_button_down_monitor == Some(monitor) + && matches!(self.state.mode, OverlayMode::Live); + let mut changed = LiveSampleApplyResult::default(); + + if is_dragging_window { + if self.state.hovered_window_rect.is_some() { + self.state.hovered_window_rect = None; + changed.overlay_changed = true; + changed.hud_changed = true; + } + } else if self.apply_live_hover_cache_state(monitor, point) { + changed.overlay_changed = true; + changed.hud_changed = true; + } + if self.state.rgb != sample.rgb && sample.rgb.is_some() { + self.state.rgb = sample.rgb; + changed.hud_changed = true; + } + if self.state.alt_held { + let loupe = + sample.patch.map(|patch| crate::state::LoupeSample { center: point, patch }); + let loupe_changed = match (&self.state.loupe, &loupe) { + (Some(current), Some(next)) => { + current.center != next.center || current.patch != next.patch + }, + (None, None) => false, + _ => true, + }; + + if loupe_changed { + self.state.loupe = loupe; + changed.loupe_changed = true; + } + } else if self.state.loupe.is_some() { + self.state.loupe = None; + changed.loupe_changed = true; + } + + changed + } + + pub(super) fn apply_live_hover_cache_state( + &mut self, + monitor: MonitorRect, + cursor: GlobalPoint, + ) -> bool { + if !matches!(self.state.mode, OverlayMode::Live) { + return false; + } + if !monitor.contains(cursor) { + return false; + } + + let hovered_window_rect = self + .hovered_window_hit_from_window_list_snapshot(monitor, cursor) + .map(|hit| MonitorRectPoints { monitor_id: monitor.id, rect: hit.rect }); + let mut updated = false; + + if self.state.hovered_window_rect != hovered_window_rect { + self.state.hovered_window_rect = hovered_window_rect; + updated = true; + } + + updated + } + + pub(super) fn live_sample_request_redraw_intent( + &self, + hover_changed: bool, + _sample_requested: bool, + _loupe_active: bool, + ) -> LiveSampleApplyResult { + let mut apply = LiveSampleApplyResult::default(); + + if hover_changed { + apply.overlay_changed = true; + apply.hud_changed = true; + } + + apply + } + + fn idle_live_sampling_interval(&self, monitor: MonitorRect) -> Duration { + self.repaint_interval_for_monitor(Some(monitor)).max(CURSOR_POLL_INTERVAL_MIN) + } + + pub(super) fn idle_live_sampling_request_allowed( + &self, + now: Instant, + monitor: MonitorRect, + ) -> bool { + self.last_idle_live_sample_request_at.is_none_or(|last_request_at| { + now.duration_since(last_request_at) >= self.idle_live_sampling_interval(monitor) + }) + } + + fn hovered_window_hit_from_window_list_snapshot( + &self, + monitor: MonitorRect, + cursor: GlobalPoint, + ) -> Option { + let (local_x, local_y) = monitor.local_u32(cursor)?; + let window_list_snapshot = self.window_list_snapshot.as_ref()?; + + window_list_snapshot.windows.iter().find_map(|window| { + let rect = monitor.clip_global_rect_i64( + window.x, + window.y, + window.x.saturating_add(window.width), + window.y.saturating_add(window.height), + )?; + + if !rect.contains((local_x, local_y)) { + return None; + } + + Some(WindowHit { window_id: window.window_id, rect }) + }) + } + + pub(super) fn record_live_sample_stall(&mut self, cursor: GlobalPoint, monitor: MonitorRect) { + let now = Instant::now(); + + match self.last_live_sample_cursor { + Some(last_cursor) if last_cursor == cursor => { + let stall_started_at = self.live_sample_stall_started_at; + + if self.live_sample_stall_started_at.is_none() { + self.live_sample_stall_started_at = Some(now); + } else if stall_started_at + .is_some_and(|start| now.duration_since(start) >= Duration::from_millis(100)) + && self.last_live_sample_stall_log_at.is_none_or(|last_log| { + now.duration_since(last_log) >= Duration::from_millis(250) + }) { + let Some(stall_started_at) = self.live_sample_stall_started_at else { + return; + }; + + tracing::debug!( + cursor = ?cursor, + monitor_id = monitor.id, + stall_duration_ms = now.duration_since(stall_started_at).as_millis(), + "Live sampling cursor unchanged while sampling ticks continue." + ); + + self.last_live_sample_stall_log_at = Some(now); + } + }, + Some(_) => { + self.live_sample_stall_started_at = None; + self.last_live_sample_stall_log_at = None; + }, + None => { + self.live_sample_stall_started_at = Some(now); + }, + } + + self.last_live_sample_cursor = Some(cursor); + } + + pub(super) fn maybe_tick_worker_response_limiter( + &mut self, + resp: WorkerResponse, + ) -> OverlayControl { + let control = match resp { + #[cfg(not(target_os = "macos"))] + WorkerResponse::SampledLiveCursor { monitor, point, request_id, sample } => { + self.handle_sampled_live_cursor_response(monitor, point, request_id, sample); + + OverlayControl::Continue + }, + WorkerResponse::RefreshedWindowList { snapshot } => { + #[cfg(target_os = "macos")] + { + self.window_list_refresh_inflight = false; + } + + #[cfg(target_os = "macos")] + let should_apply_snapshot = !mem::take(&mut self.drop_next_window_list_refresh_snapshot); + #[cfg(not(target_os = "macos"))] + let should_apply_snapshot = true; + + if should_apply_snapshot { + self.handle_refreshed_window_list(snapshot); + } + + OverlayControl::Continue + }, + WorkerResponse::HitTestWindow { monitor, point, request_id, hit } => { + self.handle_hit_test_window_response(monitor, point, request_id, hit); + + OverlayControl::Continue + }, + WorkerResponse::CapturedFreeze { monitor, image, window_image, captured_window_id } => { + self.handle_captured_freeze_response( + monitor, + image, + window_image, + captured_window_id, + ); + + OverlayControl::Continue + }, + #[cfg(target_os = "macos")] + WorkerResponse::RecognizedText { request_id, text } => { + self.handle_recognized_text_worker_response(request_id, text) + }, + WorkerResponse::Error { source, message } => { + match source { + WorkerErrorSource::FreezeCapture => { + self.pending_freeze_capture = None; + self.inflight_freeze_capture = None; + self.pending_freeze_capture_armed = false; + self.pending_window_freeze_capture = None; + self.inflight_window_freeze_capture = None; + + self.restore_capture_windows_visibility(); + }, + WorkerErrorSource::RefreshWindowList => { + #[cfg(target_os = "macos")] + { + self.window_list_refresh_inflight = false; + self.drop_next_window_list_refresh_snapshot = false; + } + }, + WorkerErrorSource::EncodePng => { + #[cfg(target_os = "macos")] + { + self.png_encode_inflight = false; + } + }, + #[cfg(target_os = "macos")] + WorkerErrorSource::RecognizeText => { + if self.handle_recognized_text_worker_error() { + return OverlayControl::Continue; + } + }, + WorkerErrorSource::CaptureMonitorRegion => { + self.clear_scroll_capture_inflight_request(); + self.scroll_capture_set_error(message); + + return OverlayControl::Continue; + }, + } + + self.state.set_error(message); + self.request_redraw_all(); + + OverlayControl::Continue + }, + WorkerResponse::EncodedPng { png_bytes } => { + #[cfg(target_os = "macos")] + { + self.png_encode_inflight = false; + } + + self.handle_encoded_png_response(png_bytes) + }, + }; + + #[cfg(target_os = "macos")] + if matches!(control, OverlayControl::Continue) { + self.maybe_request_redraw_for_pending_output(); + self.maybe_apply_pending_self_capture_exception_window_ids_worker_refresh(); + } + + control + } + + #[cfg(not(target_os = "macos"))] + fn handle_sampled_live_cursor_response( + &mut self, + monitor: MonitorRect, + point: GlobalPoint, + request_id: u64, + sample: LiveCursorSample, + ) { + if !matches!(self.state.mode, OverlayMode::Live) { + return; + } + if self.active_cursor_monitor() != Some(monitor) { + return; + } + if self.latest_live_cursor_sample_request_id != Some(request_id) { + return; + } + + self.applied_live_cursor_sample_request_id = Some(request_id); + + let apply = self.apply_live_cursor_sample_detail(monitor, point, sample); + let sample_latency = self + .latest_live_cursor_sample_requested_at + .take() + .map_or(Duration::ZERO, |requested_at| requested_at.elapsed()); + + self.log_live_sample_apply_timing( + "worker_response", + monitor, + point, + request_id, + sample_latency, + apply, + ); + + if apply.any_changed() { + self.request_redraw_live_sample_targets(monitor, apply); + } + } + + fn handle_refreshed_window_list(&mut self, snapshot: Arc) { + self.window_list_snapshot = Some(snapshot); + + if !matches!(self.state.mode, OverlayMode::Live) { + return; + } + + let Some(cursor) = self.state.cursor else { + return; + }; + let Some(monitor) = self.active_cursor_monitor() else { + return; + }; + let is_dragging_window = self.left_mouse_button_down + && self.left_mouse_button_down_monitor == Some(monitor) + && matches!(self.state.mode, OverlayMode::Live); + + if is_dragging_window { + if self.state.hovered_window_rect.is_some() { + self.state.hovered_window_rect = None; + + self.request_redraw_live_sample_targets( + monitor, + LiveSampleApplyResult { + overlay_changed: true, + hud_changed: true, + loupe_changed: false, + }, + ); + } + + return; + } + if self.apply_live_hover_cache_state(monitor, cursor) { + self.request_redraw_live_sample_targets( + monitor, + LiveSampleApplyResult { + overlay_changed: true, + hud_changed: true, + loupe_changed: false, + }, + ); + } + } + + fn handle_hit_test_window_response( + &mut self, + monitor: MonitorRect, + point: GlobalPoint, + request_id: u64, + hit: Option, + ) { + if !matches!(self.state.mode, OverlayMode::Live) { + return; + } + if self.pending_click_hit_test_request_id == Some(request_id) { + self.pending_click_hit_test_request_id = None; + self.state.hovered_window_rect = None; + + let capture_rect = hit.map(|window_hit| window_hit.rect); + let window_target = hit.and_then(|window_hit| { + window_hit.window_id.map(|window_id| WindowFreezeCaptureTarget { + monitor, + window_id, + rect: window_hit.rect, + }) + }); + + self.begin_frozen_capture_with_rect(monitor, capture_rect, window_target, Some(point)); + } + } + + pub(super) fn request_click_capture_hit_test( + &mut self, + monitor: MonitorRect, + cursor: GlobalPoint, + ) { + self.request_live_window_list_refresh_if_needed(); + + if self.window_list_snapshot.is_none() { + let request_id = self.hit_test_request_id.wrapping_add(1); + let Some(worker) = self.worker.as_ref() else { + self.begin_frozen_capture_with_rect(monitor, None, None, Some(cursor)); + + return; + }; + + self.hit_test_request_id = request_id; + + match worker.request_hit_test_window(monitor, cursor, request_id) { + Ok(()) => { + self.pending_click_hit_test_request_id = Some(request_id); + + return; + }, + Err(WorkerRequestSendError::Full) => { + self.hit_test_send_full_count = self.hit_test_send_full_count.saturating_add(1); + + tracing::debug!( + request_id, + monitor_id = monitor.id, + point = ?cursor, + full_count = self.hit_test_send_full_count, + "Hit test request dropped: worker queue full." + ); + }, + Err(WorkerRequestSendError::Disconnected) => { + self.hit_test_send_disconnected_count = + self.hit_test_send_disconnected_count.saturating_add(1); + + tracing::debug!( + request_id, + monitor_id = monitor.id, + point = ?cursor, + disconnected_count = self.hit_test_send_disconnected_count, + "Hit test request dropped: worker queue disconnected." + ); + }, + } + } + + let capture_hit = self.hovered_window_hit_from_window_list_snapshot(monitor, cursor); + let capture_rect = capture_hit.map(|window_hit| window_hit.rect); + let window_target = capture_hit.and_then(|window_hit| { + window_hit.window_id.map(|window_id| WindowFreezeCaptureTarget { + monitor, + window_id, + rect: window_hit.rect, + }) + }); + + self.begin_frozen_capture_with_rect(monitor, capture_rect, window_target, Some(cursor)); + } +} diff --git a/packages/rsnap-overlay/src/scroll_capture.rs b/packages/rsnap-overlay/src/scroll_capture.rs index 55a4d5ba..19ab3e55 100644 --- a/packages/rsnap-overlay/src/scroll_capture.rs +++ b/packages/rsnap-overlay/src/scroll_capture.rs @@ -1,286 +1,30 @@ -pub mod bench_support { - //! Deterministic scroll-capture fixtures and harnesses used by Criterion benches. - - use image::{Rgba, RgbaImage, imageops}; - - use crate::scroll_capture::{ - OverlapSearchConfig, ScrollDirection, ScrollObserveOutcome, ScrollSession, - evaluate_overlap_direction, max_directional_motion_rows, scroll_capture_fingerprint, - }; - - #[derive(Clone, Copy, Debug, Eq, PartialEq)] - /// Benchmark fixture shapes that exercise the common and wide capture paths. - pub enum ScrollCaptureBenchScenario { - /// Standard-width capture data with modest scroll movement. - Baseline, - /// Wider capture data with a larger viewport and scroll delta. - Wide, - } - - impl ScrollCaptureBenchScenario { - /// All supported benchmark scenarios in stable iteration order. - pub const ALL: [Self; 2] = [Self::Baseline, Self::Wide]; - - #[must_use] - /// Returns the stable bench-function suffix for this scenario. - pub const fn as_str(self) -> &'static str { - match self { - Self::Baseline => "baseline", - Self::Wide => "wide", - } - } - - const fn spec(self) -> ScrollCaptureBenchFixtureSpec { - match self { - Self::Baseline => ScrollCaptureBenchFixtureSpec { - width: 192, - document_rows: 320, - window_rows: 128, - motion_rows: 12, - preview_width_px: 320, - }, - Self::Wide => ScrollCaptureBenchFixtureSpec { - width: 320, - document_rows: 448, - window_rows: 160, - motion_rows: 20, - preview_width_px: 320, - }, - } - } - } - - #[derive(Clone, Copy, Debug, Default)] - /// Fingerprint benchmark output used for deterministic performance checks. - pub struct ScrollCaptureFingerprintMetrics { - /// Total byte length of the generated fingerprint payload. - pub byte_len: usize, - /// Stable checksum of the generated fingerprint payload. - pub checksum: u32, - } - - #[derive(Clone, Copy, Debug, Default)] - /// Overlap-match benchmark output for a single downward sample. - pub struct ScrollCaptureOverlapMetrics { - /// Whether the overlap search produced a valid match. - pub matched: bool, - /// Detected scroll motion in rows. - pub motion_rows: u32, - /// Rows that remained overlapped after applying the detected motion. - pub overlap_rows: u32, - /// Mean absolute difference metric for the matched overlap window. - pub mean_abs_diff_x100: u32, - } - - #[derive(Clone, Copy, Debug, Default)] - /// Session-commit benchmark output for a single growth observation. - pub struct ScrollCaptureSessionMetrics { - /// Whether the sample committed new growth into the session. - pub committed: bool, - /// Number of rows added to the stitched export. - pub growth_rows: u32, - /// Export image height after the observation completes. - pub export_height: u32, - /// Preview image height after the observation completes. - pub preview_height: u32, - } - - /// Reusable scroll-capture benchmark harness backed by deterministic image fixtures. - pub struct ScrollCaptureBenchHarness { - fixture: ScrollCaptureBenchFixture, - overlap_config: OverlapSearchConfig, - } - - impl ScrollCaptureBenchHarness { - #[must_use] - /// Builds the benchmark harness for the selected fixture scenario. - pub fn new(scenario: ScrollCaptureBenchScenario) -> Self { - Self { - fixture: ScrollCaptureBenchFixture::new(scenario.spec()), - overlap_config: OverlapSearchConfig::default(), - } - } - - #[must_use] - /// Runs the fingerprint path and returns stable summary metrics. - pub fn run_fingerprint(&self) -> ScrollCaptureFingerprintMetrics { - let bytes = scroll_capture_fingerprint(&self.fixture.fingerprint_frame); - - ScrollCaptureFingerprintMetrics { - byte_len: bytes.len(), - checksum: checksum_bytes(&bytes), - } - } - - #[must_use] - /// Runs the overlap matcher and returns the resulting comparison metrics. - pub fn run_overlap_match(&self) -> ScrollCaptureOverlapMetrics { - let max_motion_rows = max_directional_motion_rows( - &self.fixture.base_frame, - &self.fixture.next_frame, - self.overlap_config, - ); - let matched = evaluate_overlap_direction( - &self.fixture.base_frame, - &self.fixture.next_frame, - ScrollDirection::Down, - 1..=max_motion_rows, - self.overlap_config, - ); - - matched.map_or( - ScrollCaptureOverlapMetrics { - matched: false, - motion_rows: 0, - overlap_rows: 0, - mean_abs_diff_x100: u32::MAX, - }, - |matched| ScrollCaptureOverlapMetrics { - matched: true, - motion_rows: matched.motion_rows, - overlap_rows: self - .fixture - .window_rows - .min(self.fixture.base_frame.height()) - .saturating_sub(matched.motion_rows), - mean_abs_diff_x100: matched.mean_abs_diff_x100, - }, - ) - } - - #[must_use] - /// Runs one downward observation through the session-commit path. - pub fn run_session_commit(&self) -> ScrollCaptureSessionMetrics { - let mut session = self.fixture.new_session(); - let outcome = session - .observe_downward_sample(self.fixture.next_frame.clone()) - .expect("scroll-capture benchmark fixture should observe successfully"); - let (committed, growth_rows) = match outcome { - ScrollObserveOutcome::Committed { growth_rows, .. } => (true, growth_rows), - _ => (false, 0), - }; - - ScrollCaptureSessionMetrics { - committed, - growth_rows, - export_height: session.export_image().height(), - preview_height: session.preview_image().height(), - } - } - } - - #[derive(Clone, Copy)] - struct ScrollCaptureBenchFixtureSpec { - width: u32, - document_rows: u32, - window_rows: u32, - motion_rows: u32, - preview_width_px: u32, - } - - struct ScrollCaptureBenchFixture { - base_frame: RgbaImage, - next_frame: RgbaImage, - fingerprint_frame: RgbaImage, - window_rows: u32, - preview_width_px: u32, - } - - impl ScrollCaptureBenchFixture { - fn new(spec: ScrollCaptureBenchFixtureSpec) -> Self { - let document = build_document(spec.width, spec.document_rows); - let base_frame = crop_window(&document, 24, spec.window_rows); - let next_frame = crop_window(&document, 24 + spec.motion_rows, spec.window_rows); - let fingerprint_frame = - crop_window(&document, 24 + spec.motion_rows.saturating_mul(2), spec.window_rows); - - Self { - base_frame, - next_frame, - fingerprint_frame, - window_rows: spec.window_rows, - preview_width_px: spec.preview_width_px, - } - } - - fn new_session(&self) -> ScrollSession { - ScrollSession::new(self.base_frame.clone(), self.preview_width_px) - .expect("scroll-capture benchmark fixture should build a valid session") - } - } - - fn crop_window(document: &RgbaImage, start_row: u32, rows: u32) -> RgbaImage { - imageops::crop_imm(document, 0, start_row, document.width(), rows).to_image() - } - - fn build_document(width: u32, rows: u32) -> RgbaImage { - let mut image = RgbaImage::new(width, rows); - - for y in 0..rows { - for x in 0..width { - let stripe = (y / 8) % 6; - let lane = (x / 12) % 5; - let mut r = ((x.wrapping_mul(13) + y.wrapping_mul(17) + stripe.wrapping_mul(29)) - % 251) as u8; - let mut g = - ((x.wrapping_mul(7) + y.wrapping_mul(19) + lane.wrapping_mul(23)) % 251) as u8; - let mut b = (((x / 2).wrapping_mul(11) - + y.wrapping_mul(5) - + stripe.wrapping_mul(31) - + lane.wrapping_mul(17)) - % 251) as u8; - - if x < 10 || x + 10 >= width { - r = 8; - g = 8; - b = 8; - } - if y % 32 == 0 { - r = r.saturating_add(21); - g = g.saturating_add(9); - } - if (x / 24 + y / 16).is_multiple_of(2) { - b = b.saturating_add(13); - } - - image.put_pixel(x, y, Rgba([r, g, b, 255])); - } - } - - image - } - - fn checksum_bytes(bytes: &[u8]) -> u32 { - bytes.iter().fold(0_u32, |acc, byte| { - acc.wrapping_mul(16_777_619).wrapping_add(u32::from(*byte).wrapping_add(1)) - }) - } -} +pub mod bench_support; +mod downward_resolution; +mod support; use std::ops::RangeInclusive; -#[cfg(target_os = "macos")] -use std::ptr; use color_eyre::eyre::{self, Result}; -use image::{ - RgbaImage, - imageops::{self, FilterType}, +use image::RgbaImage; + +#[cfg(test)] +use self::support::detect_vertical_overlap; +use self::support::{ + append_vertical_image, best_local_downward_viewport_candidate, + classify_downward_registration_candidates, classify_vision_downward_sample_motion_against, + collect_overlap_direction_matches, collect_overlap_direction_matches_in_ranges, + crop_bottom_rows, downward_registration_has_meaningful_overlap, + estimate_pairwise_downward_shift_rows, evaluate_overlap_direction, evenly_spaced_sample, + format_downward_viewport_candidates, informative_column_span, max_directional_motion_rows, + preferred_upward_input_override_match, preferred_upward_override_match, preview_update_outcome, + resize_strip_to_preview_width, resume_direct_match_is_trustworthy, + rewind_active_upward_motion_should_fail_closed, rewind_active_upward_override_match, + select_downward_viewport_candidate, stack_vertical_images, + upward_confirmation_match_for_downward_input, }; -#[cfg(target_os = "macos")] -use objc2::{AnyThread, runtime::AnyObject}; -#[cfg(target_os = "macos")] -use objc2_core_foundation::CFData; -#[cfg(target_os = "macos")] -use objc2_core_foundation::CFRetained; -#[cfg(target_os = "macos")] -use objc2_core_graphics::{ - CGBitmapInfo, CGColorRenderingIntent, CGColorSpace, CGDataProvider, CGImage, CGImageAlphaInfo, - CGImageByteOrderInfo, +pub(crate) use self::support::{ + compose_provisional_preview_image, scroll_capture_fingerprint, scroll_capture_fingerprint_delta, }; -#[cfg(target_os = "macos")] -use objc2_foundation::{NSArray, NSDictionary}; -#[cfg(target_os = "macos")] -use objc2_vision::{VNImageOption, VNImageRequestHandler, VNTranslationalImageRegistrationRequest}; pub(crate) const PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS: u32 = 24; pub(crate) const PREVIEW_ONLY_LOCAL_RECOVERY_MAX_TOLERANCE_ROWS: u32 = 12; @@ -3297,6090 +3041,262 @@ impl ScrollSession { true } +} - fn evaluate_reference_overlap_direction( - &self, - previous: &RgbaImage, - next: &RgbaImage, - direction: ScrollDirection, - motion_rows_hint: Option, - ) -> Option { - let config = OverlapSearchConfig::default(); - let preferred_range = - self.preferred_motion_range_from_hint(previous, next, motion_rows_hint, config)?; - - evaluate_overlap_direction(previous, next, direction, preferred_range, config) - } - - fn evaluate_reference_downward_registration( - &self, - previous: &RgbaImage, - next: &RgbaImage, - motion_rows_hint: Option, - allow_full_range_fallback: bool, - ) -> DownwardRegistration { - let config = OverlapSearchConfig::default(); - let preferred_range = self.preferred_downward_motion_range_from_hint( - previous, - next, - motion_rows_hint, - config, - ); - - self.evaluate_reference_downward_registration_with_preferred_range( - previous, - next, - motion_rows_hint, - preferred_range, - allow_full_range_fallback, - ) - } - - fn evaluate_reference_downward_registration_with_preferred_ranges( - &self, - previous: &RgbaImage, - next: &RgbaImage, - motion_rows_hint: Option, - preferred_ranges: &[RangeInclusive], - allow_full_range_fallback: bool, - ) -> (DownwardRegistration, Option<&'static str>) { - let config = OverlapSearchConfig::default(); - let max_overlap = previous.height().min(next.height()); - let max_motion_rows = max_directional_motion_rows(previous, next, config); - let mut candidates = collect_overlap_direction_matches_in_ranges( - previous, - next, - ScrollDirection::Down, - preferred_ranges, - config, - ); - let mut no_match_reason = if candidates.is_empty() { Some("no_candidates") } else { None }; +#[derive(Clone, Debug, Eq, PartialEq)] +struct PreviewOnlyDownwardLocalSample { + frame: RgbaImage, + viewport_top_y: i32, +} - if candidates.is_empty() - && allow_full_range_fallback - && (motion_rows_hint.is_none() || self.transient_burst_search_enabled) - { - candidates = collect_overlap_direction_matches( - previous, - next, - ScrollDirection::Down, - 1..=max_motion_rows, - config, - ); - no_match_reason = if candidates.is_empty() { Some("no_candidates") } else { None }; - } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct DirectionMatch { + mean_abs_diff_x100: u32, + motion_rows: u32, +} - candidates.retain(|matched| { - downward_registration_has_meaningful_overlap(*matched, max_overlap, config) - }); +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct DownwardSampleMatch { + matched: DirectionMatch, + source: DownwardSampleMatchSource, +} - if candidates.is_empty() { - no_match_reason.get_or_insert("insufficient_overlap"); +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct DownwardViewportCandidate { + source: DownwardViewportCandidateSource, + viewport_top_y: i32, + motion_rows: u32, + mean_abs_diff_x100: u32, +} +impl DownwardViewportCandidate { + fn competing_block_reason(self, competing: Self) -> &'static str { + match (self.source, competing.source) { + ( + DownwardViewportCandidateSource::CommittedKeyframe, + DownwardViewportCandidateSource::CommittedKeyframe, + ) => "conflicting_committed_keyframe_authority", + _ => "conflicting_downward_viewport_authority", } + } +} - let classification = classify_downward_registration_candidates(&candidates); - let upward_veto = self.evaluate_reference_overlap_direction( - previous, - next, - ScrollDirection::Up, - motion_rows_hint, - ); +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct BlockedPreviewOnlyLocalCandidate { + candidate: DownwardViewportCandidate, + repeats: u8, +} - match (classification, upward_veto) { - (DownwardRegistration::Matched(down), Some(up)) - if up.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) - <= down.mean_abs_diff_x100 => - { - (DownwardRegistration::NoMatch, Some("upward_veto")) - }, - (DownwardRegistration::NoMatch, _) => (DownwardRegistration::NoMatch, no_match_reason), - (other, _) => (other, None), - } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct OverlapSearchRange { + start: u32, + end: u32, +} +impl OverlapSearchRange { + fn as_range(self) -> RangeInclusive { + self.start..=self.end } +} - fn evaluate_reference_downward_registration_with_preferred_range( - &self, - previous: &RgbaImage, - next: &RgbaImage, - motion_rows_hint: Option, - preferred_range: Option>, - allow_full_range_fallback: bool, - ) -> DownwardRegistration { - let config = OverlapSearchConfig::default(); - let max_overlap = previous.height().min(next.height()); - let max_motion_rows = max_directional_motion_rows(previous, next, config); - let mut candidates = preferred_range.as_ref().map_or_else(Vec::new, |range| { - collect_overlap_direction_matches( - previous, - next, - ScrollDirection::Down, - range.clone(), - config, - ) - }); - let mut no_match_reason = if candidates.is_empty() { Some("no_candidates") } else { None }; +impl From> for OverlapSearchRange { + fn from(range: RangeInclusive) -> Self { + Self { start: *range.start(), end: *range.end() } + } +} - if candidates.is_empty() - && allow_full_range_fallback - && (motion_rows_hint.is_none() || self.transient_burst_search_enabled) - { - candidates = collect_overlap_direction_matches( - previous, - next, - ScrollDirection::Down, - 1..=max_motion_rows, - config, - ); - no_match_reason = if candidates.is_empty() { Some("no_candidates") } else { None }; - } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct DirectionMatchEval { + preferred_range: Option, + max_motion_rows: u32, + preferred_only_match: Option, + final_match: Option, + used_full_range_fallback: bool, +} - candidates.retain(|matched| { - downward_registration_has_meaningful_overlap(*matched, max_overlap, config) - }); +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct MotionObservation { + direction: ScrollDirection, + motion_rows: u32, +} - if candidates.is_empty() { - no_match_reason.get_or_insert("insufficient_overlap"); - } +#[derive(Clone, Copy, Debug)] +struct UpInputMatchLog { + sample_motion: Option, + sample_down_match: Option, + sample_up_match: Option, + committed_down_match: Option, + committed_up_match: Option, + sample_override_wins: bool, + committed_override_wins: bool, +} - let classification = classify_downward_registration_candidates(&candidates); - let upward_veto = self.evaluate_reference_overlap_direction( - previous, - next, - ScrollDirection::Up, - motion_rows_hint, - ); +#[derive(Clone, Copy, Debug)] +struct UpInputSearchWindowLog<'a> { + sample_delta: Option, + sample_down_match_eval: &'a DirectionMatchEval, + sample_up_match_eval: &'a DirectionMatchEval, + committed_down_match_eval: &'a DirectionMatchEval, + committed_up_match_eval: &'a DirectionMatchEval, + frame_equals_last_sample: bool, + frame_equals_last_committed: bool, +} - match (classification, upward_veto) { - (DownwardRegistration::Matched(down), Some(up)) - if up.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) - <= down.mean_abs_diff_x100 => - { - DownwardRegistration::NoMatch - }, - (DownwardRegistration::NoMatch, _) => { - let _ = no_match_reason; +#[derive(Clone, Copy, Debug)] +struct UpwardInputDiagnostics { + sample_down_match_eval: DirectionMatchEval, + sample_up_match_eval: DirectionMatchEval, + committed_down_match_eval: DirectionMatchEval, + committed_up_match_eval: DirectionMatchEval, + sample_override_match: Option, + committed_override_match: Option, +} - DownwardRegistration::NoMatch - }, - (other, _) => other, - } - } +#[derive(Clone, Copy, Debug)] +struct ResumeFrontierMatchLog { + motion_rows: u32, + candidate_observed_viewport_top_y: i32, + residual_growth_rows: u32, + raw_committed_down_match: Option, + trusted_committed_down_match: Option, + committed_up_match: Option, + frame_reacquires_last_committed_viewport: bool, +} - fn sequential_downward_motion_ranges( - &self, - previous: &RgbaImage, - next: &RgbaImage, - config: OverlapSearchConfig, - ) -> Vec> { - let local_motion_rows_hint = self.last_motion_rows_hint; - let mut ranges = Vec::new(); - - if let Some(local_range) = self.preferred_local_downward_motion_range_from_hint( - previous, - next, - local_motion_rows_hint, - config, - ) { - ranges.push(local_range); - } - - if self.initial_downward_bootstrap_active() && self.last_motion_rows_hint.is_none() { - return ranges; - } - - if let Some(transient_range) = self.transient_downward_motion_range(previous, next, config) - && !ranges.contains(&transient_range) - { - ranges.push(transient_range); - } - - ranges - } +#[derive(Clone, Copy, Debug)] +struct ResumeFrontierDirectMatchContext { + motion_rows: u32, + candidate_observed_viewport_top_y: i32, + residual_growth_rows: u32, +} - fn clear_last_downward_sample_registration(&mut self) { - self.last_downward_sample_registration_result = None; - self.last_downward_sample_registration_source = None; - self.last_downward_sample_registration_motion_rows = None; - self.last_downward_sample_registration_provisional_viewport_top_y = None; - self.last_observed_sample_registration_result = None; - self.last_observed_sample_registration_reason = None; - self.last_observed_sample_registration_motion_rows = None; - self.last_observed_sample_registration_mean_abs_diff_x100 = None; - self.last_preview_only_local_registration_result = None; - self.last_preview_only_local_registration_reason = None; - self.last_preview_only_local_registration_motion_rows = None; - self.last_preview_only_local_registration_mean_abs_diff_x100 = None; - self.last_downward_viewport_candidate_count = None; - self.last_downward_viewport_candidates_before_prune = None; - self.last_downward_viewport_candidates_after_prune = None; - self.blocked_underconsumed_observed_recovery_in_burst = false; - self.blocked_lagging_exactly_corroborated_preview_local_tail_in_burst = false; - self.blocked_followup_after_suppressed_huge_preview_local_jump = false; - self.blocked_followup_after_extreme_preview_local_tail = false; - self.blocked_far_committed_only_recovery_after_corroborated_huge_local_jump = false; - } +#[derive(Clone, Debug)] +struct GrowthCommit { + frame: RgbaImage, + growth_rows: u32, + viewport_top_y: i32, + decision_source: &'static str, + detected_motion_rows: Option, + effective_motion_rows_hint: Option, +} - fn record_last_downward_sample_registration( - &mut self, - result: &'static str, - source: Option, - motion_rows: Option, - ) { - self.last_downward_sample_registration_result = Some(result); - self.last_downward_sample_registration_source = - source.map(DownwardSampleMatchSource::label); - self.last_downward_sample_registration_motion_rows = motion_rows; - } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct InformativeSpan { + start_x: u32, + end_exclusive_x: u32, +} - fn record_last_sample_eval_context(&mut self) { - self.last_sample_eval_last_motion_rows_hint = self.last_motion_rows_hint; - self.last_sample_eval_transient_motion_rows_hint = self.transient_motion_rows_hint; - self.last_sample_eval_effective_motion_rows_hint = self.effective_motion_rows_hint(); - self.last_sample_eval_transient_burst_search_enabled = self.transient_burst_search_enabled; - } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ScrollDirection { + Up, + Down, +} - fn transient_downward_motion_range( - &self, - previous: &RgbaImage, - next: &RgbaImage, - config: OverlapSearchConfig, - ) -> Option> { - let transient_motion_rows_hint = self.normalized_transient_motion_rows_hint()?; - let max_motion_rows = max_directional_motion_rows(previous, next, config); +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ScrollObserveOutcome { + NoChange, + PreviewUpdated, + UnsupportedDirection { direction: ScrollDirection }, + Committed { direction: ScrollDirection, growth_rows: u32 }, +} - if transient_motion_rows_hint == 0 || transient_motion_rows_hint > max_motion_rows { - return None; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DownwardRegistration { + NoMatch, + Matched(DirectionMatch), + Ambiguous { best: DirectionMatch, competing: DirectionMatch }, +} +impl DownwardRegistration { + fn map_source(self, source: DownwardSampleMatchSource) -> DownwardRegistrationWithSource { + match self { + Self::NoMatch => DownwardRegistrationWithSource::NoMatch, + Self::Matched(matched) => { + DownwardRegistrationWithSource::Matched(DownwardSampleMatch { matched, source }) + }, + Self::Ambiguous { best, competing } => DownwardRegistrationWithSource::Ambiguous { + best: DownwardSampleMatch { matched: best, source }, + competing: DownwardSampleMatch { matched: competing, source }, + }, } - - let tolerance = (transient_motion_rows_hint / 2) - .clamp( - LOCAL_DOWNWARD_SEARCH_MOTION_TOLERANCE_ROWS, - LOCAL_DOWNWARD_SEARCH_MAX_TOLERANCE_ROWS, - ) - .min(max_motion_rows); - let min_motion_rows = transient_motion_rows_hint.saturating_sub(tolerance).max(1); - let max_motion_rows = - transient_motion_rows_hint.saturating_add(tolerance).min(max_motion_rows); - - Some(min_motion_rows..=max_motion_rows) } +} - fn preferred_local_downward_motion_range_from_hint( - &self, - previous: &RgbaImage, - next: &RgbaImage, - motion_rows_hint: Option, - config: OverlapSearchConfig, - ) -> Option> { - let max_motion_rows = max_directional_motion_rows(previous, next, config); - - if let Some(last_growth_rows) = motion_rows_hint { - let tolerance = (last_growth_rows / 2) - .clamp( - LOCAL_DOWNWARD_SEARCH_MOTION_TOLERANCE_ROWS, - LOCAL_DOWNWARD_SEARCH_MAX_TOLERANCE_ROWS, - ) - .min(max_motion_rows); - let min_motion_rows = last_growth_rows.saturating_sub(tolerance).max(1); - let max_motion_rows = last_growth_rows.saturating_add(tolerance).min(max_motion_rows); - - return Some(min_motion_rows..=max_motion_rows); +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DownwardSampleMatchSource { + ObservedSample, + PreviewOnlyLocalSample, +} +impl DownwardSampleMatchSource { + const fn label(self) -> &'static str { + match self { + Self::ObservedSample => "observed_sample", + Self::PreviewOnlyLocalSample => "preview_only_local_sample", } - - Some(1..=INITIAL_DOWNWARD_MAX_MOTION_ROWS.min(max_motion_rows).max(1)) - } - - fn diagnose_reference_overlap_direction( - &self, - previous: &RgbaImage, - next: &RgbaImage, - direction: ScrollDirection, - motion_rows_hint: Option, - ) -> DirectionMatchEval { - let config = OverlapSearchConfig::default(); - let preferred_range = self - .preferred_motion_range_from_hint(previous, next, motion_rows_hint, config) - .map(OverlapSearchRange::from); - - self.diagnose_reference_overlap_direction_with_preferred_range( - previous, - next, - direction, - preferred_range, - false, - ) } +} - fn diagnose_reference_overlap_direction_with_preferred_range( - &self, - previous: &RgbaImage, - next: &RgbaImage, - direction: ScrollDirection, - preferred_range: Option, - allow_downward_full_range_fallback: bool, - ) -> DirectionMatchEval { - let config = OverlapSearchConfig::default(); - let max_motion_rows = max_directional_motion_rows(previous, next, config); - let preferred_only_match = preferred_range.and_then(|range| { - evaluate_overlap_direction(previous, next, direction, range.as_range(), config) - }); - let mut final_match = preferred_only_match; - let mut used_full_range_fallback = false; - - if final_match.is_none() && allow_downward_full_range_fallback { - final_match = - evaluate_overlap_direction(previous, next, direction, 1..=max_motion_rows, config); - used_full_range_fallback = final_match.is_some(); - } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DownwardRegistrationWithSource { + NoMatch, + Matched(DownwardSampleMatch), + Ambiguous { best: DownwardSampleMatch, competing: DownwardSampleMatch }, +} - DirectionMatchEval { - preferred_range, - max_motion_rows, - preferred_only_match, - final_match, - used_full_range_fallback, +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DownwardViewportCandidateSource { + ObservedSample, + PreviewOnlyLocalSample, + CommittedKeyframe, +} +impl DownwardViewportCandidateSource { + const fn priority(self) -> u8 { + match self { + Self::CommittedKeyframe => 0, + Self::ObservedSample => 1, + Self::PreviewOnlyLocalSample => 2, } } - fn evaluate_reference_overlap_direction_preferred_only( - &self, - previous: &RgbaImage, - next: &RgbaImage, - direction: ScrollDirection, - motion_rows_hint: Option, - ) -> Option { - let config = OverlapSearchConfig::default(); - let preferred_range = - self.preferred_motion_range_from_hint(previous, next, motion_rows_hint, config)?; - - evaluate_overlap_direction(previous, next, direction, preferred_range, config) - } - - fn preferred_motion_range_from_hint( - &self, - previous: &RgbaImage, - next: &RgbaImage, - motion_rows_hint: Option, - config: OverlapSearchConfig, - ) -> Option> { - let max_motion_rows = max_directional_motion_rows(previous, next, config); - - if let Some(last_growth_rows) = motion_rows_hint { - let tolerance = DOWNWARD_SEARCH_MOTION_TOLERANCE_ROWS.min(max_motion_rows); - let min_motion_rows = last_growth_rows.saturating_sub(tolerance).max(1); - let max_motion_rows = last_growth_rows.saturating_add(tolerance).min(max_motion_rows); - - return Some(min_motion_rows..=max_motion_rows); + const fn decision_source(self) -> &'static str { + match self { + Self::ObservedSample => "sample_motion_downward_growth_from_observed_keyframe", + Self::PreviewOnlyLocalSample => { + "sample_motion_downward_growth_from_preview_only_local_sample" + }, + Self::CommittedKeyframe => "sample_motion_downward_growth_from_committed_keyframe", } - - Some(1..=INITIAL_DOWNWARD_MAX_MOTION_ROWS.min(max_motion_rows).max(1)) } - fn preferred_downward_motion_range_from_hint( - &self, - previous: &RgbaImage, - next: &RgbaImage, - motion_rows_hint: Option, - config: OverlapSearchConfig, - ) -> Option> { - let max_motion_rows = max_directional_motion_rows(previous, next, config); - - if let Some(last_growth_rows) = motion_rows_hint { - let tolerance = (last_growth_rows / 2) - .clamp( - DOWNWARD_KEYFRAME_SEARCH_MOTION_TOLERANCE_ROWS, - DOWNWARD_KEYFRAME_SEARCH_MAX_TOLERANCE_ROWS, - ) - .min(max_motion_rows); - let min_motion_rows = last_growth_rows.saturating_sub(tolerance).max(1); - let max_motion_rows = last_growth_rows.saturating_add(tolerance).min(max_motion_rows); - - return Some(min_motion_rows..=max_motion_rows); + const fn fallback_decision_source(self) -> &'static str { + match self { + Self::ObservedSample => "fallback_downward_registration_from_observed_keyframe", + Self::PreviewOnlyLocalSample => { + "fallback_downward_registration_from_preview_only_local_sample" + }, + Self::CommittedKeyframe => "fallback_downward_registration_from_committed_keyframe", } - - Some(1..=INITIAL_DOWNWARD_MAX_MOTION_ROWS.min(max_motion_rows).max(1)) } +} - fn resolve_downward_viewport_candidate( - &mut self, - frame: &RgbaImage, - observed_match: DownwardSampleMatch, - ) -> DownwardViewportResolution { - let pending_suppressed_huge_preview_only_local_followup = - self.pending_suppressed_huge_preview_only_local_followup.take(); - let pending_suppressed_huge_preview_only_local_followup_remaining_blocks = - self.pending_suppressed_huge_preview_only_local_followup_remaining_blocks; - - self.pending_suppressed_huge_preview_only_local_followup_remaining_blocks = 0; - - let pending_extreme_preview_only_local_tail_followup = - self.pending_extreme_preview_only_local_tail_followup.take(); - let pending_extreme_preview_only_local_tail_followup_remaining_blocks = - self.pending_extreme_preview_only_local_tail_followup_remaining_blocks; - - self.pending_extreme_preview_only_local_tail_followup_remaining_blocks = 0; - - let provisional_viewport_top_y = - self.provisional_viewport_top_y_for_downward_sample_match(observed_match); - let mut candidates = Vec::with_capacity(DOWNWARD_KEYFRAME_SEARCH_LIMIT.saturating_add(1)); - let mut suppressed_observed_candidate = None; - let mut suppressed_preview_only_local_candidate = None; - - self.last_downward_sample_registration_provisional_viewport_top_y = - provisional_viewport_top_y; - - if let Some(viewport_top_y) = provisional_viewport_top_y { - let candidate = DownwardViewportCandidate { - source: observed_match.source.into(), - viewport_top_y, - motion_rows: observed_match.matched.motion_rows, - mean_abs_diff_x100: observed_match.matched.mean_abs_diff_x100, - }; - let suppress_observed = self.should_suppress_observed_sample_candidate(candidate); - let suppress_preview_local = - self.should_suppress_preview_only_local_candidate(candidate); - - if !suppress_observed && !suppress_preview_local { - candidates.push(candidate); - } else if suppress_observed - && candidate.source == DownwardViewportCandidateSource::ObservedSample - { - suppressed_observed_candidate = Some(candidate); - } else if suppress_preview_local - && candidate.source == DownwardViewportCandidateSource::PreviewOnlyLocalSample - { - suppressed_preview_only_local_candidate = Some(candidate); - } - } - - self.collect_committed_downward_viewport_candidates(frame, &mut candidates); - self.apply_pending_preview_local_followup_blocks( - suppressed_preview_only_local_candidate, - pending_suppressed_huge_preview_only_local_followup, - pending_suppressed_huge_preview_only_local_followup_remaining_blocks, - pending_extreme_preview_only_local_tail_followup, - pending_extreme_preview_only_local_tail_followup_remaining_blocks, - &mut candidates, - ); - self.restore_corroborated_observed_candidate( - suppressed_observed_candidate, - &mut candidates, - ); - - let preview_only_local_candidate_before_prune = - candidates.iter().copied().find(|candidate| { - candidate.source == DownwardViewportCandidateSource::PreviewOnlyLocalSample - }); - let candidates_before_prune = candidates.clone(); - - self.last_downward_viewport_candidates_before_prune = - Some(format_downward_viewport_candidates(&candidates)); - - self.prune_committed_keyframe_candidates_outside_local_continuity(&mut candidates); - self.restore_repeated_small_preview_only_local_candidate_after_empty_prune( - preview_only_local_candidate_before_prune, - &mut candidates, - ); - - if self.should_fail_closed_lagging_exactly_corroborated_preview_local_tail_in_burst( - &candidates, - ) { - self.blocked_lagging_exactly_corroborated_preview_local_tail_in_burst = true; - - candidates.clear(); - } - if self.should_fail_closed_underconsumed_observed_recovery_in_burst( - &candidates_before_prune, - &candidates, - ) { - self.blocked_underconsumed_observed_recovery_in_burst = true; - - candidates.clear(); +impl From for DownwardViewportCandidateSource { + fn from(value: DownwardSampleMatchSource) -> Self { + match value { + DownwardSampleMatchSource::ObservedSample => Self::ObservedSample, + DownwardSampleMatchSource::PreviewOnlyLocalSample => Self::PreviewOnlyLocalSample, } - - self.last_downward_viewport_candidate_count = Some(candidates.len()); - self.last_downward_viewport_candidates_after_prune = - Some(format_downward_viewport_candidates(&candidates)); - - select_downward_viewport_candidate(&mut candidates) } +} - #[allow(clippy::too_many_arguments)] - fn apply_pending_preview_local_followup_blocks( - &mut self, - suppressed_preview_only_local_candidate: Option, - pending_suppressed_huge_preview_only_local_followup: Option, - pending_suppressed_huge_preview_only_local_followup_remaining_blocks: u8, - pending_extreme_preview_only_local_tail_followup: Option, - pending_extreme_preview_only_local_tail_followup_remaining_blocks: u8, - candidates: &mut Vec, - ) { - if self - .should_fail_closed_suppressed_huge_preview_local_jump_corroborated_by_observed_and_committed( - suppressed_preview_only_local_candidate, - candidates, - ) { - self.pending_suppressed_huge_preview_only_local_followup = - suppressed_preview_only_local_candidate; - self.pending_suppressed_huge_preview_only_local_followup_remaining_blocks = self - .suppressed_huge_preview_only_local_followup_block_budget( - suppressed_preview_only_local_candidate, - ); - - candidates.clear(); - } - if self.should_fail_closed_committed_followup_after_suppressed_huge_preview_local_jump( - pending_suppressed_huge_preview_only_local_followup, - candidates, - ) { - if let Some(pending_candidate) = pending_suppressed_huge_preview_only_local_followup - && pending_suppressed_huge_preview_only_local_followup_remaining_blocks > 1 - { - self.pending_suppressed_huge_preview_only_local_followup = Some(pending_candidate); - self.pending_suppressed_huge_preview_only_local_followup_remaining_blocks = - pending_suppressed_huge_preview_only_local_followup_remaining_blocks - 1; - } - - self.blocked_followup_after_suppressed_huge_preview_local_jump = true; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CommittedDownwardViewportCandidateMode { + LastCommittedOnly, + IncludeRecentHistory, +} - candidates.clear(); - } - if self.should_fail_closed_committed_followup_after_extreme_preview_local_tail_block( - pending_extreme_preview_only_local_tail_followup, - candidates, - ) { - if let Some(pending_candidate) = pending_extreme_preview_only_local_tail_followup - && pending_extreme_preview_only_local_tail_followup_remaining_blocks > 1 - { - self.pending_extreme_preview_only_local_tail_followup = Some(pending_candidate); - self.pending_extreme_preview_only_local_tail_followup_remaining_blocks = - pending_extreme_preview_only_local_tail_followup_remaining_blocks - 1; - } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DownwardViewportResolution { + NoMatch, + Selected(DownwardViewportCandidate), + Ambiguous { preferred: DownwardViewportCandidate, competing: DownwardViewportCandidate }, +} - self.blocked_followup_after_extreme_preview_local_tail = true; - - candidates.clear(); - } - } - - fn should_fail_closed_suppressed_huge_preview_local_jump_corroborated_by_observed_and_committed( - &self, - suppressed_preview_only_local_candidate: Option, - committed_candidates: &[DownwardViewportCandidate], - ) -> bool { - let Some(candidate) = suppressed_preview_only_local_candidate else { - return false; - }; - let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { - return false; - }; - - if candidate.source != DownwardViewportCandidateSource::PreviewOnlyLocalSample { - return false; - } - - let large_far_recovery_threshold = last_motion_rows_hint - .saturating_mul(3) - .max(PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS.saturating_mul(2)); - - self.transient_burst_search_enabled - && self.last_observed_sample_registration_result == Some("matched") - && self.last_observed_sample_registration_motion_rows == Some(candidate.motion_rows) - && candidate.motion_rows > large_far_recovery_threshold - && self.growth_history.last().is_some_and(|commit| { - commit.decision_source - == DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source() - && commit.growth_rows - >= last_motion_rows_hint - .saturating_sub(PREVIEW_ONLY_LOCAL_NEAR_CONTINUITY_ROWS) - }) && committed_candidates.iter().any(|committed| { - committed.source == DownwardViewportCandidateSource::CommittedKeyframe - && committed.motion_rows == candidate.motion_rows - && committed.viewport_top_y == candidate.viewport_top_y - }) - } - - fn should_fail_closed_committed_followup_after_suppressed_huge_preview_local_jump( - &self, - pending_suppressed_preview_only_local_candidate: Option, - candidates: &[DownwardViewportCandidate], - ) -> bool { - let Some(pending_candidate) = pending_suppressed_preview_only_local_candidate else { - return false; - }; - - if pending_candidate.source != DownwardViewportCandidateSource::PreviewOnlyLocalSample { - return false; - } - - self.transient_burst_search_enabled - && self.last_preview_only_local_registration_result == Some("no_match") - && self.last_observed_sample_registration_result == Some("matched") - && self.last_observed_sample_registration_motion_rows - == Some(pending_candidate.motion_rows) - && candidates.iter().all(|candidate| { - candidate.source == DownwardViewportCandidateSource::CommittedKeyframe - }) && candidates.iter().any(|candidate| { - candidate.viewport_top_y == pending_candidate.viewport_top_y - && candidate.motion_rows == pending_candidate.motion_rows - }) - } - - fn should_fail_closed_committed_followup_after_extreme_preview_local_tail_block( - &self, - pending_preview_only_local_candidate: Option, - candidates: &[DownwardViewportCandidate], - ) -> bool { - let Some(pending_candidate) = pending_preview_only_local_candidate else { - return false; - }; - - if pending_candidate.source != DownwardViewportCandidateSource::PreviewOnlyLocalSample { - return false; - } - - self.transient_burst_search_enabled - && candidates.iter().all(|candidate| { - candidate.source == DownwardViewportCandidateSource::CommittedKeyframe - }) && candidates.iter().any(|candidate| { - candidate.viewport_top_y == pending_candidate.viewport_top_y - && candidate.motion_rows == pending_candidate.motion_rows - }) - } - - fn suppressed_huge_preview_only_local_followup_block_budget( - &self, - candidate: Option, - ) -> u8 { - let Some(candidate) = candidate else { - return 3; - }; - let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { - return 3; - }; - - if candidate.source != DownwardViewportCandidateSource::PreviewOnlyLocalSample { - return 3; - } - - let continuity_rows = last_motion_rows_hint.max(1); - let far_recovery_ratio = - candidate.motion_rows.saturating_add(continuity_rows.saturating_sub(1)) - / continuity_rows; - - u8::try_from(far_recovery_ratio.clamp(3, 5)).unwrap_or(5) - } - - fn restore_corroborated_observed_candidate( - &self, - suppressed_observed_candidate: Option, - candidates: &mut Vec, - ) { - let Some(candidate) = suppressed_observed_candidate else { - return; - }; - - if !self.observed_candidate_can_recover_from_committed_corroboration(candidate) { - return; - } - if candidates.iter().any(|other| { - other.source == DownwardViewportCandidateSource::CommittedKeyframe - && other.viewport_top_y == candidate.viewport_top_y - && other.motion_rows == candidate.motion_rows - }) { - candidates.push(candidate); - } - } - - fn observed_candidate_can_recover_from_committed_corroboration( - &self, - candidate: DownwardViewportCandidate, - ) -> bool { - if candidate.source != DownwardViewportCandidateSource::ObservedSample { - return false; - } - - let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { - return false; - }; - let corroboration_cap = - last_motion_rows_hint.saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS); - - self.growth_rows_for_candidate_viewport_top_y(candidate.viewport_top_y) <= corroboration_cap - } - - fn restore_repeated_small_preview_only_local_candidate_after_empty_prune( - &mut self, - preview_only_local_candidate_before_prune: Option, - candidates_after_prune: &mut Vec, - ) { - let Some(candidate) = preview_only_local_candidate_before_prune else { - self.last_blocked_preview_only_local_candidate = None; - - return; - }; - - if candidate.source != DownwardViewportCandidateSource::PreviewOnlyLocalSample - || !candidates_after_prune.is_empty() - || !self.repeated_preview_only_local_candidate_can_restore_after_empty_prune(candidate) - { - self.last_blocked_preview_only_local_candidate = None; - - return; - } - - let repeats = match self.last_blocked_preview_only_local_candidate { - Some(previous) if previous.candidate == candidate => previous.repeats.saturating_add(1), - _ => 1, - }; - - self.last_blocked_preview_only_local_candidate = - Some(BlockedPreviewOnlyLocalCandidate { candidate, repeats }); - - if repeats >= 2 { - candidates_after_prune.push(candidate); - - self.last_blocked_preview_only_local_candidate = None; - } - } - - fn repeated_preview_only_local_candidate_can_restore_after_empty_prune( - &self, - candidate: DownwardViewportCandidate, - ) -> bool { - candidate.motion_rows <= REPEATED_PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS - && self.transient_burst_search_enabled - && self.transient_burst_motion_hint_exceeds_local_authority(candidate.motion_rows) - && self.last_motion_rows_hint.is_some() - } - - fn should_fail_closed_lagging_exactly_corroborated_preview_local_tail_in_burst( - &self, - candidates_after_prune: &[DownwardViewportCandidate], - ) -> bool { - if !self.transient_burst_search_enabled { - return false; - } - - let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { - return false; - }; - let Some(preview_only_local_candidate) = - candidates_after_prune.iter().copied().find(|candidate| { - candidate.source == DownwardViewportCandidateSource::PreviewOnlyLocalSample - }) - else { - return false; - }; - - preview_only_local_candidate.motion_rows - <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS.saturating_div(2) - && preview_only_local_candidate.motion_rows - < last_motion_rows_hint - .saturating_sub(UNDERCONSUMED_OBSERVED_BURST_RECOVERY_GAP_ROWS) - && candidates_after_prune.iter().any(|candidate| { - candidate.source == DownwardViewportCandidateSource::CommittedKeyframe - && candidate.viewport_top_y == preview_only_local_candidate.viewport_top_y - && candidate.motion_rows == preview_only_local_candidate.motion_rows - && candidate.mean_abs_diff_x100 - <= preview_only_local_candidate - .mean_abs_diff_x100 - .saturating_add(DIRECTION_WARNING_MARGIN_X100) - }) - } - - fn should_fail_closed_underconsumed_observed_recovery_in_burst( - &self, - candidates_before_prune: &[DownwardViewportCandidate], - candidates_after_prune: &[DownwardViewportCandidate], - ) -> bool { - let Some(observed_candidate) = candidates_after_prune - .iter() - .copied() - .find(|candidate| candidate.source == DownwardViewportCandidateSource::ObservedSample) - else { - return false; - }; - let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { - return false; - }; - - if self.last_preview_only_downward_local_sample.is_some() - || !self - .transient_burst_motion_hint_exceeds_local_authority(observed_candidate.motion_rows) - || last_motion_rows_hint - < observed_candidate - .motion_rows - .saturating_add(UNDERCONSUMED_OBSERVED_BURST_RECOVERY_GAP_ROWS) - { - return false; - } - - let has_same_motion_committed_corroboration = - candidates_after_prune.iter().any(|candidate| { - candidate.source == DownwardViewportCandidateSource::CommittedKeyframe - && candidate.viewport_top_y == observed_candidate.viewport_top_y - && candidate.motion_rows == observed_candidate.motion_rows - }); - - if !has_same_motion_committed_corroboration { - return false; - } - - candidates_before_prune.iter().any(|candidate| { - candidate.source == DownwardViewportCandidateSource::CommittedKeyframe - && candidate.motion_rows > observed_candidate.motion_rows - && candidate.motion_rows >= last_motion_rows_hint - && candidate.viewport_top_y >= observed_candidate.viewport_top_y - && candidate.viewport_top_y.abs_diff(observed_candidate.viewport_top_y) - <= DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS - && candidate.mean_abs_diff_x100 - <= observed_candidate - .mean_abs_diff_x100 - .saturating_add(DIRECTION_WARNING_MARGIN_X100) - }) - } - - fn prune_committed_keyframe_candidates_outside_local_continuity( - &mut self, - candidates: &mut Vec, - ) { - let has_committed_candidate = candidates.iter().any(|candidate| { - candidate.source == DownwardViewportCandidateSource::CommittedKeyframe - }); - let mut local_anchor = best_local_downward_viewport_candidate(candidates); - - if local_anchor.is_some_and(|anchor| { - has_committed_candidate - && anchor.source == DownwardViewportCandidateSource::PreviewOnlyLocalSample - && self.transient_burst_motion_hint_exceeds_local_authority(anchor.motion_rows) - && !self - .preview_only_local_anchor_has_exact_committed_corroboration(anchor, candidates) - && !self.preview_only_local_candidate_has_material_progress(anchor) - && ((anchor.motion_rows <= TINY_PREVIEW_ONLY_LOCAL_BURST_RECOVERY_MAX_MOTION_ROWS - && self.consecutive_transient_burst_missing_downward_candidate_frames < 2) - || candidates.iter().any(|candidate| { - self.committed_candidate_can_plausibly_replace_underconsumed_preview_local_anchor( - anchor, - *candidate, - ) - })) - }) { - candidates.retain(|candidate| { - candidate.source != DownwardViewportCandidateSource::PreviewOnlyLocalSample - }); - - local_anchor = best_local_downward_viewport_candidate(candidates); - } - - let Some(local_anchor) = local_anchor else { - candidates.retain(|candidate| { - candidate.source != DownwardViewportCandidateSource::CommittedKeyframe - || !self.transient_burst_search_enabled - || !self.fallback_downward_growth_exceeds_continuity_budget( - candidate.viewport_top_y, - ) || self.transient_burst_growth_matches_pending_hint_band(candidate.viewport_top_y) - || self.growth_rows_for_candidate_viewport_top_y(candidate.viewport_top_y) - <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS - }); - - if let Some(max_bootstrap_growth_rows) = - self.bootstrap_committed_keyframe_growth_cap_rows() - { - candidates.retain(|candidate| { - candidate.source != DownwardViewportCandidateSource::CommittedKeyframe - || self.growth_rows_for_candidate_viewport_top_y(candidate.viewport_top_y) - <= max_bootstrap_growth_rows - }); - } - - self.prune_committed_keyframe_candidates_without_local_anchor(candidates); - - return; - }; - let allowed_overrun_rows = self - .max_committed_keyframe_local_overrun_rows(local_anchor) - .max(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS); - let max_allowed_motion_rows = self.max_committed_keyframe_motion_rows(local_anchor); - let max_allowed_viewport_top_y = local_anchor - .viewport_top_y - .saturating_add(i32::try_from(allowed_overrun_rows).unwrap_or(i32::MAX)); - let local_observed_has_same_motion_committed_corroboration = local_anchor.source - == DownwardViewportCandidateSource::ObservedSample - && candidates.iter().any(|candidate| { - candidate.source == DownwardViewportCandidateSource::CommittedKeyframe - && candidate.viewport_top_y == local_anchor.viewport_top_y - && candidate.motion_rows == local_anchor.motion_rows - }); - - candidates.retain(|candidate| { - candidate.source != DownwardViewportCandidateSource::CommittedKeyframe - || (candidate.viewport_top_y <= max_allowed_viewport_top_y - && candidate.motion_rows <= max_allowed_motion_rows) - || (!local_observed_has_same_motion_committed_corroboration - && self.committed_candidate_can_override_untrustworthy_observed_local_recovery( - local_anchor, - *candidate, - )) - }); - self.prune_committed_keyframe_candidates_for_transient_burst(candidates); - } - - fn preview_only_local_anchor_has_exact_committed_corroboration( - &self, - local_anchor: DownwardViewportCandidate, - candidates: &[DownwardViewportCandidate], - ) -> bool { - local_anchor.source == DownwardViewportCandidateSource::PreviewOnlyLocalSample - && candidates.iter().any(|candidate| { - candidate.source == DownwardViewportCandidateSource::CommittedKeyframe - && candidate.viewport_top_y == local_anchor.viewport_top_y - && candidate.motion_rows == local_anchor.motion_rows - && candidate.mean_abs_diff_x100 - <= local_anchor - .mean_abs_diff_x100 - .saturating_add(DIRECTION_WARNING_MARGIN_X100) - }) - } - - fn prune_committed_keyframe_candidates_without_local_anchor( - &mut self, - candidates: &mut Vec, - ) { - if !candidates - .iter() - .all(|candidate| candidate.source == DownwardViewportCandidateSource::CommittedKeyframe) - { - return; - } - - let Some(preferred) = candidates.iter().copied().min_by(|left, right| { - left.motion_rows - .cmp(&right.motion_rows) - .then(left.mean_abs_diff_x100.cmp(&right.mean_abs_diff_x100)) - .then(left.viewport_top_y.cmp(&right.viewport_top_y)) - }) else { - return; - }; - - if self.should_fail_closed_far_committed_only_recovery_without_local_anchor( - preferred, candidates, - ) { - if self - .should_fail_closed_far_committed_only_recovery_after_corroborated_huge_local_jump( - preferred, - self.growth_rows_for_candidate_viewport_top_y(preferred.viewport_top_y), - ) { - self.blocked_far_committed_only_recovery_after_corroborated_huge_local_jump = true; - } - - candidates.clear(); - - return; - } - - candidates.retain(|candidate| *candidate == preferred); - } - - fn should_fail_closed_far_committed_only_recovery_without_local_anchor( - &self, - preferred: DownwardViewportCandidate, - candidates: &[DownwardViewportCandidate], - ) -> bool { - let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { - return false; - }; - - if !self.transient_burst_search_enabled { - return false; - } - - let preferred_growth_rows = - self.growth_rows_for_candidate_viewport_top_y(preferred.viewport_top_y); - - if self - .should_fail_closed_underconsumed_committed_only_recovery_after_suppressed_preview_local_match( - preferred, - preferred_growth_rows, - ) { - return true; - } - if self - .should_fail_closed_committed_only_recovery_after_corroborated_sample_registration_without_viewport_anchor( - preferred, - preferred_growth_rows, - ) - { - return true; - } - if self - .should_fail_closed_committed_only_recovery_when_observed_burst_outpaces_recent_preview_local_commit( - preferred, - preferred_growth_rows, - ) - { - return true; - } - if self.should_fail_closed_far_committed_only_recovery_after_corroborated_huge_local_jump( - preferred, - preferred_growth_rows, - ) { - return true; - } - if self.last_preview_only_downward_local_sample.is_some() - && self.last_preview_only_local_registration_result == Some("matched") - && last_motion_rows_hint <= DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS - && self.last_preview_only_local_registration_motion_rows.is_some_and( - |local_motion_rows| { - local_motion_rows - <= last_motion_rows_hint - .saturating_add(PREVIEW_ONLY_LOCAL_NEAR_CONTINUITY_ROWS) - && preferred_growth_rows - > local_motion_rows.saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS) - }, - ) { - return true; - } - if last_motion_rows_hint > DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS.saturating_mul(2) { - let all_candidates_low_confidence = candidates.iter().all(|candidate| { - candidate.mean_abs_diff_x100 > DIRECTION_WARNING_MARGIN_X100.saturating_mul(4) - }); - - return preferred_growth_rows <= last_motion_rows_hint && all_candidates_low_confidence; - } - - let far_growth_threshold = PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS - .max(last_motion_rows_hint.saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS)); - - self.growth_rows_for_candidate_viewport_top_y(preferred.viewport_top_y) - > far_growth_threshold - && candidates.iter().all(|candidate| { - self.growth_rows_for_candidate_viewport_top_y(candidate.viewport_top_y) - > far_growth_threshold - }) - } - - fn should_fail_closed_far_committed_only_recovery_after_corroborated_huge_local_jump( - &self, - preferred: DownwardViewportCandidate, - preferred_growth_rows: u32, - ) -> bool { - let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { - return false; - }; - - if preferred.source != DownwardViewportCandidateSource::CommittedKeyframe { - return false; - } - - let large_far_recovery_threshold = last_motion_rows_hint - .saturating_mul(3) - .max(PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS.saturating_mul(2)); - let observed_material_lag_threshold = PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS - .max(last_motion_rows_hint.saturating_mul(2)); - let observed_corroborates_or_materially_lags = - self.last_observed_sample_registration_result == Some("matched") - && self.last_observed_sample_registration_motion_rows.is_some_and( - |observed_motion_rows| { - observed_motion_rows == preferred.motion_rows - || observed_motion_rows.saturating_add(observed_material_lag_threshold) - < preferred.motion_rows - }, - ); - - self.transient_burst_search_enabled - && self.last_preview_only_local_registration_result == Some("matched") - && self.last_preview_only_local_registration_motion_rows == Some(preferred.motion_rows) - && observed_corroborates_or_materially_lags - && preferred.motion_rows > large_far_recovery_threshold - && preferred_growth_rows > large_far_recovery_threshold - && self.growth_history.last().is_some_and(|commit| { - commit.decision_source - == DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source() - && commit.growth_rows - >= last_motion_rows_hint - .saturating_sub(PREVIEW_ONLY_LOCAL_NEAR_CONTINUITY_ROWS) - }) - } - - fn should_fail_closed_underconsumed_committed_only_recovery_after_suppressed_preview_local_match( - &self, - preferred: DownwardViewportCandidate, - preferred_growth_rows: u32, - ) -> bool { - let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { - return false; - }; - let Some(local_motion_rows) = self.last_preview_only_local_registration_motion_rows else { - return false; - }; - - self.last_preview_only_downward_local_sample.is_some() - && self.last_preview_only_local_registration_result == Some("matched") - && self.transient_burst_motion_hint_exceeds_local_authority(preferred.motion_rows) - && !self.transient_burst_growth_matches_pending_hint_band(preferred.viewport_top_y) - && local_motion_rows > PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS - && local_motion_rows - > preferred - .motion_rows - .saturating_add(UNDERCONSUMED_OBSERVED_BURST_RECOVERY_GAP_ROWS) - && preferred_growth_rows - <= last_motion_rows_hint - .saturating_mul(2) - .max(PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS) - } - - fn should_fail_closed_committed_only_recovery_after_corroborated_sample_registration_without_viewport_anchor( - &self, - preferred: DownwardViewportCandidate, - preferred_growth_rows: u32, - ) -> bool { - let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { - return false; - }; - let Some(observed_motion_rows) = self.last_observed_sample_registration_motion_rows else { - return false; - }; - let Some(local_motion_rows) = self.last_preview_only_local_registration_motion_rows else { - return false; - }; - let corroborated_motion_floor = - last_motion_rows_hint.saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS); - let corroborated_motion_ceiling = observed_motion_rows - .max(local_motion_rows) - .saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS); - - preferred.source == DownwardViewportCandidateSource::CommittedKeyframe - && self.transient_burst_search_enabled - && self.last_preview_only_downward_local_sample.is_some() - && self.last_observed_sample_registration_result == Some("matched") - && self.last_preview_only_local_registration_result == Some("matched") - && last_motion_rows_hint <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS - && observed_motion_rows > corroborated_motion_floor - && local_motion_rows > corroborated_motion_floor - && preferred_growth_rows > corroborated_motion_floor - && preferred.motion_rows >= local_motion_rows - && preferred_growth_rows <= corroborated_motion_ceiling - } - - fn should_fail_closed_committed_only_recovery_when_observed_burst_outpaces_recent_preview_local_commit( - &self, - preferred: DownwardViewportCandidate, - preferred_growth_rows: u32, - ) -> bool { - let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { - return false; - }; - let Some(observed_motion_rows) = self.last_observed_sample_registration_motion_rows else { - return false; - }; - let recent_preview_local_commit = self.growth_history.last().is_some_and(|commit| { - commit.decision_source - == DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source() - && commit.growth_rows - >= last_motion_rows_hint.saturating_sub(PREVIEW_ONLY_LOCAL_NEAR_CONTINUITY_ROWS) - }); - let corroborated_motion_floor = - last_motion_rows_hint.saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS); - - preferred.source == DownwardViewportCandidateSource::CommittedKeyframe - && self.transient_burst_search_enabled - && recent_preview_local_commit - && self.last_observed_sample_registration_result == Some("matched") - && self.last_preview_only_local_registration_result == Some("no_match") - && last_motion_rows_hint <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS - && observed_motion_rows > corroborated_motion_floor - && preferred_growth_rows > corroborated_motion_floor - && preferred.motion_rows.saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS) - < observed_motion_rows - } - - fn should_suppress_preview_only_local_candidate( - &self, - candidate: DownwardViewportCandidate, - ) -> bool { - candidate.source == DownwardViewportCandidateSource::PreviewOnlyLocalSample - && self.transient_burst_motion_hint_exceeds_local_authority(candidate.motion_rows) - && candidate.motion_rows > PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS - && !self.preview_only_local_candidate_remains_trustworthy_in_burst(candidate) - } - - fn should_suppress_observed_sample_candidate( - &self, - candidate: DownwardViewportCandidate, - ) -> bool { - candidate.source == DownwardViewportCandidateSource::ObservedSample - && self.transient_burst_search_enabled - && self.fallback_downward_growth_exceeds_continuity_budget(candidate.viewport_top_y) - && !self.observed_sample_candidate_remains_trustworthy_in_burst(candidate) - } - - fn observed_sample_candidate_remains_trustworthy_in_burst( - &self, - candidate: DownwardViewportCandidate, - ) -> bool { - if candidate.source != DownwardViewportCandidateSource::ObservedSample { - return false; - } - - let growth_rows = self.growth_rows_for_candidate_viewport_top_y(candidate.viewport_top_y); - - self.transient_burst_motion_hint_exceeds_local_authority(candidate.motion_rows) - && self.last_motion_rows_hint.is_some_and(|last_hint| { - candidate.motion_rows.saturating_add(UNDERCONSUMED_OBSERVED_BURST_RECOVERY_GAP_ROWS) - >= last_hint && candidate.motion_rows <= last_hint - }) && candidate.mean_abs_diff_x100 <= DIRECTION_WARNING_MARGIN_X100.saturating_mul(6) - && self.transient_pending_growth_cap_rows().is_some_and(|cap| growth_rows <= cap) - } - - fn preview_only_local_candidate_has_material_progress( - &self, - candidate: DownwardViewportCandidate, - ) -> bool { - if self.seeded_preview_only_local_catch_up_candidate_can_commit(candidate) { - return true; - } - - candidate.source == DownwardViewportCandidateSource::PreviewOnlyLocalSample && { - let growth_rows = - self.growth_rows_for_candidate_viewport_top_y(candidate.viewport_top_y); - - growth_rows >= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS - || self.last_motion_rows_hint.is_some_and(|last_hint| { - last_hint >= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS - && growth_rows.saturating_add(PREVIEW_ONLY_LOCAL_NEAR_CONTINUITY_ROWS) - >= last_hint - }) || self.last_motion_rows_hint.is_some_and(|last_hint| { - last_hint >= DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS - && candidate.motion_rows.abs_diff(last_hint) - <= PREVIEW_ONLY_LOCAL_NEAR_CONTINUITY_ROWS - }) - } - } - - fn preview_only_local_candidate_remains_trustworthy_in_burst( - &self, - candidate: DownwardViewportCandidate, - ) -> bool { - if candidate.source != DownwardViewportCandidateSource::PreviewOnlyLocalSample { - return true; - } - if candidate.motion_rows <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS { - return true; - } - - self.transient_burst_growth_matches_pending_hint_band(candidate.viewport_top_y) - && self.last_motion_rows_hint.is_some_and(|last_hint| { - candidate.motion_rows - <= last_hint.saturating_add(PREVIEW_ONLY_LOCAL_RECOVERY_MAX_TOLERANCE_ROWS) - }) - } - - fn seeded_preview_only_local_catch_up_candidate_can_commit( - &self, - candidate: DownwardViewportCandidate, - ) -> bool { - candidate.source == DownwardViewportCandidateSource::PreviewOnlyLocalSample - && self.seeded_preview_only_local_after_observed_burst_commit - && candidate.viewport_top_y > self.current_viewport_top_y - && candidate.motion_rows <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS - } - - fn prune_committed_keyframe_candidates_for_transient_burst( - &mut self, - candidates: &mut Vec, - ) { - if !self.transient_burst_search_enabled { - return; - } - - let Some(local_candidate) = candidates - .iter() - .copied() - .filter(|candidate| candidate.source == DownwardViewportCandidateSource::ObservedSample) - .min_by(|left, right| { - left.mean_abs_diff_x100 - .cmp(&right.mean_abs_diff_x100) - .then(left.motion_rows.cmp(&right.motion_rows)) - }) - else { - return; - }; - let Some(previous_growth_rows) = self.last_motion_rows_hint else { - return; - }; - - if local_candidate.motion_rows <= previous_growth_rows { - return; - } - - candidates.retain(|candidate| { - candidate.source != DownwardViewportCandidateSource::CommittedKeyframe - || candidate.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) - < local_candidate.mean_abs_diff_x100 - }); - } - - fn max_committed_keyframe_local_overrun_rows( - &self, - local_anchor: DownwardViewportCandidate, - ) -> u32 { - self.max_committed_keyframe_motion_rows(local_anchor).clamp( - DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS, - DOWNWARD_COMMITTED_KEYFRAME_LOCAL_OVERRUN_MAX_ROWS, - ) - } - - fn max_committed_keyframe_motion_rows(&self, local_anchor: DownwardViewportCandidate) -> u32 { - let continuity_rows = self - .last_motion_rows_hint - .unwrap_or(local_anchor.motion_rows) - .max(local_anchor.motion_rows); - let tolerance_rows = (continuity_rows / 2).clamp(1, DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS); - - continuity_rows.saturating_add(tolerance_rows) - } - - fn committed_candidate_can_plausibly_replace_underconsumed_preview_local_anchor( - &self, - local_anchor: DownwardViewportCandidate, - committed_candidate: DownwardViewportCandidate, - ) -> bool { - if committed_candidate.source != DownwardViewportCandidateSource::CommittedKeyframe { - return false; - } - - let allowed_overrun_rows = self - .max_committed_keyframe_local_overrun_rows(local_anchor) - .max(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS); - let max_allowed_motion_rows = self.max_committed_keyframe_motion_rows(local_anchor); - let max_allowed_viewport_top_y = local_anchor - .viewport_top_y - .saturating_add(i32::try_from(allowed_overrun_rows).unwrap_or(i32::MAX)); - let local_anchor_tracks_recent_continuity = self - .last_motion_rows_hint - .is_some_and(|last_hint| local_anchor.motion_rows >= last_hint); - let committed_is_not_materially_worse_than_local_anchor = committed_candidate - .mean_abs_diff_x100 - <= local_anchor.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100); - - (committed_candidate.viewport_top_y <= max_allowed_viewport_top_y - && committed_candidate.motion_rows <= max_allowed_motion_rows) - && (!local_anchor_tracks_recent_continuity - || committed_is_not_materially_worse_than_local_anchor) - || self.transient_burst_growth_matches_pending_hint_band( - committed_candidate.viewport_top_y, - ) || self.committed_candidate_can_override_untrustworthy_observed_local_recovery( - local_anchor, - committed_candidate, - ) - } - - fn committed_candidate_can_override_untrustworthy_observed_local_recovery( - &self, - local_anchor: DownwardViewportCandidate, - committed_candidate: DownwardViewportCandidate, - ) -> bool { - let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { - return false; - }; - let Some(transient_growth_cap_rows) = self.transient_pending_growth_cap_rows() else { - return false; - }; - - if committed_candidate.source != DownwardViewportCandidateSource::CommittedKeyframe { - return false; - } - - let local_growth_rows = - self.growth_rows_for_candidate_viewport_top_y(local_anchor.viewport_top_y); - let committed_growth_rows = - self.growth_rows_for_candidate_viewport_top_y(committed_candidate.viewport_top_y); - - local_anchor.source == DownwardViewportCandidateSource::ObservedSample - && self.transient_burst_motion_hint_exceeds_local_authority(local_anchor.motion_rows) - && local_anchor.mean_abs_diff_x100 > DIRECTION_WARNING_MARGIN_X100.saturating_mul(4) - && local_anchor.motion_rows - <= last_motion_rows_hint.saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS) - && (committed_growth_rows - <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS.saturating_mul(2) - || self.transient_burst_growth_matches_pending_hint_band( - committed_candidate.viewport_top_y, - )) && committed_candidate.mean_abs_diff_x100 - <= DIRECTION_WARNING_MARGIN_X100.saturating_mul(2) - && committed_candidate - .mean_abs_diff_x100 - .saturating_add(DIRECTION_WARNING_MARGIN_X100.saturating_mul(3)) - < local_anchor.mean_abs_diff_x100 - && committed_candidate.motion_rows - > local_anchor - .motion_rows - .saturating_add(UNDERCONSUMED_OBSERVED_BURST_RECOVERY_GAP_ROWS) - && committed_growth_rows - > local_growth_rows.saturating_add(UNDERCONSUMED_OBSERVED_BURST_RECOVERY_GAP_ROWS) - && committed_growth_rows <= transient_growth_cap_rows - } - - fn bootstrap_committed_keyframe_growth_cap_rows(&self) -> Option { - if !self.initial_downward_bootstrap_active() { - return None; - } - - self.transient_pending_growth_cap_rows() - } - - fn transient_pending_growth_cap_rows(&self) -> Option { - let hint = self.normalized_transient_motion_rows_hint()?; - let tolerance = (hint / 2).clamp(1, PREVIEW_ONLY_LOCAL_RECOVERY_MAX_TOLERANCE_ROWS); - - Some(hint.saturating_add(tolerance)) - } - - fn transient_burst_growth_matches_pending_hint_band( - &self, - candidate_viewport_top_y: i32, - ) -> bool { - if !self.transient_burst_search_enabled { - return false; - } - - let Some(transient_hint) = self.normalized_transient_motion_rows_hint() else { - return false; - }; - let growth_rows = self.growth_rows_for_candidate_viewport_top_y(candidate_viewport_top_y); - let min_growth_rows = - (transient_hint / 2).max(self.last_motion_rows_hint.unwrap_or_default()); - - self.transient_pending_growth_cap_rows() - .is_some_and(|cap| growth_rows >= min_growth_rows && growth_rows <= cap) - } - - fn collect_committed_downward_viewport_candidates( - &self, - frame: &RgbaImage, - candidates: &mut Vec, - ) { - self.collect_committed_downward_viewport_candidates_with_mode( - frame, - candidates, - CommittedDownwardViewportCandidateMode::IncludeRecentHistory, - ); - } - - fn collect_fallback_downward_viewport_candidates( - &self, - frame: &RgbaImage, - candidates: &mut Vec, - ) { - self.collect_committed_downward_viewport_candidates_with_mode( - frame, - candidates, - CommittedDownwardViewportCandidateMode::LastCommittedOnly, - ); - } - - fn collect_committed_downward_viewport_candidates_with_mode( - &self, - frame: &RgbaImage, - candidates: &mut Vec, - mode: CommittedDownwardViewportCandidateMode, - ) { - self.push_downward_viewport_candidate( - &self.last_committed_frame, - self.current_viewport_top_y, - frame, - DownwardViewportCandidateSource::CommittedKeyframe, - candidates, - ); - - if mode == CommittedDownwardViewportCandidateMode::LastCommittedOnly - || DOWNWARD_KEYFRAME_SEARCH_LIMIT <= 1 - { - return; - } - - for commit in self - .growth_history - .iter() - .rev() - .skip(1) - .take(DOWNWARD_KEYFRAME_SEARCH_LIMIT.saturating_sub(1)) - { - self.push_downward_viewport_candidate( - &commit.frame, - commit.viewport_top_y, - frame, - DownwardViewportCandidateSource::CommittedKeyframe, - candidates, - ); - } - } - - fn push_downward_viewport_candidate( - &self, - reference: &RgbaImage, - reference_viewport_top_y: i32, - frame: &RgbaImage, - source: DownwardViewportCandidateSource, - candidates: &mut Vec, - ) { - let predicted_motion_rows = self.downward_keyframe_motion_hint(reference_viewport_top_y); - let allow_full_range_fallback = - !(self.initial_downward_bootstrap_active() && predicted_motion_rows.is_none()); - let mut registration = self.evaluate_reference_downward_registration( - reference, - frame, - predicted_motion_rows, - allow_full_range_fallback, - ); - - if source == DownwardViewportCandidateSource::CommittedKeyframe - && self.should_retry_committed_keyframe_registration_across_full_range(registration) - { - let full_range_registration = self - .evaluate_reference_downward_registration_with_preferred_range( - reference, - frame, - predicted_motion_rows, - None, - true, - ); - - registration = self.prefer_full_range_committed_keyframe_registration( - registration, - full_range_registration, - ); - } - - if let DownwardRegistration::Matched(matched) = registration { - if self.bootstrap_motion_exceeds_pending_hint(matched.motion_rows) { - return; - } - - let max_overlap = reference.height().min(frame.height()); - let min_keyframe_overlap_rows = OverlapSearchConfig::default() - .min_overlap_rows - .max(max_overlap / DOWNWARD_KEYFRAME_MIN_OVERLAP_DIVISOR) - .max(1); - let overlap_rows = max_overlap.saturating_sub(matched.motion_rows); - - if overlap_rows < min_keyframe_overlap_rows { - return; - } - - let viewport_top_y = reference_viewport_top_y - .saturating_add(i32::try_from(matched.motion_rows).unwrap_or_default()); - - if viewport_top_y <= self.current_viewport_top_y { - return; - } - - candidates.push(DownwardViewportCandidate { - source, - viewport_top_y, - motion_rows: matched.motion_rows, - mean_abs_diff_x100: matched.mean_abs_diff_x100, - }); - } - } - - fn should_retry_committed_keyframe_registration_across_full_range( - &self, - registration: DownwardRegistration, - ) -> bool { - let DownwardRegistration::Matched(matched) = registration else { - return false; - }; - let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { - return false; - }; - let low_confidence_match = - matched.mean_abs_diff_x100 > DIRECTION_WARNING_MARGIN_X100.saturating_mul(4); - let tiny_underconsumed_match = self - .transient_burst_motion_hint_exceeds_local_authority(matched.motion_rows) - && matched.mean_abs_diff_x100 > DIRECTION_WARNING_MARGIN_X100.saturating_mul(4) - && matched.motion_rows - <= last_motion_rows_hint.saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS); - let large_overshot_match = matched.motion_rows > last_motion_rows_hint.saturating_mul(2); - - low_confidence_match && (tiny_underconsumed_match || large_overshot_match) - } - - fn prefer_full_range_committed_keyframe_registration( - &self, - preferred_range_registration: DownwardRegistration, - full_range_registration: DownwardRegistration, - ) -> DownwardRegistration { - match (preferred_range_registration, full_range_registration) { - (DownwardRegistration::Matched(preferred), DownwardRegistration::Matched(full)) - if full.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) - < preferred.mean_abs_diff_x100 - && preferred.motion_rows.abs_diff(full.motion_rows) - > UNDERCONSUMED_OBSERVED_BURST_RECOVERY_GAP_ROWS => - { - DownwardRegistration::Matched(full) - }, - (preferred, _) => preferred, - } - } - - fn downward_keyframe_motion_hint(&self, reference_viewport_top_y: i32) -> Option { - let last_motion_rows = self.last_motion_rows_hint?; - let already_traversed_rows = u32::try_from( - self.current_viewport_top_y.saturating_sub(reference_viewport_top_y).max(0), - ) - .unwrap_or_default(); - - Some(already_traversed_rows.saturating_add(last_motion_rows)) - } - - fn fallback_downward_growth_blocked_while_resume_frontier_active( - &mut self, - candidate_viewport_top_y: i32, - motion_rows: u32, - preview_changed: bool, - decision_source: &'static str, - ) -> Option { - let resume_frontier_top_y = self.resume_frontier_top_y?; - let growth_rows = if candidate_viewport_top_y <= resume_frontier_top_y { - 0 - } else { - u32::try_from(candidate_viewport_top_y - resume_frontier_top_y).unwrap_or_default() - }; - - self.log_decision( - "scroll_capture.fallback_downward_blocked_while_resume_frontier_active", - ScrollDirection::Down, - Some(MotionObservation { direction: ScrollDirection::Down, motion_rows }), - Some(candidate_viewport_top_y), - Some(growth_rows), - Some(decision_source), - ); - - Some(preview_update_outcome(preview_changed)) - } - - fn fallback_downward_growth_exceeds_continuity_budget( - &self, - candidate_viewport_top_y: i32, - ) -> bool { - let growth_rows = self.growth_rows_for_candidate_viewport_top_y(candidate_viewport_top_y); - let Some(base_continuity_rows) = self.last_motion_rows_hint else { - return false; - }; - let local_overrun_rows = base_continuity_rows - .saturating_mul(2) - .clamp(FALLBACK_DOWNWARD_GROWTH_MIN_ROWS, FALLBACK_DOWNWARD_GROWTH_MAX_ROWS); - let preview_local_rows = self - .last_preview_only_downward_local_sample - .as_ref() - .map(|sample| { - u32::try_from( - sample.viewport_top_y.saturating_sub(self.current_viewport_top_y).max(0), - ) - .unwrap_or_default() - }) - .unwrap_or_default(); - let max_growth_rows = preview_local_rows.saturating_add(local_overrun_rows); - - growth_rows > max_growth_rows - } - - fn observe_fallback_downward_growth( - &mut self, - frame: RgbaImage, - preview_changed: bool, - ) -> Result { - let mut candidates = Vec::with_capacity(DOWNWARD_KEYFRAME_SEARCH_LIMIT); - - self.collect_fallback_downward_viewport_candidates(&frame, &mut candidates); - - match select_downward_viewport_candidate(&mut candidates) { - DownwardViewportResolution::NoMatch => { - self.refresh_preview_only_downward_local_sample( - &frame, - self.stable_preview_only_downward_local_viewport_top_y(), - ); - self.log_decision( - "scroll_capture.fallback_downward_no_match", - ScrollDirection::Down, - None, - None, - Some(0), - Some("no_committed_keyframe_match"), - ); - - Ok(preview_update_outcome(preview_changed)) - }, - DownwardViewportResolution::Selected(candidate) => { - if self.fallback_downward_growth_exceeds_continuity_budget(candidate.viewport_top_y) - { - self.refresh_preview_only_downward_local_sample( - &frame, - self.stable_preview_only_downward_local_viewport_top_y(), - ); - self.log_decision( - "scroll_capture.fallback_downward_growth_blocked", - ScrollDirection::Down, - Some(MotionObservation { - direction: ScrollDirection::Down, - motion_rows: candidate.motion_rows, - }), - Some(candidate.viewport_top_y), - Some( - self.growth_rows_for_candidate_viewport_top_y(candidate.viewport_top_y), - ), - Some("fallback_committed_candidate_exceeded_local_continuity_budget"), - ); - - return Ok(preview_update_outcome(preview_changed)); - } - - if let Some(outcome) = self - .fallback_downward_growth_blocked_while_resume_frontier_active( - candidate.viewport_top_y, - candidate.motion_rows, - preview_changed, - "resume_frontier_active_blocks_keyframe_fallback_downward_match", - ) { - return Ok(outcome); - } - - self.observe_downward_growth_to_viewport( - frame, - candidate.viewport_top_y, - preview_changed, - Some(MotionObservation { - direction: ScrollDirection::Down, - motion_rows: candidate.motion_rows, - }), - candidate.source.fallback_decision_source(), - ) - }, - DownwardViewportResolution::Ambiguous { preferred, competing } => { - self.refresh_preview_only_downward_local_sample( - &frame, - self.stable_preview_only_downward_local_viewport_top_y(), - ); - self.log_decision( - "scroll_capture.fallback_ambiguous_downward_registration", - ScrollDirection::Down, - Some(MotionObservation { - direction: ScrollDirection::Down, - motion_rows: preferred.motion_rows, - }), - Some(preferred.viewport_top_y), - Some(0), - Some(preferred.competing_block_reason(competing)), - ); - - Ok(preview_update_outcome(preview_changed)) - }, - } - } - - #[allow(clippy::too_many_arguments)] - fn apply_growth( - &mut self, - frame: RgbaImage, - growth_rows: u32, - viewport_top_y: i32, - decision_source: &'static str, - detected_motion_rows: Option, - effective_motion_rows_hint: Option, - previous_motion_rows_hint: Option, - ) -> Result { - let fingerprint = scroll_capture_fingerprint(&frame); - let strip = crop_bottom_rows(&frame, growth_rows) - .ok_or_else(|| eyre::eyre!("failed to extract growth strip"))?; - let preview_strip = resize_strip_to_preview_width(&strip, self.preview_width_px); - - self.export_image = append_vertical_image(&self.export_image, &strip)?; - self.preview_image = append_vertical_image(&self.preview_image, &preview_strip)?; - - self.bottom_segments.push(strip); - self.bottom_preview_segments.push(preview_strip); - - self.current_viewport_top_y = viewport_top_y; - self.observed_viewport_top_y = viewport_top_y; - - self.record_last_sample(&frame, fingerprint); - self.record_last_downward_observed_sample(&frame, scroll_capture_fingerprint(&frame)); - - if self.should_seed_preview_only_local_after_observed_burst_commit( - decision_source, - growth_rows, - previous_motion_rows_hint, - ) { - self.record_preview_only_downward_local_sample(&frame, viewport_top_y); - - self.seeded_preview_only_local_after_observed_burst_commit = true; - } else if self.should_preserve_preview_only_local_after_preview_only_burst_commit( - decision_source, - growth_rows, - previous_motion_rows_hint, - ) { - self.record_preview_only_downward_local_sample(&frame, viewport_top_y); - - self.seeded_preview_only_local_after_observed_burst_commit = false; - self.last_blocked_preview_only_local_candidate = None; - } else { - self.clear_preview_only_downward_local_sample(); - } - - self.last_unconfirmed_upward_fingerprint = None; - self.last_committed_frame = frame.clone(); - self.resume_frontier_top_y = None; - self.resume_frontier_requires_reacquire = false; - - self.growth_history.push(GrowthCommit { - frame, - growth_rows, - viewport_top_y, - decision_source, - detected_motion_rows, - effective_motion_rows_hint, - }); - - Ok(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows }) - } - - fn should_seed_preview_only_local_after_observed_burst_commit( - &self, - decision_source: &'static str, - growth_rows: u32, - previous_motion_rows_hint: Option, - ) -> bool { - decision_source == DownwardViewportCandidateSource::ObservedSample.decision_source() - && self.transient_burst_search_enabled - && previous_motion_rows_hint.is_some_and(|previous| { - previous >= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS && growth_rows < previous - }) - } - - fn should_preserve_preview_only_local_after_preview_only_burst_commit( - &self, - decision_source: &'static str, - growth_rows: u32, - previous_motion_rows_hint: Option, - ) -> bool { - decision_source == DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source() - && previous_motion_rows_hint.is_some_and(|previous| { - if self.transient_burst_search_enabled { - growth_rows >= DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS - && growth_rows - >= previous.saturating_sub(PREVIEW_ONLY_LOCAL_NEAR_CONTINUITY_ROWS) - && growth_rows - <= previous - .saturating_add(PREVIEW_ONLY_LOCAL_RECOVERY_MAX_TOLERANCE_ROWS) - } else { - previous <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS - && growth_rows > 1 && growth_rows <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS - && growth_rows <= previous - } - }) - } - - fn rebuild_export_image(&self) -> Result { - let mut ordered = Vec::with_capacity(self.bottom_segments.len().saturating_add(1)); - - ordered.push(&self.anchor_frame); - - for strip in &self.bottom_segments { - ordered.push(strip); - } - - stack_vertical_images(&ordered) - } - - fn rebuild_preview_image(&self) -> Result { - let mut ordered = Vec::with_capacity(self.bottom_preview_segments.len().saturating_add(1)); - - ordered.push(&self.anchor_preview); - - for strip in &self.bottom_preview_segments { - ordered.push(strip); - } - - stack_vertical_images(&ordered) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct PreviewOnlyDownwardLocalSample { - frame: RgbaImage, - viewport_top_y: i32, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct DirectionMatch { - mean_abs_diff_x100: u32, - motion_rows: u32, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct DownwardSampleMatch { - matched: DirectionMatch, - source: DownwardSampleMatchSource, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct DownwardViewportCandidate { - source: DownwardViewportCandidateSource, - viewport_top_y: i32, - motion_rows: u32, - mean_abs_diff_x100: u32, -} -impl DownwardViewportCandidate { - fn competing_block_reason(self, competing: Self) -> &'static str { - match (self.source, competing.source) { - ( - DownwardViewportCandidateSource::CommittedKeyframe, - DownwardViewportCandidateSource::CommittedKeyframe, - ) => "conflicting_committed_keyframe_authority", - _ => "conflicting_downward_viewport_authority", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct BlockedPreviewOnlyLocalCandidate { - candidate: DownwardViewportCandidate, - repeats: u8, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct OverlapSearchRange { - start: u32, - end: u32, -} -impl OverlapSearchRange { - fn as_range(self) -> RangeInclusive { - self.start..=self.end - } -} - -impl From> for OverlapSearchRange { - fn from(range: RangeInclusive) -> Self { - Self { start: *range.start(), end: *range.end() } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct DirectionMatchEval { - preferred_range: Option, - max_motion_rows: u32, - preferred_only_match: Option, - final_match: Option, - used_full_range_fallback: bool, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct MotionObservation { - direction: ScrollDirection, - motion_rows: u32, -} - -#[derive(Clone, Copy, Debug)] -struct UpInputMatchLog { - sample_motion: Option, - sample_down_match: Option, - sample_up_match: Option, - committed_down_match: Option, - committed_up_match: Option, - sample_override_wins: bool, - committed_override_wins: bool, -} - -#[derive(Clone, Copy, Debug)] -struct UpInputSearchWindowLog<'a> { - sample_delta: Option, - sample_down_match_eval: &'a DirectionMatchEval, - sample_up_match_eval: &'a DirectionMatchEval, - committed_down_match_eval: &'a DirectionMatchEval, - committed_up_match_eval: &'a DirectionMatchEval, - frame_equals_last_sample: bool, - frame_equals_last_committed: bool, -} - -#[derive(Clone, Copy, Debug)] -struct UpwardInputDiagnostics { - sample_down_match_eval: DirectionMatchEval, - sample_up_match_eval: DirectionMatchEval, - committed_down_match_eval: DirectionMatchEval, - committed_up_match_eval: DirectionMatchEval, - sample_override_match: Option, - committed_override_match: Option, -} - -#[derive(Clone, Copy, Debug)] -struct ResumeFrontierMatchLog { - motion_rows: u32, - candidate_observed_viewport_top_y: i32, - residual_growth_rows: u32, - raw_committed_down_match: Option, - trusted_committed_down_match: Option, - committed_up_match: Option, - frame_reacquires_last_committed_viewport: bool, -} - -#[derive(Clone, Copy, Debug)] -struct ResumeFrontierDirectMatchContext { - motion_rows: u32, - candidate_observed_viewport_top_y: i32, - residual_growth_rows: u32, -} - -#[derive(Clone, Debug)] -struct GrowthCommit { - frame: RgbaImage, - growth_rows: u32, - viewport_top_y: i32, - decision_source: &'static str, - detected_motion_rows: Option, - effective_motion_rows_hint: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct InformativeSpan { - start_x: u32, - end_exclusive_x: u32, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ScrollDirection { - Up, - Down, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ScrollObserveOutcome { - NoChange, - PreviewUpdated, - UnsupportedDirection { direction: ScrollDirection }, - Committed { direction: ScrollDirection, growth_rows: u32 }, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DownwardRegistration { - NoMatch, - Matched(DirectionMatch), - Ambiguous { best: DirectionMatch, competing: DirectionMatch }, -} -impl DownwardRegistration { - fn map_source(self, source: DownwardSampleMatchSource) -> DownwardRegistrationWithSource { - match self { - Self::NoMatch => DownwardRegistrationWithSource::NoMatch, - Self::Matched(matched) => { - DownwardRegistrationWithSource::Matched(DownwardSampleMatch { matched, source }) - }, - Self::Ambiguous { best, competing } => DownwardRegistrationWithSource::Ambiguous { - best: DownwardSampleMatch { matched: best, source }, - competing: DownwardSampleMatch { matched: competing, source }, - }, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DownwardSampleMatchSource { - ObservedSample, - PreviewOnlyLocalSample, -} -impl DownwardSampleMatchSource { - const fn label(self) -> &'static str { - match self { - Self::ObservedSample => "observed_sample", - Self::PreviewOnlyLocalSample => "preview_only_local_sample", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DownwardRegistrationWithSource { - NoMatch, - Matched(DownwardSampleMatch), - Ambiguous { best: DownwardSampleMatch, competing: DownwardSampleMatch }, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DownwardViewportCandidateSource { - ObservedSample, - PreviewOnlyLocalSample, - CommittedKeyframe, -} -impl DownwardViewportCandidateSource { - const fn priority(self) -> u8 { - match self { - Self::CommittedKeyframe => 0, - Self::ObservedSample => 1, - Self::PreviewOnlyLocalSample => 2, - } - } - - const fn decision_source(self) -> &'static str { - match self { - Self::ObservedSample => "sample_motion_downward_growth_from_observed_keyframe", - Self::PreviewOnlyLocalSample => { - "sample_motion_downward_growth_from_preview_only_local_sample" - }, - Self::CommittedKeyframe => "sample_motion_downward_growth_from_committed_keyframe", - } - } - - const fn fallback_decision_source(self) -> &'static str { - match self { - Self::ObservedSample => "fallback_downward_registration_from_observed_keyframe", - Self::PreviewOnlyLocalSample => { - "fallback_downward_registration_from_preview_only_local_sample" - }, - Self::CommittedKeyframe => "fallback_downward_registration_from_committed_keyframe", - } - } -} - -impl From for DownwardViewportCandidateSource { - fn from(value: DownwardSampleMatchSource) -> Self { - match value { - DownwardSampleMatchSource::ObservedSample => Self::ObservedSample, - DownwardSampleMatchSource::PreviewOnlyLocalSample => Self::PreviewOnlyLocalSample, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum CommittedDownwardViewportCandidateMode { - LastCommittedOnly, - IncludeRecentHistory, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DownwardViewportResolution { - NoMatch, - Selected(DownwardViewportCandidate), - Ambiguous { preferred: DownwardViewportCandidate, competing: DownwardViewportCandidate }, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum OverlapOrientation { - PreviousBottomToNextTop, - PreviousTopToNextBottom, -} - -#[must_use] -pub(crate) fn scroll_capture_fingerprint(image: &RgbaImage) -> Vec { - ScrollFrameFingerprint::from_image(image).into_bytes() -} - -#[must_use] -pub(crate) fn scroll_capture_fingerprint_delta(left: &[u8], right: &[u8]) -> u32 { - if left.len() != right.len() || left.is_empty() || !left.len().is_multiple_of(4) { - return u32::MAX; - } - - let mut total_abs_diff = 0_u64; - let mut comparisons = 0_u64; - - for (left_pixel, right_pixel) in left.chunks_exact(4).zip(right.chunks_exact(4)) { - total_abs_diff = total_abs_diff - .saturating_add(u64::from(left_pixel[0].abs_diff(right_pixel[0]))) - .saturating_add(u64::from(left_pixel[1].abs_diff(right_pixel[1]))) - .saturating_add(u64::from(left_pixel[2].abs_diff(right_pixel[2]))) - .saturating_add(u64::from(left_pixel[3].abs_diff(right_pixel[3]))); - comparisons = comparisons.saturating_add(4); - } - - if comparisons == 0 { u32::MAX } else { (total_abs_diff / comparisons) as u32 } -} - -#[cfg(test)] -#[must_use] -pub(crate) fn detect_vertical_overlap( - previous: &RgbaImage, - next: &RgbaImage, - config: OverlapSearchConfig, -) -> OverlapMatch { - detect_vertical_overlap_in_range( - previous, - next, - 1..=previous.height().min(next.height()), - ScrollDirection::Down, - config, - overlap_global_informative_span(previous, next), - ) -} - -pub(crate) fn compose_provisional_preview_image( - base_preview: &RgbaImage, - latest_frame: Option<&RgbaImage>, - motion_rows_hint: Option, - preview_width_px: u32, -) -> RgbaImage { - let Some(frame) = latest_frame else { - return base_preview.clone(); - }; - let Some(motion_rows_hint) = motion_rows_hint else { - return base_preview.clone(); - }; - let hinted_growth_rows = motion_rows_hint.min(frame.height()); - - if hinted_growth_rows == 0 { - return base_preview.clone(); - } - - let Some(strip) = crop_bottom_rows(frame, hinted_growth_rows) else { - return base_preview.clone(); - }; - let preview_strip = resize_strip_to_preview_width(&strip, preview_width_px); - - append_vertical_image(base_preview, &preview_strip).unwrap_or_else(|_| base_preview.clone()) -} - -fn worker_pairwise_overlap_search_config() -> OverlapSearchConfig { - OverlapSearchConfig { - min_overlap_rows: 24, - max_column_samples: 96, - max_row_samples: 96, - max_mean_abs_diff_x100: 850, - } -} - -#[cfg(target_os = "macos")] -fn classify_vision_downward_sample_motion_against( - previous: &RgbaImage, - next: &RgbaImage, -) -> Option { - let previous_cg = cg_image_from_rgba_image(previous).ok()?; - let next_cg = cg_image_from_rgba_image(next).ok()?; - let options = NSDictionary::::new(); - let request = unsafe { - VNTranslationalImageRegistrationRequest::initWithTargetedCGImage_options( - VNTranslationalImageRegistrationRequest::alloc(), - previous_cg.as_ref(), - options.as_ref(), - ) - }; - let request_array = NSArray::from_retained_slice(&[request - .clone() - .into_super() - .into_super() - .into_super() - .into_super()]); - let handler = unsafe { - VNImageRequestHandler::initWithCGImage_options( - VNImageRequestHandler::alloc(), - next_cg.as_ref(), - options.as_ref(), - ) - }; - - handler.performRequests_error(request_array.as_ref()).ok()?; - - let results = unsafe { request.results() }?; - - if results.count() == 0 { - return None; - } - - let translation = unsafe { results.objectAtIndex(0).alignmentTransform() }; - let motion_rows = translation.ty.round(); - - if !motion_rows.is_finite() || motion_rows <= 0.0 { - return None; - } - - let motion_rows = motion_rows as u32; - let config = OverlapSearchConfig::default(); - let matched = evaluate_overlap_direction( - previous, - next, - ScrollDirection::Down, - motion_rows..=motion_rows, - config, - )?; - let max_overlap = previous.height().min(next.height()); - - downward_registration_has_meaningful_overlap(matched, max_overlap, config).then_some(matched) -} - -#[cfg(not(target_os = "macos"))] -fn classify_vision_downward_sample_motion_against( - _previous: &RgbaImage, - _next: &RgbaImage, -) -> Option { - None -} - -fn estimate_pairwise_downward_shift_rows(previous: &RgbaImage, current: &RgbaImage) -> Option { - if previous.dimensions() != current.dimensions() { - return None; - } - - let (_width, height) = previous.dimensions(); - - if height < 3 { - return None; - } - - let max_shift = height.saturating_sub(1); - - evaluate_overlap_direction( - previous, - current, - ScrollDirection::Down, - 1..=max_shift, - worker_pairwise_overlap_search_config(), - ) - .map(|matched| matched.motion_rows) -} - -#[cfg(target_os = "macos")] -fn cg_image_from_rgba_image(image: &RgbaImage) -> Result> { - let width = image.width() as usize; - let height = image.height() as usize; - - if width == 0 || height == 0 { - return Err(eyre::eyre!("vision registration image has zero dimensions")); - } - - let bytes = CFData::from_bytes(image.as_raw()); - let provider = CGDataProvider::with_cf_data(Some(bytes.as_ref())) - .ok_or_else(|| eyre::eyre!("failed to create CGDataProvider for Vision registration"))?; - let color_space = CGColorSpace::new_device_rgb() - .ok_or_else(|| eyre::eyre!("failed to create RGB colorspace for Vision registration"))?; - let bitmap_info = CGBitmapInfo(CGImageAlphaInfo::Last.0 | CGImageByteOrderInfo::Order32Big.0); - - unsafe { - CGImage::new( - width, - height, - 8, - 32, - width.saturating_mul(4), - Some(color_space.as_ref()), - bitmap_info, - Some(provider.as_ref()), - ptr::null(), - false, - CGColorRenderingIntent::RenderingIntentDefault, - ) - } - .ok_or_else(|| eyre::eyre!("failed to create CGImage for Vision registration")) -} - -fn select_downward_viewport_candidate( - candidates: &mut [DownwardViewportCandidate], -) -> DownwardViewportResolution { - if candidates.is_empty() { - return DownwardViewportResolution::NoMatch; - } - - if let Some(preferred_local) = prefer_local_downward_viewport_candidate(candidates) { - let competing = candidates.iter().copied().find(|candidate| { - candidate != &preferred_local - && candidate.viewport_top_y.abs_diff(preferred_local.viewport_top_y) - >= DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS - && candidate.mean_abs_diff_x100 - <= preferred_local - .mean_abs_diff_x100 - .saturating_add(DIRECTION_WARNING_MARGIN_X100) - }); - - return match competing { - Some(competing) => { - DownwardViewportResolution::Ambiguous { preferred: preferred_local, competing } - }, - None => DownwardViewportResolution::Selected(preferred_local), - }; - } - - candidates.sort_by(|left, right| { - left.mean_abs_diff_x100 - .cmp(&right.mean_abs_diff_x100) - .then(left.source.priority().cmp(&right.source.priority())) - .then(left.motion_rows.cmp(&right.motion_rows)) - }); - - let preferred = candidates[0]; - let competing = candidates.iter().copied().skip(1).find(|candidate| { - candidate.viewport_top_y.abs_diff(preferred.viewport_top_y) - >= DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS - && candidate.mean_abs_diff_x100 - <= preferred.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) - }); - - match competing { - Some(competing) => DownwardViewportResolution::Ambiguous { preferred, competing }, - None => DownwardViewportResolution::Selected(preferred), - } -} - -fn format_downward_viewport_candidates(candidates: &[DownwardViewportCandidate]) -> String { - candidates - .iter() - .map(|candidate| { - format!( - "{:?}@{}/{}:{}", - candidate.source, - candidate.viewport_top_y, - candidate.motion_rows, - candidate.mean_abs_diff_x100 - ) - }) - .collect::>() - .join(",") -} - -fn prefer_local_downward_viewport_candidate( - candidates: &[DownwardViewportCandidate], -) -> Option { - let local = best_local_downward_viewport_candidate(candidates)?; - let committed = candidates - .iter() - .copied() - .filter(|candidate| candidate.source == DownwardViewportCandidateSource::CommittedKeyframe) - .min_by(|left, right| { - left.mean_abs_diff_x100 - .cmp(&right.mean_abs_diff_x100) - .then(left.motion_rows.cmp(&right.motion_rows)) - }); - let Some(committed) = committed else { - return Some(local); - }; - let committed_is_nearby = committed.viewport_top_y.abs_diff(local.viewport_top_y) - < DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS; - let committed_is_only_modestly_better = - committed.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) - >= local.mean_abs_diff_x100; - - if committed_is_nearby && committed_is_only_modestly_better { Some(local) } else { None } -} - -fn best_local_downward_viewport_candidate( - candidates: &[DownwardViewportCandidate], -) -> Option { - candidates - .iter() - .copied() - .filter(|candidate| candidate.source != DownwardViewportCandidateSource::CommittedKeyframe) - .min_by(|left, right| { - left.mean_abs_diff_x100 - .cmp(&right.mean_abs_diff_x100) - .then(left.source.priority().cmp(&right.source.priority())) - .then(left.motion_rows.cmp(&right.motion_rows)) - }) -} - -fn evaluate_overlap_direction( - previous: &RgbaImage, - next: &RgbaImage, - direction: ScrollDirection, - range: RangeInclusive, - config: OverlapSearchConfig, -) -> Option { - collect_overlap_direction_matches(previous, next, direction, range, config).into_iter().next() -} - -fn collect_overlap_direction_matches( - previous: &RgbaImage, - next: &RgbaImage, - direction: ScrollDirection, - range: RangeInclusive, - config: OverlapSearchConfig, -) -> Vec { - let Some(informative_span) = overlap_global_informative_span(previous, next) else { - return Vec::new(); - }; - let max_overlap = previous.height().min(next.height()); - let effective_min_overlap = - if max_overlap <= config.min_overlap_rows { 1 } else { config.min_overlap_rows.max(1) }; - let max_motion_rows = max_overlap.saturating_sub(effective_min_overlap).max(1); - let search_start = (*range.start()).max(1).min(max_motion_rows); - let search_end = (*range.end()).max(search_start).min(max_motion_rows); - let orientation = match direction { - ScrollDirection::Down => OverlapOrientation::PreviousBottomToNextTop, - ScrollDirection::Up => OverlapOrientation::PreviousTopToNextBottom, - }; - let mut matches = Vec::with_capacity(search_end.saturating_sub(search_start) as usize + 1); - - for motion_rows in search_start..=search_end { - let overlap_rows = max_overlap.saturating_sub(motion_rows); - - if overlap_rows < effective_min_overlap { - continue; - } - - let diff = motion_mean_abs_diff_x100( - previous, - next, - motion_rows, - config, - orientation, - informative_span, - ); - - if diff > config.max_mean_abs_diff_x100 { - continue; - } - - matches.push(DirectionMatch { mean_abs_diff_x100: diff, motion_rows }); - } - - matches.sort_by(|left, right| { - left.mean_abs_diff_x100 - .cmp(&right.mean_abs_diff_x100) - .then(left.motion_rows.cmp(&right.motion_rows)) - }); - - matches -} - -fn collect_overlap_direction_matches_in_ranges( - previous: &RgbaImage, - next: &RgbaImage, - direction: ScrollDirection, - ranges: &[RangeInclusive], - config: OverlapSearchConfig, -) -> Vec { - let mut matches = Vec::new(); - - for range in ranges { - matches.extend(collect_overlap_direction_matches( - previous, - next, - direction, - range.clone(), - config, - )); - } - - if matches.len() <= 1 { - return matches; - } - - matches.sort_by(|left, right| { - left.motion_rows - .cmp(&right.motion_rows) - .then(left.mean_abs_diff_x100.cmp(&right.mean_abs_diff_x100)) - }); - - let mut deduped: Vec = Vec::with_capacity(matches.len()); - - for matched in matches { - if let Some(previous) = deduped.last_mut() - && previous.motion_rows == matched.motion_rows - { - if matched.mean_abs_diff_x100 < previous.mean_abs_diff_x100 { - *previous = matched; - } - - continue; - } - - deduped.push(matched); - } - - deduped.sort_by(|left, right| { - left.mean_abs_diff_x100 - .cmp(&right.mean_abs_diff_x100) - .then(left.motion_rows.cmp(&right.motion_rows)) - }); - - deduped -} - -fn classify_downward_registration_candidates( - candidates: &[DirectionMatch], -) -> DownwardRegistration { - let Some(best) = candidates.first().copied() else { - return DownwardRegistration::NoMatch; - }; - let competing = candidates.iter().copied().skip(1).find(|candidate| { - candidate.motion_rows.abs_diff(best.motion_rows) >= DOWNWARD_REGISTRATION_AMBIGUOUS_GAP_ROWS - }); - - match competing { - Some(competing) - if best.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) - >= competing.mean_abs_diff_x100 => - { - DownwardRegistration::Ambiguous { best, competing } - }, - _ => DownwardRegistration::Matched(best), - } -} - -fn downward_registration_has_meaningful_overlap( - matched: DirectionMatch, - max_overlap: u32, - config: OverlapSearchConfig, -) -> bool { - let overlap_rows = max_overlap.saturating_sub(matched.motion_rows); - let effective_min_overlap = - if max_overlap <= config.min_overlap_rows { 1 } else { config.min_overlap_rows.max(1) }; - let min_overlap_rows = - effective_min_overlap.max(max_overlap / DOWNWARD_REGISTRATION_MIN_OVERLAP_DIVISOR).max(1); - - overlap_rows >= min_overlap_rows -} - -fn preview_update_outcome(preview_changed: bool) -> ScrollObserveOutcome { - if preview_changed { - ScrollObserveOutcome::PreviewUpdated - } else { - ScrollObserveOutcome::NoChange - } -} - -fn resume_direct_match_is_trustworthy(matched: DirectionMatch) -> bool { - matched.mean_abs_diff_x100 <= RESUME_DIRECT_PROOF_MAX_MEAN_ABS_DIFF_X100 -} - -fn preferred_upward_override_match( - up_match: Option, - down_match: Option, -) -> Option { - match (up_match, down_match) { - (Some(up), Some(_down)) if resume_direct_match_is_trustworthy(up) => Some(up), - (Some(up), None) if resume_direct_match_is_trustworthy(up) => Some(up), - _ => None, - } -} - -fn preferred_upward_input_override_match( - sample_match: Option, - committed_match: Option, -) -> Option<(DirectionMatch, bool)> { - match (sample_match, committed_match) { - (Some(sample), Some(committed)) - if committed.motion_rows <= sample.motion_rows - && committed.mean_abs_diff_x100 - <= sample.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) => - { - Some((committed, true)) - }, - (Some(sample), Some(_committed)) => Some((sample, false)), - (Some(sample), None) => Some((sample, false)), - (None, Some(committed)) => Some((committed, true)), - (None, None) => None, - } -} - -fn upward_confirmation_match_for_downward_input( - up_match: Option, - down_match: Option, - has_committed_growth: bool, -) -> Option { - if !has_committed_growth { - return None; - } - - match (up_match, down_match) { - (Some(up), Some(down)) - if resume_direct_match_is_trustworthy(up) - && up.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) - <= down.mean_abs_diff_x100 => - { - Some(up) - }, - (Some(up), None) if resume_direct_match_is_trustworthy(up) => Some(up), - _ => None, - } -} - -fn rewind_active_upward_override_match( - sample_match: Option, - committed_match: Option, - rewind_active: bool, -) -> Option<(DirectionMatch, bool)> { - if !rewind_active { - return None; - } - - match (sample_match, committed_match) { - (Some(sample), Some(committed)) - if committed.motion_rows < sample.motion_rows - && committed.mean_abs_diff_x100 - <= sample.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) => - { - Some((committed, true)) - }, - (Some(sample), _) => Some((sample, false)), - (None, Some(committed)) => Some((committed, true)), - (None, None) => None, - } -} - -fn rewind_active_upward_motion_should_fail_closed( - sample_up_match: Option, - committed_up_match: Option, - committed_down_match: Option, - rewind_active: bool, -) -> bool { - if !rewind_active { - return false; - } - if committed_up_match.is_some() { - return false; - } - - matches!( - (sample_up_match, committed_down_match), - (Some(sample_up), Some(committed_down)) - if committed_down.mean_abs_diff_x100 - <= sample_up.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) - && committed_down.motion_rows >= sample_up.motion_rows - ) -} - -fn max_directional_motion_rows( - previous: &RgbaImage, - next: &RgbaImage, - config: OverlapSearchConfig, -) -> u32 { - let max_overlap = previous.height().min(next.height()); - let effective_min_overlap = - if max_overlap <= config.min_overlap_rows { 1 } else { config.min_overlap_rows.max(1) }; - - max_overlap.saturating_sub(effective_min_overlap).max(1) -} - -#[cfg(test)] -fn detect_vertical_overlap_in_range( - previous: &RgbaImage, - next: &RgbaImage, - range: RangeInclusive, - direction: ScrollDirection, - config: OverlapSearchConfig, - informative_span: Option, -) -> OverlapMatch { - if previous.width() == 0 || next.width() == 0 || previous.height() == 0 || next.height() == 0 { - return OverlapMatch { rows: 0, matched: false, mean_abs_diff_x100: u32::MAX }; - } - - let Some(informative_span) = informative_span else { - return OverlapMatch { rows: 0, matched: false, mean_abs_diff_x100: u32::MAX }; - }; - let max_overlap = previous.height().min(next.height()); - let effective_min_overlap = - if max_overlap <= config.min_overlap_rows { 1 } else { config.min_overlap_rows.max(1) }; - let max_motion_rows = max_overlap.saturating_sub(effective_min_overlap).max(1); - let search_start = (*range.start()).max(1).min(max_motion_rows); - let search_end = (*range.end()).max(search_start).min(max_motion_rows); - let orientation = match direction { - ScrollDirection::Down => OverlapOrientation::PreviousBottomToNextTop, - ScrollDirection::Up => OverlapOrientation::PreviousTopToNextBottom, - }; - let mut best = OverlapMatch { rows: 0, matched: false, mean_abs_diff_x100: u32::MAX }; - - for motion_rows in search_start..=search_end { - let overlap_rows = max_overlap.saturating_sub(motion_rows); - - if overlap_rows < effective_min_overlap { - continue; - } - - let diff = motion_mean_abs_diff_x100( - previous, - next, - motion_rows, - config, - orientation, - informative_span, - ); - - if diff > config.max_mean_abs_diff_x100 { - continue; - } - if !best.matched - || diff < best.mean_abs_diff_x100 - || (diff == best.mean_abs_diff_x100 && overlap_rows > best.rows) - { - best = OverlapMatch { rows: overlap_rows, matched: true, mean_abs_diff_x100: diff }; - } - } - - best -} - -fn resize_strip_to_preview_width(strip: &RgbaImage, preview_width_px: u32) -> RgbaImage { - if strip.width() <= preview_width_px { - return strip.clone(); - } - - let preview_height = ((strip.height() as f32 / strip.width() as f32) * preview_width_px as f32) - .round() - .max(1.0) as u32; - - imageops::resize(strip, preview_width_px, preview_height, FilterType::Triangle) -} - -fn crop_bottom_rows(frame: &RgbaImage, rows: u32) -> Option { - let rows = rows.min(frame.height()); - - if rows == 0 { - return None; - } - - let start_y = frame.height().saturating_sub(rows); - - Some(imageops::crop_imm(frame, 0, start_y, frame.width(), rows).to_image()) -} - -fn stack_vertical_images(images: &[&RgbaImage]) -> Result { - let Some(first) = images.first() else { - return Err(eyre::eyre!("cannot stack an empty image list")); - }; - let width = first.width(); - let total_height = images.iter().try_fold(0_u32, |acc, image| { - if image.width() != width { - return Err(eyre::eyre!( - "image width mismatch while stacking: expected {} got {}", - width, - image.width() - )); - } - - acc.checked_add(image.height()).ok_or_else(|| eyre::eyre!("stacked image height overflow")) - })?; - let total_bytes = images.iter().try_fold(0_usize, |acc, image| { - acc.checked_add(image.as_raw().len()) - .ok_or_else(|| eyre::eyre!("stacked image byte length overflow")) - })?; - let mut raw = Vec::with_capacity(total_bytes); - - for image in images { - raw.extend_from_slice(image.as_raw()); - } - - RgbaImage::from_raw(width, total_height, raw) - .ok_or_else(|| eyre::eyre!("failed to construct stacked image buffer")) -} - -fn append_vertical_image(base: &RgbaImage, strip: &RgbaImage) -> Result { - if base.width() != strip.width() { - return Err(eyre::eyre!( - "image width mismatch while appending: expected {} got {}", - base.width(), - strip.width() - )); - } - - stack_vertical_images(&[base, strip]) -} - -fn motion_mean_abs_diff_x100( - previous: &RgbaImage, - next: &RgbaImage, - motion_rows: u32, - config: OverlapSearchConfig, - orientation: OverlapOrientation, - informative_span: InformativeSpan, -) -> u32 { - let width = previous.width().min(next.width()); - let max_overlap = previous.height().min(next.height()); - let overlap_rows = max_overlap.saturating_sub(motion_rows); - - if overlap_rows == 0 { - return u32::MAX; - } - - let column_samples = width.min(config.max_column_samples).max(1); - let row_samples = overlap_rows.min(config.max_row_samples).max(1); - let previous_overlap_start_y = previous.height().saturating_sub(overlap_rows); - let next_overlap_start_y = next.height().saturating_sub(overlap_rows); - let previous_start_y = match orientation { - OverlapOrientation::PreviousBottomToNextTop => previous_overlap_start_y, - OverlapOrientation::PreviousTopToNextBottom => 0, - }; - let next_start_y = match orientation { - OverlapOrientation::PreviousBottomToNextTop => 0, - OverlapOrientation::PreviousTopToNextBottom => next_overlap_start_y, - }; - let x_start = informative_span.start_x.min(width.saturating_sub(1)); - let x_end = informative_span.end_exclusive_x.min(width).max(x_start + 1); - let effective_width = x_end.saturating_sub(x_start).max(1); - let column_samples = effective_width.min(column_samples).max(1); - let mut total_abs_diff = 0_u64; - let mut comparisons = 0_u64; - - for row in 0..row_samples { - let local_y = evenly_spaced_sample(0, overlap_rows, row, row_samples); - let previous_y = - previous_start_y.saturating_add(local_y).min(previous.height().saturating_sub(1)); - let next_y = next_start_y.saturating_add(local_y).min(next.height().saturating_sub(1)); - - for column in 0..column_samples { - let x = evenly_spaced_sample(x_start, x_end, column, column_samples); - let previous_pixel = previous.get_pixel(x, previous_y).0; - let next_pixel = next.get_pixel(x, next_y).0; - - total_abs_diff = total_abs_diff - .saturating_add(u64::from(previous_pixel[0].abs_diff(next_pixel[0]))) - .saturating_add(u64::from(previous_pixel[1].abs_diff(next_pixel[1]))) - .saturating_add(u64::from(previous_pixel[2].abs_diff(next_pixel[2]))); - comparisons = comparisons.saturating_add(3); - } - } - - if comparisons == 0 { - return u32::MAX; - } - - ((total_abs_diff.saturating_mul(100)) / comparisons) as u32 -} - -fn overlap_global_informative_span(left: &RgbaImage, right: &RgbaImage) -> Option { - let left_span = informative_column_span(left, 0, left.height()); - let right_span = informative_column_span(right, 0, right.height()); - let width = left.width().min(right.width()); - - match (left_span, right_span) { - (Some(left_span), Some(right_span)) => { - let start_x = left_span.start_x.max(right_span.start_x); - let end_exclusive_x = - left_span.end_exclusive_x.min(right_span.end_exclusive_x).min(width); - - (end_exclusive_x > start_x).then_some(InformativeSpan { start_x, end_exclusive_x }) - }, - (Some(span), None) | (None, Some(span)) => { - let end_exclusive_x = span.end_exclusive_x.min(width).max(span.start_x + 1); - - (end_exclusive_x > span.start_x) - .then_some(InformativeSpan { start_x: span.start_x, end_exclusive_x }) - }, - (None, None) => None, - } -} - -fn informative_column_span(image: &RgbaImage, start_y: u32, rows: u32) -> Option { - if image.width() == 0 || image.height() == 0 || rows == 0 { - return None; - } - - let clamped_rows = rows.min(image.height().saturating_sub(start_y)).max(1); - let row_samples = clamped_rows.min(INFORMATIVE_SPAN_ROW_SAMPLES.max(2)).max(2); - let mut scores = vec![0_u32; image.width() as usize]; - let mut max_score = 0_u32; - - for row in 0..row_samples.saturating_sub(1) { - let local_y = evenly_spaced_sample(0, clamped_rows, row, row_samples); - let next_local_y = (local_y.saturating_add(1)).min(clamped_rows.saturating_sub(1)); - let y = start_y.saturating_add(local_y).min(image.height().saturating_sub(1)); - let next_y = start_y.saturating_add(next_local_y).min(image.height().saturating_sub(1)); - - for x in 0..image.width() { - let pixel = image.get_pixel(x, y).0; - let next_pixel = image.get_pixel(x, next_y).0; - let score = u32::from(pixel[0].abs_diff(next_pixel[0])) - .saturating_add(u32::from(pixel[1].abs_diff(next_pixel[1]))) - .saturating_add(u32::from(pixel[2].abs_diff(next_pixel[2]))); - let slot = &mut scores[x as usize]; - - *slot = slot.saturating_add(score); - max_score = max_score.max(*slot); - } - } - - if max_score == 0 { - return None; - } - - let threshold = (max_score / 6).max(INFORMATIVE_SPAN_SCORE_FLOOR_X100); - let mut start_x = None; - let mut end_x = None; - - for (x, score) in scores.iter().enumerate() { - if *score >= threshold { - start_x.get_or_insert(x as u32); - - end_x = Some((x as u32).saturating_add(1)); - } - } - - let start_x = start_x?; - let end_exclusive_x = end_x?; - let padding = INFORMATIVE_SPAN_HORIZONTAL_PADDING_PX.min(image.width() / 8); - let start_x = start_x.saturating_sub(padding); - let end_exclusive_x = - end_exclusive_x.saturating_add(padding).min(image.width()).max(start_x.saturating_add(1)); - - Some(InformativeSpan { start_x, end_exclusive_x }) -} - -fn evenly_spaced_sample(start: u32, end_exclusive: u32, index: u32, count: u32) -> u32 { - let span = end_exclusive.saturating_sub(start).max(1); - - if count <= 1 { - return start.min(end_exclusive.saturating_sub(1)); - } - - let numerator = - (u64::from(index) * u64::from(span.saturating_sub(1))) / u64::from(count.saturating_sub(1)); - - start.saturating_add(numerator as u32).min(end_exclusive.saturating_sub(1)) -} - -#[cfg(test)] -mod tests { - use image::Rgba; - - use crate::scroll_capture::{ - self, DirectionMatch, DownwardRegistration, DownwardSampleMatch, DownwardSampleMatchSource, - DownwardViewportCandidate, DownwardViewportCandidateSource, DownwardViewportResolution, - GrowthCommit, MotionObservation, OverlapSearchConfig, PreviewOnlyDownwardLocalSample, - ScrollDirection, ScrollFrameFingerprint, ScrollObserveOutcome, ScrollSession, - }; - - fn make_test_image(width: u32, rows: &[[u8; 4]]) -> image::RgbaImage { - let mut image = image::RgbaImage::new(width, rows.len() as u32); - - for (y, row) in rows.iter().enumerate() { - for x in 0..width { - image.put_pixel(x, y as u32, Rgba(*row)); - } - } - - image - } - - fn make_window( - document: &[[u8; 4]], - width: u32, - start_row: usize, - window_rows: usize, - ) -> image::RgbaImage { - make_test_image(width, &document[start_row..start_row + window_rows]) - } - - fn make_sparse_textlike_window(width: u32, height: u32, start_row: u32) -> image::RgbaImage { - let stripe_x = 104_u32; - let mut image = image::RgbaImage::from_pixel(width, height, Rgba([255, 255, 255, 255])); - - for y in 0..height { - let document_row = start_row.saturating_add(y); - let shade = ((document_row.saturating_mul(17)) % 180) as u8; - - for x in stripe_x..stripe_x.saturating_add(6) { - image.put_pixel(x, y, Rgba([shade, shade, shade, 255])); - } - for x in stripe_x.saturating_add(10)..stripe_x.saturating_add(13) { - if document_row % 19 < 9 { - image.put_pixel(x, y, Rgba([40, 40, 40, 255])); - } - } - } - - image - } - - fn make_sparse_textlike_window_with_moving_edge_scrollbar( - width: u32, - height: u32, - start_row: u32, - thumb_top: u32, - ) -> image::RgbaImage { - let track_left = width.saturating_sub(18); - let thumb_height = (height / 4).max(12).min(height.max(1)); - let thumb_top = thumb_top.min(height.saturating_sub(thumb_height)); - let thumb_right = width.saturating_sub(3).max(track_left.saturating_add(4)); - let mut image = make_sparse_textlike_window(width, height, start_row); - - for y in 0..height { - for x in track_left..width { - image.put_pixel(x, y, Rgba([224, 224, 224, 255])); - } - } - for y in thumb_top..thumb_top.saturating_add(thumb_height) { - for x in track_left.saturating_add(3)..thumb_right { - image.put_pixel(x, y, Rgba([28, 28, 28, 255])); - } - } - - image - } - - fn make_browser_like_window(width: u32, height: u32, start_row: u32) -> image::RgbaImage { - let scrollbar_left = width.saturating_sub(18); - let content_left = 56_u32; - let content_right = width.saturating_sub(48); - let heading_width = 220_u32; - let paragraph_width = content_right.saturating_sub(content_left); - let mut image = make_sparse_textlike_window(width, height, start_row); - - for y in 0..height { - let document_row = start_row.saturating_add(y); - - if document_row % 420 < 18 { - for x in content_left..content_left.saturating_add(heading_width) { - image.put_pixel(x, y, Rgba([26, 26, 26, 255])); - } - } else if document_row % 420 >= 54 && document_row % 420 < 220 { - if document_row % 24 < 3 { - let trim = ((document_row / 24) % 5) * 18; - - for x in content_left - ..content_left.saturating_add(paragraph_width.saturating_sub(trim)) - { - image.put_pixel(x, y, Rgba([72, 72, 72, 255])); - } - } - } else if document_row % 420 >= 270 && document_row % 420 < 360 && document_row % 20 < 2 - { - for x in content_left.saturating_add(20) - ..content_left.saturating_add(paragraph_width.saturating_sub(70)) - { - image.put_pixel(x, y, Rgba([98, 98, 98, 255])); - } - } - - for x in scrollbar_left..width { - image.put_pixel(x, y, Rgba([232, 232, 232, 255])); - } - } - - let thumb_height = (height / 5).max(16); - let thumb_top = (start_row / 3) % height.max(thumb_height + 1); - let thumb_top = thumb_top.min(height.saturating_sub(thumb_height)); - - for y in thumb_top..thumb_top.saturating_add(thumb_height) { - for x in scrollbar_left.saturating_add(3)..width.saturating_sub(4) { - image.put_pixel(x, y, Rgba([96, 96, 96, 255])); - } - } - - image - } - - #[test] - fn overlap_detection_prefers_largest_matching_suffix() { - let previous = make_test_image( - 5, - &[ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - ], - ); - let next = make_test_image( - 5, - &[[40, 0, 0, 255], [50, 0, 0, 255], [60, 0, 0, 255], [70, 0, 0, 255], [80, 0, 0, 255]], - ); - let overlap = scroll_capture::detect_vertical_overlap( - &previous, - &next, - OverlapSearchConfig { min_overlap_rows: 1, ..Default::default() }, - ); - - assert!(overlap.matched); - assert_eq!(overlap.rows, 3); - } - - #[test] - fn fingerprint_wrapper_returns_zero_delta_for_identical_images() { - let image = image::RgbaImage::from_pixel(12, 12, Rgba([9, 8, 7, 255])); - let left = scroll_capture::scroll_capture_fingerprint(&image); - let right = scroll_capture::scroll_capture_fingerprint(&image); - - assert_eq!(scroll_capture::scroll_capture_fingerprint_delta(&left, &right), 0); - } - - #[test] - fn fingerprint_struct_distance_detects_changed_image() { - let base = image::RgbaImage::from_pixel(12, 12, Rgba([9, 8, 7, 255])); - let changed = image::RgbaImage::from_pixel(12, 12, Rgba([30, 8, 7, 255])); - let left = ScrollFrameFingerprint::from_image(&base); - let right = ScrollFrameFingerprint::from_image(&changed); - - assert!(left.distance(&right) > 0); - } - - #[test] - fn session_commits_downward_growth_on_first_matching_sample() { - let base = make_test_image( - 3, - &[[10, 0, 0, 255], [20, 0, 0, 255], [30, 0, 0, 255], [40, 0, 0, 255], [50, 0, 0, 255]], - ); - let moved = make_test_image( - 3, - &[[20, 0, 0, 255], [30, 0, 0, 255], [40, 0, 0, 255], [50, 0, 0, 255], [60, 0, 0, 255]], - ); - let mut session = ScrollSession::new(base.clone(), 320).unwrap(); - let outcome = session.observe_downward_sample(moved).unwrap(); - - assert_eq!( - outcome, - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - assert_eq!(session.export_image().height(), 6); - assert_eq!(session.export_image().get_pixel(0, 5), &Rgba([60, 0, 0, 255])); - } - - #[cfg(target_os = "macos")] - #[test] - fn worker_pairwise_vision_commits_substantial_downward_growth_with_corroboration() { - let base = make_sparse_textlike_window(512, 640, 0); - let moved = make_sparse_textlike_window(512, 640, 90); - let matched = scroll_capture::classify_vision_downward_sample_motion_against(&base, &moved) - .expect("vision registration should detect the substantial downward motion"); - let mut session = ScrollSession::new(base, 320).unwrap(); - let outcome = session.observe_worker_pairwise_vision_frame(moved).unwrap(); - - assert!(matched.motion_rows >= 32); - assert_eq!( - outcome, - ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: matched.motion_rows, - } - ); - assert_eq!(session.export_image().height(), 640 + matched.motion_rows); - assert_eq!(session.current_viewport_top_y(), i32::try_from(matched.motion_rows).unwrap()); - } - - #[test] - fn pairwise_downward_shift_estimate_matches_sparse_textlike_motion() { - let base = make_sparse_textlike_window(512, 640, 0); - let moved = make_sparse_textlike_window(512, 640, 58); - - assert_eq!(scroll_capture::estimate_pairwise_downward_shift_rows(&base, &moved), Some(58)); - } - - #[test] - fn pairwise_downward_shift_estimate_matches_browser_like_motion_above_legacy_cap() { - let base = make_browser_like_window(512, 640, 0); - let moved = make_browser_like_window(512, 640, 320); - - assert_eq!(scroll_capture::estimate_pairwise_downward_shift_rows(&base, &moved), Some(320)); - } - - #[test] - fn pairwise_downward_shift_estimate_tracks_successive_browser_like_steps() { - let frames = [0_u32, 180, 360, 540, 720] - .into_iter() - .map(|start_row| make_browser_like_window(512, 640, start_row)) - .collect::>(); - - for window in frames.windows(2) { - assert_eq!( - scroll_capture::estimate_pairwise_downward_shift_rows(&window[0], &window[1]), - Some(180) - ); - } - } - - #[cfg(target_os = "macos")] - #[test] - fn worker_pairwise_vision_uses_latest_committed_live_frame_for_followup_growth() { - let base = make_sparse_textlike_window(512, 640, 0); - let step_one = make_sparse_textlike_window(512, 640, 180); - let step_two = make_sparse_textlike_window(512, 640, 360); - let first_match = - scroll_capture::classify_vision_downward_sample_motion_against(&base, &step_one) - .expect("first pairwise registration should detect downward motion"); - let followup_match = - scroll_capture::classify_vision_downward_sample_motion_against(&step_one, &step_two) - .expect("followup pairwise registration should detect downward motion"); - let mut session = ScrollSession::new(base, 320).unwrap(); - - assert_eq!( - session.observe_worker_pairwise_vision_frame(step_one).unwrap(), - ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: first_match.motion_rows, - } - ); - assert_eq!( - session.observe_worker_pairwise_vision_frame(step_two).unwrap(), - ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: followup_match.motion_rows, - } - ); - assert_eq!( - session.export_image().height(), - 640 + first_match.motion_rows + followup_match.motion_rows - ); - assert_eq!( - session.current_viewport_top_y(), - i32::try_from(first_match.motion_rows + followup_match.motion_rows).unwrap() - ); - } - - #[cfg(target_os = "macos")] - #[test] - fn worker_pairwise_vision_handles_repeated_frame_between_growth_steps() { - let base = make_sparse_textlike_window(512, 640, 0); - let step_one = make_sparse_textlike_window(512, 640, 180); - let step_two = make_sparse_textlike_window(512, 640, 360); - let first_match = - scroll_capture::classify_vision_downward_sample_motion_against(&base, &step_one) - .expect("first pairwise registration should detect downward motion"); - let followup_match = - scroll_capture::classify_vision_downward_sample_motion_against(&step_one, &step_two) - .expect("followup pairwise registration should detect downward motion"); - let mut session = ScrollSession::new(base, 320).unwrap(); - - assert_eq!( - session.observe_worker_pairwise_vision_frame(step_one.clone()).unwrap(), - ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: first_match.motion_rows, - } - ); - assert_eq!( - session.observe_worker_pairwise_vision_frame(step_one).unwrap(), - ScrollObserveOutcome::NoChange - ); - assert_eq!( - session.observe_worker_pairwise_vision_frame(step_two).unwrap(), - ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: followup_match.motion_rows, - } - ); - assert_eq!( - session.export_image().height(), - 640 + first_match.motion_rows + followup_match.motion_rows - ); - } - - #[cfg(target_os = "macos")] - #[test] - fn worker_pairwise_vision_recovers_after_blocked_overshot_frame() { - let base = make_browser_like_window(512, 640, 0); - let blocked = make_browser_like_window(512, 640, 760); - let followup = make_browser_like_window(512, 640, 844); - let matched = - scroll_capture::classify_vision_downward_sample_motion_against(&blocked, &followup) - .expect( - "pairwise registration should detect the followup step after the blocked overshot", - ); - let mut session = ScrollSession::new(base, 320).unwrap(); - - assert_eq!( - session.observe_worker_pairwise_vision_frame(blocked).unwrap(), - ScrollObserveOutcome::NoChange - ); - assert_eq!(session.export_image().height(), 640); - assert_eq!(session.current_viewport_top_y(), 0); - assert_eq!( - session.observe_worker_pairwise_vision_frame(followup).unwrap(), - ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: matched.motion_rows, - } - ); - assert_eq!(session.export_image().height(), 640 + matched.motion_rows); - assert_eq!(session.current_viewport_top_y(), i32::try_from(matched.motion_rows).unwrap()); - } - - #[cfg(target_os = "macos")] - #[test] - fn worker_pairwise_vision_clears_preview_local_followup_carryover_on_no_change() { - let base = make_sparse_textlike_window(512, 640, 0); - let mut session = ScrollSession::new(base.clone(), 320).unwrap(); - - session.record_preview_only_downward_local_sample(&base, 123); - - session.pending_suppressed_huge_preview_only_local_followup = - Some(DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 160, - motion_rows: 160, - mean_abs_diff_x100: 0, - }); - session.pending_suppressed_huge_preview_only_local_followup_remaining_blocks = 2; - session.pending_extreme_preview_only_local_tail_followup = - Some(DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 161, - motion_rows: 1, - mean_abs_diff_x100: 0, - }); - session.pending_extreme_preview_only_local_tail_followup_remaining_blocks = 1; - - assert_eq!( - session.observe_worker_pairwise_vision_frame(base).unwrap(), - ScrollObserveOutcome::NoChange - ); - assert!(session.last_preview_only_downward_local_sample.is_none()); - assert!(session.pending_suppressed_huge_preview_only_local_followup.is_none()); - assert_eq!(session.pending_suppressed_huge_preview_only_local_followup_remaining_blocks, 0); - assert!(session.pending_extreme_preview_only_local_tail_followup.is_none()); - assert_eq!(session.pending_extreme_preview_only_local_tail_followup_remaining_blocks, 0); - } - - #[cfg(target_os = "macos")] - #[test] - fn worker_pairwise_vision_clears_preview_local_followup_carryover_on_commit() { - let base = make_sparse_textlike_window(512, 640, 0); - let moved = make_sparse_textlike_window(512, 640, 180); - let matched = scroll_capture::classify_vision_downward_sample_motion_against(&base, &moved) - .expect("pairwise registration should detect downward motion"); - let mut session = ScrollSession::new(base, 320).unwrap(); - - session.record_preview_only_downward_local_sample(&moved, 180); - - session.pending_suppressed_huge_preview_only_local_followup = - Some(DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 160, - motion_rows: 160, - mean_abs_diff_x100: 0, - }); - session.pending_suppressed_huge_preview_only_local_followup_remaining_blocks = 2; - session.pending_extreme_preview_only_local_tail_followup = - Some(DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 161, - motion_rows: 1, - mean_abs_diff_x100: 0, - }); - session.pending_extreme_preview_only_local_tail_followup_remaining_blocks = 1; - - assert_eq!( - session.observe_worker_pairwise_vision_frame(moved).unwrap(), - ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: matched.motion_rows, - } - ); - assert!(session.last_preview_only_downward_local_sample.is_none()); - assert!(session.pending_suppressed_huge_preview_only_local_followup.is_none()); - assert_eq!(session.pending_suppressed_huge_preview_only_local_followup_remaining_blocks, 0); - assert!(session.pending_extreme_preview_only_local_tail_followup.is_none()); - assert_eq!(session.pending_extreme_preview_only_local_tail_followup_remaining_blocks, 0); - } - - #[cfg(target_os = "macos")] - #[test] - fn worker_pairwise_vision_commits_successive_slowdown_steps() { - let frames = [0_u32, 180, 300, 380, 420] - .into_iter() - .map(|start_row| make_sparse_textlike_window(512, 640, start_row)) - .collect::>(); - let mut session = ScrollSession::new(frames[0].clone(), 320).unwrap(); - let mut expected_export_height = 640_u32; - let mut expected_viewport_top_y = 0_i32; - - for window in frames.windows(2) { - let previous = &window[0]; - let next = window[1].clone(); - let matched = - scroll_capture::classify_vision_downward_sample_motion_against(previous, &next) - .expect("pairwise registration should detect each slowdown step"); - - assert_eq!( - session.observe_worker_pairwise_vision_frame(next).unwrap(), - ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: matched.motion_rows, - } - ); - - expected_export_height = expected_export_height.saturating_add(matched.motion_rows); - expected_viewport_top_y += i32::try_from(matched.motion_rows).unwrap(); - } - - assert_eq!(session.export_image().height(), expected_export_height); - assert_eq!(session.current_viewport_top_y(), expected_viewport_top_y); - } - - #[cfg(target_os = "macos")] - #[test] - fn worker_pairwise_vision_commits_browser_like_growth_above_legacy_cap() { - let base = make_browser_like_window(512, 640, 0); - let moved = make_browser_like_window(512, 640, 320); - let matched = scroll_capture::classify_vision_downward_sample_motion_against(&base, &moved) - .expect("vision registration should detect the browser-like downward motion"); - let mut session = ScrollSession::new(base, 320).unwrap(); - - assert!(matched.motion_rows > 256); - assert_eq!( - session.observe_worker_pairwise_vision_frame(moved).unwrap(), - ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: matched.motion_rows, - } - ); - assert_eq!(session.export_image().height(), 640 + matched.motion_rows); - assert_eq!(session.current_viewport_top_y(), i32::try_from(matched.motion_rows).unwrap()); - } - - #[cfg(target_os = "macos")] - #[test] - fn worker_pairwise_vision_commits_successive_browser_like_steps() { - let frames = [0_u32, 180, 360, 540, 720] - .into_iter() - .map(|start_row| make_browser_like_window(512, 640, start_row)) - .collect::>(); - let mut session = ScrollSession::new(frames[0].clone(), 320).unwrap(); - let mut expected_export_height = 640_u32; - let mut expected_viewport_top_y = 0_i32; - - for window in frames.windows(2) { - let previous = &window[0]; - let next = window[1].clone(); - let matched = - scroll_capture::classify_vision_downward_sample_motion_against(previous, &next) - .expect("pairwise registration should detect each browser-like step"); - - assert_eq!( - session.observe_worker_pairwise_vision_frame(next).unwrap(), - ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: matched.motion_rows, - } - ); - - expected_export_height = expected_export_height.saturating_add(matched.motion_rows); - expected_viewport_top_y += i32::try_from(matched.motion_rows).unwrap(); - } - - assert_eq!(session.export_image().height(), expected_export_height); - assert_eq!(session.current_viewport_top_y(), expected_viewport_top_y); - } - - #[cfg(target_os = "macos")] - #[test] - fn worker_pairwise_vision_handles_repeated_browser_like_frame_between_growth_steps() { - let base = make_browser_like_window(512, 640, 0); - let step_one = make_browser_like_window(512, 640, 180); - let step_two = make_browser_like_window(512, 640, 360); - let first_match = - scroll_capture::classify_vision_downward_sample_motion_against(&base, &step_one) - .expect("first browser-like step should register downward motion"); - let followup_match = - scroll_capture::classify_vision_downward_sample_motion_against(&step_one, &step_two) - .expect("followup browser-like step should register downward motion"); - let mut session = ScrollSession::new(base, 320).unwrap(); - - assert_eq!( - session.observe_worker_pairwise_vision_frame(step_one.clone()).unwrap(), - ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: first_match.motion_rows, - } - ); - assert_eq!( - session.observe_worker_pairwise_vision_frame(step_one).unwrap(), - ScrollObserveOutcome::NoChange - ); - assert_eq!( - session.observe_worker_pairwise_vision_frame(step_two).unwrap(), - ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: followup_match.motion_rows, - } - ); - assert_eq!( - session.export_image().height(), - 640 + first_match.motion_rows + followup_match.motion_rows - ); - } - - #[cfg(target_os = "macos")] - #[test] - fn worker_pairwise_vision_browser_like_followup_uses_adjacent_worker_frame() { - let base = make_browser_like_window(512, 640, 0); - let blocked = make_browser_like_window(512, 640, 700); - let followup = make_browser_like_window(512, 640, 784); - let matched = - scroll_capture::classify_vision_downward_sample_motion_against(&blocked, &followup) - .expect( - "browser-like pairwise registration should use the immediately previous worker frame", - ); - let mut session = ScrollSession::new(base, 320).unwrap(); - - assert_eq!( - session.observe_worker_pairwise_vision_frame(blocked).unwrap(), - ScrollObserveOutcome::NoChange - ); - assert_eq!( - session.observe_worker_pairwise_vision_frame(followup).unwrap(), - ScrollObserveOutcome::Committed { - direction: ScrollDirection::Down, - growth_rows: matched.motion_rows, - } - ); - assert_eq!(session.export_image().height(), 640 + matched.motion_rows); - assert_eq!(session.current_viewport_top_y(), i32::try_from(matched.motion_rows).unwrap()); - } - - #[test] - fn session_supports_multiple_downward_growth_steps() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - ]; - let mut session = ScrollSession::new(make_window(&document, 3, 0, 5), 320).unwrap(); - - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 2, 5)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - assert_eq!(session.export_image().height(), 7); - assert_eq!(session.export_image().get_pixel(0, 0), &Rgba([10, 0, 0, 255])); - assert_eq!(session.export_image().get_pixel(0, 6), &Rgba([70, 0, 0, 255])); - } - - #[test] - fn downward_hot_path_falls_back_when_scroll_step_grows() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - [80, 0, 0, 255], - [90, 0, 0, 255], - ]; - let mut session = ScrollSession::new(make_window(&document, 3, 0, 5), 320).unwrap(); - - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 4, 5)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 3 } - ); - assert_eq!(session.export_image().height(), 9); - assert_eq!(session.export_image().get_pixel(0, 0), &Rgba([10, 0, 0, 255])); - assert_eq!(session.export_image().get_pixel(0, 8), &Rgba([90, 0, 0, 255])); - } - - #[test] - fn session_reports_upward_motion_without_growing() { - let base = make_test_image( - 3, - &[[20, 0, 0, 255], [30, 0, 0, 255], [40, 0, 0, 255], [50, 0, 0, 255], [60, 0, 0, 255]], - ); - let moved = make_test_image( - 3, - &[[10, 0, 0, 255], [20, 0, 0, 255], [30, 0, 0, 255], [40, 0, 0, 255], [50, 0, 0, 255]], - ); - let mut session = ScrollSession::new(base.clone(), 320).unwrap(); - let outcome = session.observe_downward_sample(moved).unwrap(); - - assert!(matches!( - outcome, - ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } - | ScrollObserveOutcome::PreviewUpdated - | ScrollObserveOutcome::NoChange - )); - assert_eq!(session.export_image(), &base); - } - - #[test] - fn pure_upward_sequence_never_commits_growth() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - [80, 0, 0, 255], - [90, 0, 0, 255], - [100, 0, 0, 255], - ]; - let mut session = ScrollSession::new(make_window(&document, 3, 5, 5), 320).unwrap(); - let initial_height = session.export_image().height(); - - for start_row in (0..5).rev() { - assert!(matches!( - session.observe_downward_sample(make_window(&document, 3, start_row, 5)).unwrap(), - ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } - | ScrollObserveOutcome::PreviewUpdated - | ScrollObserveOutcome::NoChange - )); - assert_eq!(session.export_image().height(), initial_height); - } - } - - #[test] - fn low_information_motion_does_not_commit_growth() { - let base = make_test_image( - 3, - &[[10, 0, 0, 255], [10, 0, 0, 255], [11, 0, 0, 255], [11, 0, 0, 255], [12, 0, 0, 255]], - ); - let moved = make_test_image( - 3, - &[[10, 0, 0, 255], [11, 0, 0, 255], [11, 0, 0, 255], [12, 0, 0, 255], [12, 0, 0, 255]], - ); - let mut session = ScrollSession::new(base.clone(), 320).unwrap(); - let outcome = session.observe_downward_sample(moved).unwrap(); - - assert!(matches!( - outcome, - ScrollObserveOutcome::PreviewUpdated - | ScrollObserveOutcome::NoChange - | ScrollObserveOutcome::UnsupportedDirection { .. } - )); - assert_eq!(session.export_image(), &base); - } - - #[test] - fn session_commits_growth_with_sparse_informative_columns() { - let base = make_sparse_textlike_window(256, 120, 0); - let moved = make_sparse_textlike_window(256, 120, 9); - let mut session = ScrollSession::new(base, 320).unwrap(); - let outcome = session.observe_downward_sample(moved).unwrap(); - - assert_eq!( - outcome, - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 9 } - ); - assert_eq!(session.export_image().height(), 129); - } - - #[test] - fn session_commits_growth_with_sparse_columns_and_moving_edge_scrollbar() { - let base = make_sparse_textlike_window_with_moving_edge_scrollbar(256, 120, 0, 8); - let moved = make_sparse_textlike_window_with_moving_edge_scrollbar(256, 120, 9, 40); - let mut session = ScrollSession::new(base, 320).unwrap(); - let outcome = session.observe_downward_sample(moved).unwrap(); - - assert_eq!( - outcome, - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 9 } - ); - assert_eq!(session.export_image().height(), 129); - } - - #[test] - fn repeated_periodic_content_fails_closed_when_downward_registration_is_ambiguous() { - let document: Vec<[u8; 4]> = (0..256) - .map(|row| { - let bucket = (row % 32) as u8; - - [ - bucket.saturating_mul(7), - 255_u8.saturating_sub(bucket.saturating_mul(3)), - bucket.saturating_mul(5), - 255, - ] - }) - .collect(); - let base = make_window(&document, 8, 0, 96); - let moved = make_window(&document, 8, 24, 96); - let mut session = ScrollSession::new(base.clone(), 320).unwrap(); - - assert!(matches!( - session.observe_downward_sample(moved).unwrap(), - ScrollObserveOutcome::PreviewUpdated | ScrollObserveOutcome::NoChange - )); - assert_eq!(session.export_image(), &base); - assert_eq!(session.current_viewport_top_y, 0); - } - - #[test] - fn sparse_textlike_small_downward_steps_eventually_append() { - let base = make_sparse_textlike_window(256, 120, 0); - let mut session = ScrollSession::new(base, 320).unwrap(); - let initial_height = session.export_image().height(); - let mut committed = 0_u32; - - for start_row in 1..=8 { - if matches!( - session - .observe_downward_sample(make_sparse_textlike_window(256, 120, start_row)) - .unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, .. } - ) { - committed = committed.saturating_add(1); - } - } - - assert!(committed > 0); - assert!(session.export_image().height() > initial_height); - } - - #[test] - fn observed_sample_requires_meaningful_overlap_before_committing_large_motion() { - let document = (0_u16..320) - .map(|row| { - [((row * 17) % 251) as u8, ((row * 47) % 251) as u8, ((row * 89) % 251) as u8, 255] - }) - .collect::>(); - let base = make_window(&document, 3, 0, 160); - let mut session = ScrollSession::new(base.clone(), 320).unwrap(); - - session.last_motion_rows_hint = Some(128); - - let far = make_window(&document, 3, 130, 160); - let export_before = session.export_image().clone(); - let preview_before = session.preview_image().clone(); - - assert!(matches!( - session.observe_downward_sample(far).unwrap(), - ScrollObserveOutcome::PreviewUpdated | ScrollObserveOutcome::NoChange - )); - assert_eq!(session.export_image(), &export_before); - assert_eq!(session.preview_image(), &preview_before); - assert_eq!(session.current_viewport_top_y, 0); - } - - #[test] - fn periodic_far_downward_frame_does_not_use_full_range_fallback_after_local_miss() { - let document = (0_u16..128) - .map(|row| { - let phase = (row % 40) as u8; - - [phase.saturating_mul(5), phase.saturating_mul(7), phase.saturating_mul(11), 255] - }) - .collect::>(); - let mut session = ScrollSession::new(make_window(&document, 3, 0, 48), 320).unwrap(); - - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 9, 48)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 9 } - ); - - let far = make_window(&document, 3, 40, 48); - let match_eval = session.diagnose_reference_overlap_direction( - &session.last_sample_frame, - &far, - ScrollDirection::Down, - session.last_motion_rows_hint, - ); - - assert_eq!(session.last_motion_rows_hint, Some(9)); - assert!(match_eval.preferred_only_match.is_none()); - assert!(match_eval.final_match.is_none()); - assert!(!match_eval.used_full_range_fallback); - - let export_before = session.export_image().clone(); - let preview_before = session.preview_image().clone(); - let outcome = session.observe_downward_sample(far).unwrap(); - - assert!(matches!( - outcome, - ScrollObserveOutcome::PreviewUpdated - | ScrollObserveOutcome::NoChange - | ScrollObserveOutcome::UnsupportedDirection { .. } - )); - assert_eq!(session.export_image(), &export_before); - assert_eq!(session.preview_image(), &preview_before); - } - - #[test] - fn committed_growth_rewrites_motion_hint_to_actual_growth_rows() { - let document = (0_u16..160) - .map(|row| { - [((row * 17) % 251) as u8, ((row * 47) % 251) as u8, ((row * 89) % 251) as u8, 255] - }) - .collect::>(); - let mut session = ScrollSession::new(make_window(&document, 3, 0, 48), 320).unwrap(); - - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 20, 48)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 20 } - ); - assert_eq!(session.last_motion_rows_hint, Some(20)); - assert_eq!( - session - .observe_downward_growth_to_viewport( - make_window(&document, 3, 24, 48), - 24, - true, - Some(MotionObservation { direction: ScrollDirection::Down, motion_rows: 64 }), - "test_residual_growth_rewrites_hint", - ) - .unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 4 } - ); - assert_eq!(session.last_motion_rows_hint, Some(4)); - } - - #[test] - fn hinted_downward_registration_does_not_escape_to_far_full_range_match() { - let document = (0_u16..320) - .map(|row| { - [((row * 17) % 251) as u8, ((row * 47) % 251) as u8, ((row * 89) % 251) as u8, 255] - }) - .collect::>(); - let previous = make_window(&document, 3, 0, 160); - let next = make_window(&document, 3, 100, 160); - let session = ScrollSession::new(previous.clone(), 320).unwrap(); - - assert!(matches!( - session.evaluate_reference_downward_registration(&previous, &next, None, true), - DownwardRegistration::Matched(DirectionMatch { motion_rows: 100, .. }) - )); - assert_eq!( - session.evaluate_reference_downward_registration(&previous, &next, Some(20), true), - DownwardRegistration::NoMatch - ); - } - - #[test] - fn active_preview_helpers_stay_committed_even_with_provisional_like_session_state() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - [80, 0, 0, 255], - ]; - let base = make_window(&document, 3, 0, 5); - let latest = make_window(&document, 3, 1, 5); - let mut session = ScrollSession::new(base.clone(), 320).unwrap(); - - session.last_sample_frame = latest.clone(); - session.observed_viewport_top_y = 1; - - assert_eq!(session.preview_display_mode(), "committed"); - assert_eq!(session.preview_display_image(), session.export_image().clone()); - } - - #[test] - fn upward_motion_does_not_reset_downward_progress() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - [80, 0, 0, 255], - ]; - let mut session = ScrollSession::new(make_window(&document, 3, 0, 5), 320).unwrap(); - - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - assert!(matches!( - session.observe_downward_sample(make_window(&document, 3, 0, 5)).unwrap(), - ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } - | ScrollObserveOutcome::PreviewUpdated - | ScrollObserveOutcome::NoChange - )); - - let resume_outcome = - session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(); - - assert!(matches!( - resume_outcome, - ScrollObserveOutcome::NoChange - | ScrollObserveOutcome::PreviewUpdated - | ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } - )); - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 2, 5)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 3, 5)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - assert_eq!(session.export_image().height(), 8); - assert_eq!(session.export_image().get_pixel(0, 0), &Rgba([10, 0, 0, 255])); - assert_eq!(session.export_image().get_pixel(0, 7), &Rgba([80, 0, 0, 255])); - } - - #[test] - fn upward_input_never_commits_lower_frame_and_does_not_advance_frontier() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - ]; - let mut session = ScrollSession::new(make_window(&document, 3, 0, 5), 320).unwrap(); - - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - - let height_after_first_append = session.export_image().height(); - - assert!(matches!( - session.observe_upward_sample(make_window(&document, 3, 2, 5)).unwrap(), - ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } - | ScrollObserveOutcome::PreviewUpdated - | ScrollObserveOutcome::NoChange - )); - assert_eq!(session.export_image().height(), height_after_first_append); - assert!(matches!( - session.observe_upward_sample(make_window(&document, 3, 2, 5)).unwrap(), - ScrollObserveOutcome::PreviewUpdated | ScrollObserveOutcome::NoChange - )); - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 2, 5)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - } - - #[test] - fn upward_rewind_blocks_partial_downward_recovery_until_baseline() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - [80, 0, 0, 255], - ]; - let mut session = ScrollSession::new(make_window(&document, 3, 0, 5), 320).unwrap(); - - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 2, 5)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - assert!(matches!( - session.observe_downward_sample(make_window(&document, 3, 0, 5)).unwrap(), - ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } - | ScrollObserveOutcome::PreviewUpdated - | ScrollObserveOutcome::NoChange - )); - - let height_after_upward_rewind = session.export_image().height(); - - assert!(matches!( - session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), - ScrollObserveOutcome::NoChange - | ScrollObserveOutcome::PreviewUpdated - | ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } - )); - assert_eq!(session.export_image().height(), height_after_upward_rewind); - - let partial_resume_outcome = - session.observe_downward_sample(make_window(&document, 3, 2, 5)).unwrap(); - - assert!(matches!( - partial_resume_outcome, - ScrollObserveOutcome::NoChange - | ScrollObserveOutcome::PreviewUpdated - | ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } - )); - assert_eq!(session.export_image().height(), height_after_upward_rewind); - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 3, 5)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - } - - #[test] - fn returning_below_last_committed_viewport_does_not_duplicate_growth() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - [80, 0, 0, 255], - ]; - let mut session = ScrollSession::new(make_window(&document, 3, 0, 5), 320).unwrap(); - - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 2, 5)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - - let height_before_resume = session.export_image().height(); - - assert!(matches!( - session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), - ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } - | ScrollObserveOutcome::PreviewUpdated - | ScrollObserveOutcome::NoChange - )); - - let return_outcome = - session.observe_downward_sample(make_window(&document, 3, 2, 5)).unwrap(); - - assert!(matches!( - return_outcome, - ScrollObserveOutcome::NoChange - | ScrollObserveOutcome::PreviewUpdated - | ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } - )); - assert_eq!(session.export_image().height(), height_before_resume); - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 3, 5)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - assert_eq!(session.export_image().height(), 8); - assert_eq!(session.export_image().get_pixel(0, 0), &Rgba([10, 0, 0, 255])); - assert_eq!(session.export_image().get_pixel(0, 7), &Rgba([80, 0, 0, 255])); - } - - #[test] - fn downward_input_upward_like_frame_does_not_arm_resume_frontier_or_poison_sample() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - [70, 0, 0, 255], - [80, 0, 0, 255], - ]; - let mut session = ScrollSession::new(make_window(&document, 3, 0, 5), 320).unwrap(); - - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 2, 5)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - - let sample_before = session.last_sample_frame.clone(); - let sample_fingerprint_before = session.last_sample_fingerprint.clone(); - let height_before = session.export_image().height(); - - assert!(matches!( - session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), - ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } - | ScrollObserveOutcome::PreviewUpdated - | ScrollObserveOutcome::NoChange - )); - assert_eq!(session.export_image().height(), height_before); - assert_eq!(session.current_viewport_top_y, 2); - assert_eq!(session.observed_viewport_top_y, 2); - assert_eq!(session.resume_frontier_top_y, None); - assert!(!session.resume_frontier_requires_reacquire); - assert_eq!(session.last_sample_frame, sample_before); - assert_eq!(session.last_sample_fingerprint, sample_fingerprint_before); - } - - #[test] - fn viewport_selection_fails_closed_when_observed_and_committed_authority_conflict() { - let observed = DownwardViewportCandidate { - source: DownwardViewportCandidateSource::ObservedSample, - viewport_top_y: 120, - motion_rows: 20, - mean_abs_diff_x100: 100, - }; - let committed = DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 360, - motion_rows: 260, - mean_abs_diff_x100: 90, - }; - let mut candidates = [observed, committed]; - - assert_eq!( - scroll_capture::select_downward_viewport_candidate(&mut candidates), - DownwardViewportResolution::Ambiguous { preferred: committed, competing: observed } - ); - } - - #[test] - fn committed_keyframe_candidate_requires_meaningful_overlap() { - let document = (0_u16..96) - .map(|row| { - [((row * 17) % 251) as u8, ((row * 47) % 251) as u8, ((row * 89) % 251) as u8, 255] - }) - .collect::>(); - let session = ScrollSession::new(make_window(&document, 3, 0, 48), 320).unwrap(); - let mut candidates = Vec::new(); - - session.push_downward_viewport_candidate( - &session.anchor_frame, - 0, - &make_window(&document, 3, 40, 48), - DownwardViewportCandidateSource::CommittedKeyframe, - &mut candidates, - ); - - assert!(candidates.is_empty()); - } - - #[test] - fn committed_fallback_can_recover_from_an_older_recent_keyframe() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - assert_eq!( - session.observe_downward_sample(make_sparse_textlike_window(256, 120, 18)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 18 } - ); - assert_eq!( - session.observe_downward_sample(make_sparse_textlike_window(256, 120, 29)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 11 } - ); - - session.last_committed_frame = - image::RgbaImage::from_pixel(256, 120, Rgba([255, 255, 255, 255])); - - let target = make_sparse_textlike_window(256, 120, 39); - let mut candidates = Vec::new(); - - session.collect_committed_downward_viewport_candidates(&target, &mut candidates); - - assert!(candidates.iter().any(|candidate| { - candidate.source == DownwardViewportCandidateSource::CommittedKeyframe - && candidate.viewport_top_y == 39 - })); - } - - #[test] - fn fallback_committed_candidates_ignore_older_recent_keyframes() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - assert_eq!( - session.observe_downward_sample(make_sparse_textlike_window(256, 120, 18)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 18 } - ); - assert_eq!( - session.observe_downward_sample(make_sparse_textlike_window(256, 120, 29)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 11 } - ); - - session.last_committed_frame = - image::RgbaImage::from_pixel(256, 120, Rgba([255, 255, 255, 255])); - - let target = make_sparse_textlike_window(256, 120, 39); - let mut candidates = Vec::new(); - - session.collect_fallback_downward_viewport_candidates(&target, &mut candidates); - - assert!(candidates.is_empty()); - } - - #[test] - fn fallback_committed_growth_respects_local_continuity_budget() { - let document = (0_u16..220) - .map(|row| { - [((row * 17) % 251) as u8, ((row * 47) % 251) as u8, ((row * 89) % 251) as u8, 255] - }) - .collect::>(); - let mut session = ScrollSession::new(make_window(&document, 3, 0, 64), 320).unwrap(); - - assert_eq!( - session.observe_downward_sample(make_window(&document, 3, 20, 64)).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 20 } - ); - - session.last_motion_rows_hint = Some(2); - session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { - frame: make_window(&document, 3, 24, 64), - viewport_top_y: 24, - }); - - assert!(session.fallback_downward_growth_exceeds_continuity_budget(33)); - assert!(!session.fallback_downward_growth_exceeds_continuity_budget(32)); - } - - #[test] - fn nearby_local_candidate_wins_when_committed_is_only_modestly_better() { - let observed = DownwardViewportCandidate { - source: DownwardViewportCandidateSource::ObservedSample, - viewport_top_y: 132, - motion_rows: 12, - mean_abs_diff_x100: 120, - }; - let committed = DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 130, - motion_rows: 10, - mean_abs_diff_x100: 80, - }; - let mut candidates = [observed, committed]; - - assert_eq!( - scroll_capture::select_downward_viewport_candidate(&mut candidates), - DownwardViewportResolution::Selected(observed) - ); - } - - #[test] - fn burst_observed_sample_candidate_is_suppressed_when_it_far_exceeds_local_continuity_budget() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 14; - session.last_motion_rows_hint = Some(2); - session.transient_motion_rows_hint = Some(1_219); - session.transient_burst_search_enabled = true; - - assert!(session.should_suppress_observed_sample_candidate(DownwardViewportCandidate { - source: DownwardViewportCandidateSource::ObservedSample, - viewport_top_y: 419, - motion_rows: 413, - mean_abs_diff_x100: 0, - })); - } - - #[test] - fn burst_observed_sample_candidate_is_kept_when_it_stays_within_local_continuity_budget() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 14; - session.last_motion_rows_hint = Some(9); - session.transient_motion_rows_hint = Some(74); - session.transient_burst_search_enabled = true; - - assert!(!session.should_suppress_observed_sample_candidate(DownwardViewportCandidate { - source: DownwardViewportCandidateSource::ObservedSample, - viewport_top_y: 30, - motion_rows: 16, - mean_abs_diff_x100: 0, - })); - } - - #[test] - fn burst_observed_sample_candidate_near_recent_continuity_can_exceed_budget_without_suppression() - { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 130; - session.last_motion_rows_hint = Some(38); - session.transient_motion_rows_hint = Some(1_150); - session.transient_burst_search_enabled = true; - - assert!(!session.should_suppress_observed_sample_candidate(DownwardViewportCandidate { - source: DownwardViewportCandidateSource::ObservedSample, - viewport_top_y: 162, - motion_rows: 32, - mean_abs_diff_x100: 533, - })); - } - - #[test] - fn burst_observed_sample_candidate_near_recent_continuity_still_suppresses_when_diff_is_too_high() - { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 14; - session.last_motion_rows_hint = Some(9); - session.transient_motion_rows_hint = Some(1_219); - session.transient_burst_search_enabled = true; - - assert!(session.should_suppress_observed_sample_candidate(DownwardViewportCandidate { - source: DownwardViewportCandidateSource::ObservedSample, - viewport_top_y: 31, - motion_rows: 17, - mean_abs_diff_x100: 733, - })); - } - - #[test] - fn corroborated_observed_candidate_can_recover_after_initial_continuity_suppression() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 149; - session.last_motion_rows_hint = Some(16); - session.transient_motion_rows_hint = Some(12); - session.transient_burst_search_enabled = true; - - let candidate = DownwardViewportCandidate { - source: DownwardViewportCandidateSource::ObservedSample, - viewport_top_y: 169, - motion_rows: 20, - mean_abs_diff_x100: 0, - }; - let mut candidates = vec![DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 169, - motion_rows: 20, - mean_abs_diff_x100: 0, - }]; - - assert!(session.should_suppress_observed_sample_candidate(candidate)); - - session.restore_corroborated_observed_candidate(Some(candidate), &mut candidates); - - assert!(candidates.contains(&candidate)); - } - - #[test] - fn tiny_observed_recovery_fails_closed_during_large_transient_burst() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 261; - session.last_motion_rows_hint = Some(24); - session.transient_motion_rows_hint = Some(86); - session.transient_burst_search_enabled = true; - - assert!(session.should_fail_closed_tiny_observed_recovery_in_burst( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::ObservedSample, - viewport_top_y: 263, - motion_rows: 2, - mean_abs_diff_x100: 0, - } - )); - } - - #[test] - fn tiny_observed_recovery_does_not_block_when_recent_continuity_is_small() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 14; - session.last_motion_rows_hint = Some(2); - session.transient_motion_rows_hint = Some(1_217); - session.transient_burst_search_enabled = true; - - assert!(!session.should_fail_closed_tiny_observed_recovery_in_burst( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::ObservedSample, - viewport_top_y: 15, - motion_rows: 1, - mean_abs_diff_x100: 0, - } - )); - } - - #[test] - fn outsized_observed_recovery_after_one_pixel_preview_local_commit_fails_closed() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 74; - session.last_motion_rows_hint = Some(1); - session.transient_motion_rows_hint = Some(277); - session.transient_burst_search_enabled = true; - - session.growth_history.push(super::GrowthCommit { - frame: make_sparse_textlike_window(256, 120, 74), - growth_rows: 1, - viewport_top_y: 74, - decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample - .decision_source(), - detected_motion_rows: Some(1), - effective_motion_rows_hint: Some(277), - }); - - assert!( - session - .should_fail_closed_outsized_observed_recovery_after_one_pixel_preview_local_commit( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::ObservedSample, - viewport_top_y: 82, - motion_rows: 8, - mean_abs_diff_x100: 0, - }, - ) - ); - } - - #[test] - fn tiny_observed_burst_block_keeps_preview_local_baseline_stable() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 261; - session.observed_viewport_top_y = 261; - session.last_motion_rows_hint = Some(24); - session.transient_motion_rows_hint = Some(86); - session.transient_burst_search_enabled = true; - - session.refresh_preview_only_downward_local_sample( - &make_sparse_textlike_window(256, 120, 261), - session.stable_preview_only_downward_local_viewport_top_y(), - ); - - assert_eq!( - session - .last_preview_only_downward_local_sample - .as_ref() - .map(|sample| sample.viewport_top_y), - Some(261) - ); - } - - #[test] - fn tiny_preview_only_local_recovery_fails_closed_during_large_transient_burst() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(7); - session.transient_motion_rows_hint = Some(167); - session.transient_burst_search_enabled = true; - - assert!(session.should_fail_closed_tiny_preview_only_local_recovery_in_burst( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 303, - motion_rows: 1, - mean_abs_diff_x100: 232, - } - )); - } - - #[test] - fn tiny_preview_only_local_recovery_does_not_block_recorded_small_commit() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(2); - session.transient_motion_rows_hint = Some(1_217); - session.transient_burst_search_enabled = true; - - assert!(!session.should_fail_closed_tiny_preview_only_local_recovery_in_burst( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 15, - motion_rows: 1, - mean_abs_diff_x100: 97, - } - )); - } - - #[test] - fn small_preview_only_local_recovery_lagging_recent_continuity_fails_closed_during_burst() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(26); - session.transient_motion_rows_hint = Some(356); - session.transient_burst_search_enabled = true; - - assert!(session.should_fail_closed_tiny_preview_only_local_recovery_in_burst( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 84, - motion_rows: 6, - mean_abs_diff_x100: 0, - } - )); - } - - #[test] - fn preview_only_local_tail_after_unresolved_burst_fails_closed() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 360; - session.last_block_reason = Some("no_downward_viewport_candidate_resolved"); - session.last_motion_rows_hint = Some(9); - session.transient_motion_rows_hint = Some(1_002); - session.transient_burst_search_enabled = true; - session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { - frame: make_sparse_textlike_window(256, 120, 360), - viewport_top_y: 360, - }); - - assert!(session.should_fail_closed_preview_only_local_tail_after_unresolved_burst( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 378, - motion_rows: 18, - mean_abs_diff_x100: 0, - } - )); - } - - #[test] - fn preview_only_local_tail_after_unresolved_burst_does_not_block_without_extreme_gap() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 360; - session.last_block_reason = Some("no_downward_viewport_candidate_resolved"); - session.last_motion_rows_hint = Some(9); - session.transient_motion_rows_hint = Some(18); - session.transient_burst_search_enabled = true; - session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { - frame: make_sparse_textlike_window(256, 120, 360), - viewport_top_y: 360, - }); - - assert!(!session.should_fail_closed_preview_only_local_tail_after_unresolved_burst( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 378, - motion_rows: 18, - mean_abs_diff_x100: 0, - } - )); - } - - #[test] - fn preview_only_local_tail_after_unresolved_burst_does_not_block_after_registered_growth_matches_pending_band() - { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 184; - session.last_block_reason = Some("no_downward_viewport_candidate_resolved"); - session.last_motion_rows_hint = Some(1); - session.transient_motion_rows_hint = Some(277); - session.transient_burst_search_enabled = true; - session.pending_unresolved_burst_registered_growth_viewport_top_y = Some(461); - session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { - frame: make_sparse_textlike_window(256, 120, 184), - viewport_top_y: 184, - }); - - assert!(!session.should_fail_closed_preview_only_local_tail_after_unresolved_burst( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 186, - motion_rows: 2, - mean_abs_diff_x100: 125, - } - )); - } - - #[test] - fn exactly_corroborated_preview_local_tail_fails_closed_in_extreme_transient_burst() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(10); - session.transient_motion_rows_hint = Some(1_057); - session.transient_burst_search_enabled = true; - session.last_observed_sample_registration_result = Some("matched"); - session.last_observed_sample_registration_motion_rows = Some(20); - session.last_downward_viewport_candidates_before_prune = - Some("PreviewOnlyLocalSample@472/20:0,CommittedKeyframe@472/20:0".to_string()); - - for (viewport_top_y, growth_rows) in [(442_i32, 8_u32), (452_i32, 10_u32)] { - session.growth_history.push(super::GrowthCommit { - frame: make_sparse_textlike_window( - 256, - 120, - u32::try_from(viewport_top_y).unwrap(), - ), - growth_rows, - viewport_top_y, - decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample - .decision_source(), - detected_motion_rows: Some(growth_rows), - effective_motion_rows_hint: Some(1_057), - }); - } - - assert!( - session.should_fail_closed_exactly_corroborated_preview_local_tail_in_extreme_burst( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 472, - motion_rows: 20, - mean_abs_diff_x100: 0, - }, - ) - ); - } - - #[test] - fn moderate_transient_preview_local_tail_is_not_blocked_by_extreme_burst_rule() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(20); - session.transient_motion_rows_hint = Some(110); - session.transient_burst_search_enabled = true; - session.last_observed_sample_registration_result = Some("matched"); - session.last_observed_sample_registration_motion_rows = Some(24); - session.last_downward_viewport_candidates_before_prune = - Some("PreviewOnlyLocalSample@261/24:329,CommittedKeyframe@512/275:460".to_string()); - - session.growth_history.push(super::GrowthCommit { - frame: make_sparse_textlike_window(256, 120, 237), - growth_rows: 20, - viewport_top_y: 237, - decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample - .decision_source(), - detected_motion_rows: Some(20), - effective_motion_rows_hint: Some(110), - }); - session.growth_history.push(super::GrowthCommit { - frame: make_sparse_textlike_window(256, 120, 217), - growth_rows: 18, - viewport_top_y: 217, - decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample - .decision_source(), - detected_motion_rows: Some(18), - effective_motion_rows_hint: Some(104), - }); - - assert!( - !session.should_fail_closed_exactly_corroborated_preview_local_tail_in_extreme_burst( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 261, - motion_rows: 24, - mean_abs_diff_x100: 329, - }, - ) - ); - } - - #[test] - fn burst_prefers_observed_sample_over_underconsumed_preview_only_local_recovery() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(38); - session.transient_motion_rows_hint = Some(1_150); - session.transient_burst_search_enabled = true; - - let primary = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 120, motion_rows: 32 }, - source: DownwardSampleMatchSource::ObservedSample, - }; - let local = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 0, motion_rows: 8 }, - source: DownwardSampleMatchSource::PreviewOnlyLocalSample, - }; - - assert!( - session.should_prefer_observed_sample_over_preview_only_local_recovery(primary, local) - ); - } - - #[test] - fn burst_keeps_preview_only_local_recovery_when_observed_is_only_modestly_ahead() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(38); - session.transient_motion_rows_hint = Some(1_150); - session.transient_burst_search_enabled = true; - - let primary = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 120, motion_rows: 16 }, - source: DownwardSampleMatchSource::ObservedSample, - }; - let local = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 0, motion_rows: 8 }, - source: DownwardSampleMatchSource::PreviewOnlyLocalSample, - }; - - assert!( - !session.should_prefer_observed_sample_over_preview_only_local_recovery(primary, local) - ); - } - - #[test] - fn tiny_recent_continuity_burst_prefers_preview_local_over_far_ahead_observed_sample() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(2); - session.transient_motion_rows_hint = Some(225); - session.transient_burst_search_enabled = true; - - let primary = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 0, motion_rows: 12 }, - source: DownwardSampleMatchSource::ObservedSample, - }; - let local = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 405, motion_rows: 3 }, - source: DownwardSampleMatchSource::PreviewOnlyLocalSample, - }; - - assert!( - session.should_prefer_preview_only_local_recovery_over_observed_sample(primary, local) - ); - } - - #[test] - fn tiny_recent_continuity_burst_does_not_force_preview_local_when_observed_is_still_nearby() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(2); - session.transient_motion_rows_hint = Some(225); - session.transient_burst_search_enabled = true; - - let primary = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 0, motion_rows: 6 }, - source: DownwardSampleMatchSource::ObservedSample, - }; - let local = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 405, motion_rows: 3 }, - source: DownwardSampleMatchSource::PreviewOnlyLocalSample, - }; - - assert!( - !session.should_prefer_preview_only_local_recovery_over_observed_sample(primary, local) - ); - } - - #[test] - fn tiny_recent_continuity_burst_does_not_force_one_pixel_preview_local_recovery() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(2); - session.transient_motion_rows_hint = Some(1_211); - session.transient_burst_search_enabled = true; - - let primary = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 553, motion_rows: 413 }, - source: DownwardSampleMatchSource::ObservedSample, - }; - let local = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 97, motion_rows: 1 }, - source: DownwardSampleMatchSource::PreviewOnlyLocalSample, - }; - - assert!( - !session.should_prefer_preview_only_local_recovery_over_observed_sample(primary, local) - ); - } - - #[test] - fn repeated_missing_burst_frames_can_prefer_one_pixel_preview_local_recovery() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(2); - session.transient_motion_rows_hint = Some(277); - session.transient_burst_search_enabled = true; - session.consecutive_transient_burst_missing_downward_candidate_frames = 2; - - let primary = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 0, motion_rows: 116 }, - source: DownwardSampleMatchSource::ObservedSample, - }; - let local = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 149, motion_rows: 1 }, - source: DownwardSampleMatchSource::PreviewOnlyLocalSample, - }; - - assert!( - session.should_prefer_preview_only_local_recovery_over_observed_sample(primary, local) - ); - } - - #[test] - fn preview_local_slowdown_followup_can_prefer_one_pixel_preview_local_recovery() { - let previous = make_sparse_textlike_window(256, 120, 16); - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(4); - session.transient_motion_rows_hint = Some(29); - session.transient_burst_search_enabled = true; - session.last_preview_only_downward_local_sample = - Some(PreviewOnlyDownwardLocalSample { frame: previous.clone(), viewport_top_y: 145 }); - - session.growth_history.push(GrowthCommit { - frame: previous, - growth_rows: 4, - viewport_top_y: 145, - decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample - .decision_source(), - detected_motion_rows: Some(4), - effective_motion_rows_hint: Some(8), - }); - - let primary = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 0, motion_rows: 41 }, - source: DownwardSampleMatchSource::ObservedSample, - }; - let local = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 410, motion_rows: 1 }, - source: DownwardSampleMatchSource::PreviewOnlyLocalSample, - }; - - assert!( - session.should_prefer_preview_only_local_recovery_over_observed_sample(primary, local) - ); - } - - #[test] - fn preview_local_slowdown_followup_can_prefer_near_continuity_preview_local_recovery() { - let previous = make_sparse_textlike_window(256, 120, 16); - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(10); - session.transient_motion_rows_hint = Some(1_150); - session.transient_burst_search_enabled = true; - session.last_preview_only_downward_local_sample = - Some(PreviewOnlyDownwardLocalSample { frame: previous.clone(), viewport_top_y: 416 }); - - session.growth_history.push(GrowthCommit { - frame: previous, - growth_rows: 10, - viewport_top_y: 416, - decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample - .decision_source(), - detected_motion_rows: Some(10), - effective_motion_rows_hint: Some(10), - }); - - let primary = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 0, motion_rows: 158 }, - source: DownwardSampleMatchSource::ObservedSample, - }; - let local = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 697, motion_rows: 12 }, - source: DownwardSampleMatchSource::PreviewOnlyLocalSample, - }; - - assert!( - session.should_prefer_preview_only_local_recovery_over_observed_sample(primary, local) - ); - } - - #[test] - fn preview_local_slowdown_followup_without_recent_small_preview_commit_does_not_prefer_local() { - let previous = make_sparse_textlike_window(256, 120, 16); - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(4); - session.transient_motion_rows_hint = Some(29); - session.transient_burst_search_enabled = true; - session.last_preview_only_downward_local_sample = - Some(PreviewOnlyDownwardLocalSample { frame: previous.clone(), viewport_top_y: 145 }); - - session.growth_history.push(GrowthCommit { - frame: previous, - growth_rows: 12, - viewport_top_y: 145, - decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample - .decision_source(), - detected_motion_rows: Some(12), - effective_motion_rows_hint: Some(12), - }); - - let primary = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 0, motion_rows: 41 }, - source: DownwardSampleMatchSource::ObservedSample, - }; - let local = DownwardSampleMatch { - matched: DirectionMatch { mean_abs_diff_x100: 410, motion_rows: 1 }, - source: DownwardSampleMatchSource::PreviewOnlyLocalSample, - }; - - assert!( - !session.should_prefer_preview_only_local_recovery_over_observed_sample(primary, local) - ); - } - - #[test] - fn observed_burst_catch_up_commit_seeds_preview_local_baseline() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.transient_motion_rows_hint = Some(1_150); - session.transient_burst_search_enabled = true; - - assert!(session.should_seed_preview_only_local_after_observed_burst_commit( - "sample_motion_downward_growth_from_observed_keyframe", - 32, - Some(38), - )); - } - - #[test] - fn non_observed_or_non_catch_up_commit_does_not_seed_preview_local_baseline() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.transient_motion_rows_hint = Some(1_150); - session.transient_burst_search_enabled = true; - - assert!(!session.should_seed_preview_only_local_after_observed_burst_commit( - "sample_motion_downward_growth_from_committed_keyframe", - 32, - Some(38), - )); - assert!(!session.should_seed_preview_only_local_after_observed_burst_commit( - "sample_motion_downward_growth_from_observed_keyframe", - 38, - Some(38), - )); - } - - #[test] - fn preview_local_burst_commit_preserves_local_baseline_for_next_frame() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.transient_motion_rows_hint = Some(226); - session.transient_burst_search_enabled = true; - - assert!(session.should_preserve_preview_only_local_after_preview_only_burst_commit( - "sample_motion_downward_growth_from_preview_only_local_sample", - 18, - Some(12), - )); - } - - #[test] - fn preview_local_burst_commit_does_not_preserve_local_baseline_for_tiny_or_far_growth() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.transient_motion_rows_hint = Some(226); - session.transient_burst_search_enabled = true; - - assert!(!session.should_preserve_preview_only_local_after_preview_only_burst_commit( - "sample_motion_downward_growth_from_preview_only_local_sample", - 1, - Some(12), - )); - assert!(!session.should_preserve_preview_only_local_after_preview_only_burst_commit( - "sample_motion_downward_growth_from_preview_only_local_sample", - 36, - Some(12), - )); - } - - #[test] - fn preview_local_non_burst_small_slowdown_preserves_local_baseline_for_next_frame() { - let session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - assert!(session.should_preserve_preview_only_local_after_preview_only_burst_commit( - "sample_motion_downward_growth_from_preview_only_local_sample", - 4, - Some(8), - )); - } - - #[test] - fn preview_local_non_burst_tiny_or_growing_commit_does_not_preserve_local_baseline() { - let session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - assert!(!session.should_preserve_preview_only_local_after_preview_only_burst_commit( - "sample_motion_downward_growth_from_preview_only_local_sample", - 1, - Some(8), - )); - assert!(!session.should_preserve_preview_only_local_after_preview_only_burst_commit( - "sample_motion_downward_growth_from_preview_only_local_sample", - 10, - Some(8), - )); - } - - #[test] - fn corroborated_huge_local_jump_after_preview_local_commit_blocks_far_committed_only_recovery() - { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 230; - session.last_motion_rows_hint = Some(18); - session.transient_motion_rows_hint = Some(226); - session.transient_burst_search_enabled = true; - session.last_observed_sample_registration_result = Some("matched"); - session.last_observed_sample_registration_motion_rows = Some(164); - session.last_preview_only_local_registration_result = Some("matched"); - session.last_preview_only_local_registration_motion_rows = Some(164); - - session.growth_history.push(super::GrowthCommit { - frame: make_sparse_textlike_window(256, 120, 230), - growth_rows: 18, - viewport_top_y: 230, - decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample - .decision_source(), - detected_motion_rows: Some(18), - effective_motion_rows_hint: Some(226), - }); - - assert!( - session - .should_fail_closed_far_committed_only_recovery_after_corroborated_huge_local_jump( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 394, - motion_rows: 164, - mean_abs_diff_x100: 0, - }, - 164, - ) - ); - } - - #[test] - fn materially_smaller_observed_motion_still_blocks_huge_committed_only_recovery() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 230; - session.last_motion_rows_hint = Some(18); - session.transient_motion_rows_hint = Some(282); - session.transient_burst_search_enabled = true; - session.last_observed_sample_registration_result = Some("matched"); - session.last_observed_sample_registration_motion_rows = Some(112); - session.last_preview_only_local_registration_result = Some("matched"); - session.last_preview_only_local_registration_motion_rows = Some(276); - - session.growth_history.push(super::GrowthCommit { - frame: make_sparse_textlike_window(256, 120, 230), - growth_rows: 18, - viewport_top_y: 230, - decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample - .decision_source(), - detected_motion_rows: Some(18), - effective_motion_rows_hint: Some(282), - }); - - assert!( - session - .should_fail_closed_far_committed_only_recovery_after_corroborated_huge_local_jump( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 506, - motion_rows: 276, - mean_abs_diff_x100: 0, - }, - 276, - ) - ); - } - - #[test] - fn nearby_committed_recovery_is_not_blocked_when_local_jump_is_not_huge() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 230; - session.last_motion_rows_hint = Some(18); - session.transient_motion_rows_hint = Some(226); - session.transient_burst_search_enabled = true; - session.last_observed_sample_registration_result = Some("matched"); - session.last_observed_sample_registration_motion_rows = Some(38); - session.last_preview_only_local_registration_result = Some("matched"); - session.last_preview_only_local_registration_motion_rows = Some(38); - - session.growth_history.push(super::GrowthCommit { - frame: make_sparse_textlike_window(256, 120, 230), - growth_rows: 18, - viewport_top_y: 230, - decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample - .decision_source(), - detected_motion_rows: Some(18), - effective_motion_rows_hint: Some(226), - }); - - assert!( - !session - .should_fail_closed_far_committed_only_recovery_after_corroborated_huge_local_jump( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 268, - motion_rows: 38, - mean_abs_diff_x100: 0, - }, - 38, - ) - ); - } - - #[test] - fn suppressed_huge_preview_local_jump_corroborated_by_observed_and_committed_fails_closed() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 230; - session.last_motion_rows_hint = Some(18); - session.transient_motion_rows_hint = Some(226); - session.transient_burst_search_enabled = true; - session.last_observed_sample_registration_result = Some("matched"); - session.last_observed_sample_registration_motion_rows = Some(164); - - session.growth_history.push(super::GrowthCommit { - frame: make_sparse_textlike_window(256, 120, 230), - growth_rows: 18, - viewport_top_y: 230, - decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample - .decision_source(), - detected_motion_rows: Some(18), - effective_motion_rows_hint: Some(226), - }); - - assert!( - session - .should_fail_closed_suppressed_huge_preview_local_jump_corroborated_by_observed_and_committed( - Some(DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 394, - motion_rows: 164, - mean_abs_diff_x100: 0, - }), - &[DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 394, - motion_rows: 164, - mean_abs_diff_x100: 0, - }], - ) - ); - } - - #[test] - fn suppressed_preview_local_jump_without_exact_committed_corroboration_stays_unblocked() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 230; - session.last_motion_rows_hint = Some(18); - session.transient_motion_rows_hint = Some(226); - session.transient_burst_search_enabled = true; - session.last_observed_sample_registration_result = Some("matched"); - session.last_observed_sample_registration_motion_rows = Some(164); - - session.growth_history.push(super::GrowthCommit { - frame: make_sparse_textlike_window(256, 120, 230), - growth_rows: 18, - viewport_top_y: 230, - decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample - .decision_source(), - detected_motion_rows: Some(18), - effective_motion_rows_hint: Some(226), - }); - - assert!( - !session - .should_fail_closed_suppressed_huge_preview_local_jump_corroborated_by_observed_and_committed( - Some(DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 394, - motion_rows: 164, - mean_abs_diff_x100: 0, - }), - &[DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 398, - motion_rows: 186, - mean_abs_diff_x100: 0, - }], - ) - ); - } - - #[test] - fn committed_followup_after_suppressed_huge_preview_local_jump_fails_closed() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.transient_burst_search_enabled = true; - session.last_preview_only_local_registration_result = Some("no_match"); - session.last_observed_sample_registration_result = Some("matched"); - session.last_observed_sample_registration_motion_rows = Some(164); - - assert!( - session.should_fail_closed_committed_followup_after_suppressed_huge_preview_local_jump( - Some(DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 394, - motion_rows: 164, - mean_abs_diff_x100: 0, - }), - &[ - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 394, - motion_rows: 164, - mean_abs_diff_x100: 0, - }, - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 398, - motion_rows: 186, - mean_abs_diff_x100: 0, - }, - ], - ) - ); - } - - #[test] - fn committed_followup_without_pending_suppressed_preview_local_jump_stays_unblocked() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.transient_burst_search_enabled = true; - session.last_preview_only_local_registration_result = Some("no_match"); - session.last_observed_sample_registration_result = Some("matched"); - session.last_observed_sample_registration_motion_rows = Some(164); - - assert!( - !session - .should_fail_closed_committed_followup_after_suppressed_huge_preview_local_jump( - None, - &[DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 394, - motion_rows: 164, - mean_abs_diff_x100: 0, - }], - ) - ); - } - - #[test] - fn committed_followup_after_extreme_preview_local_tail_block_fails_closed() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.transient_burst_search_enabled = true; - - assert!( - session.should_fail_closed_committed_followup_after_extreme_preview_local_tail_block( - Some(DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 472, - motion_rows: 20, - mean_abs_diff_x100: 0, - }), - &[ - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 472, - motion_rows: 20, - mean_abs_diff_x100: 0, - }, - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 472, - motion_rows: 30, - mean_abs_diff_x100: 0, - }, - ], - ) - ); - } - - #[test] - fn committed_followup_after_extreme_preview_local_tail_block_ignores_non_exact_match() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.transient_burst_search_enabled = true; - - assert!( - !session.should_fail_closed_committed_followup_after_extreme_preview_local_tail_block( - Some(DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 472, - motion_rows: 20, - mean_abs_diff_x100: 0, - }), - &[DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 472, - motion_rows: 30, - mean_abs_diff_x100: 0, - }], - ) - ); - } - - #[test] - fn suppressed_huge_preview_local_followup_block_budget_scales_with_far_recovery_ratio() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(18); - - assert_eq!( - session.suppressed_huge_preview_only_local_followup_block_budget(Some( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 394, - motion_rows: 164, - mean_abs_diff_x100: 0, - }, - )), - 5 - ); - assert_eq!( - session.suppressed_huge_preview_only_local_followup_block_budget(Some( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 290, - motion_rows: 42, - mean_abs_diff_x100: 0, - }, - )), - 3 - ); - } - - #[test] - fn huge_suppressed_jump_window_refreshes_observed_baseline_without_advancing_viewport() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - assert!(!session.should_refresh_downward_observed_baseline_after_huge_suppressed_jump()); - - session.pending_suppressed_huge_preview_only_local_followup = - Some(DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 394, - motion_rows: 164, - mean_abs_diff_x100: 0, - }); - - assert!(session.should_refresh_downward_observed_baseline_after_huge_suppressed_jump()); - - session.pending_suppressed_huge_preview_only_local_followup = None; - session.blocked_followup_after_suppressed_huge_preview_local_jump = true; - - assert!(session.should_refresh_downward_observed_baseline_after_huge_suppressed_jump()); - - session.blocked_followup_after_suppressed_huge_preview_local_jump = false; - session.blocked_far_committed_only_recovery_after_corroborated_huge_local_jump = true; - - assert!(session.should_refresh_downward_observed_baseline_after_huge_suppressed_jump()); - } - - #[test] - fn huge_far_committed_block_resets_preview_only_local_baseline() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.refresh_preview_only_downward_local_sample( - &make_sparse_textlike_window(256, 120, 32), - Some(32), - ); - - assert!(session.last_preview_only_downward_local_sample.is_some()); - assert!(!session.should_reset_preview_only_local_baseline_after_huge_far_committed_block()); - - session.blocked_far_committed_only_recovery_after_corroborated_huge_local_jump = true; - - assert!(session.should_reset_preview_only_local_baseline_after_huge_far_committed_block()); - } - - #[test] - fn seeded_preview_only_local_catch_up_candidate_can_commit_small_tail_growth() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 162; - session.seeded_preview_only_local_after_observed_burst_commit = true; - - assert!(session.seeded_preview_only_local_catch_up_candidate_can_commit( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 170, - motion_rows: 8, - mean_abs_diff_x100: 0, - } - )); - } - - #[test] - fn unseeded_preview_only_local_candidate_still_needs_normal_burst_rules() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 162; - - assert!(!session.seeded_preview_only_local_catch_up_candidate_can_commit( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, - viewport_top_y: 170, - motion_rows: 8, - mean_abs_diff_x100: 0, - } - )); - } - - #[test] - fn seeded_preview_only_local_recovery_range_includes_one_pixel_tail_growth() { - let previous = make_sparse_textlike_window(256, 120, 0); - let next = make_sparse_textlike_window(256, 120, 1); - let mut session = ScrollSession::new(previous.clone(), 320).unwrap(); - - session.last_motion_rows_hint = Some(4); - session.seeded_preview_only_local_after_observed_burst_commit = true; - - let range = session - .preview_only_local_recovery_motion_range( - &previous, - &next, - OverlapSearchConfig::default(), - ) - .unwrap(); - - assert_eq!(*range.start(), 1); - assert_eq!(*range.end(), 6); - } - - #[test] - fn unseeded_preview_only_local_recovery_range_keeps_hint_floor() { - let previous = make_sparse_textlike_window(256, 120, 0); - let next = make_sparse_textlike_window(256, 120, 1); - let mut session = ScrollSession::new(previous.clone(), 320).unwrap(); - - session.last_motion_rows_hint = Some(4); - - let range = session - .preview_only_local_recovery_motion_range( - &previous, - &next, - OverlapSearchConfig::default(), - ) - .unwrap(); - - assert_eq!(*range.start(), 2); - assert_eq!(*range.end(), 6); - } - - #[test] - fn preview_local_slowdown_followup_range_allows_one_pixel_tail_in_burst() { - let previous = make_sparse_textlike_window(256, 120, 16); - let next = make_sparse_textlike_window(256, 120, 17); - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(4); - session.transient_motion_rows_hint = Some(29); - session.transient_burst_search_enabled = true; - session.last_preview_only_downward_local_sample = - Some(PreviewOnlyDownwardLocalSample { frame: previous.clone(), viewport_top_y: 145 }); - - session.growth_history.push(GrowthCommit { - frame: previous.clone(), - growth_rows: 4, - viewport_top_y: 145, - decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample - .decision_source(), - detected_motion_rows: Some(4), - effective_motion_rows_hint: Some(8), - }); - - let range = session - .preview_only_local_recovery_motion_range( - &previous, - &next, - OverlapSearchConfig::default(), - ) - .unwrap(); - - assert_eq!(*range.start(), 1); - assert_eq!(*range.end(), 6); - } - - #[test] - fn preview_local_followup_without_recent_small_preview_commit_keeps_hint_floor_in_burst() { - let previous = make_sparse_textlike_window(256, 120, 16); - let next = make_sparse_textlike_window(256, 120, 17); - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(4); - session.transient_motion_rows_hint = Some(29); - session.transient_burst_search_enabled = true; - session.last_preview_only_downward_local_sample = - Some(PreviewOnlyDownwardLocalSample { frame: previous.clone(), viewport_top_y: 145 }); - - session.growth_history.push(GrowthCommit { - frame: previous.clone(), - growth_rows: 12, - viewport_top_y: 145, - decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample - .decision_source(), - detected_motion_rows: Some(12), - effective_motion_rows_hint: Some(12), - }); - - let range = session - .preview_only_local_recovery_motion_range( - &previous, - &next, - OverlapSearchConfig::default(), - ) - .unwrap(); - - assert_eq!(*range.start(), 2); - assert_eq!(*range.end(), 6); - } - - #[test] - fn tiny_committed_keyframe_recovery_fails_closed_during_large_transient_burst() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 68; - session.last_motion_rows_hint = Some(6); - session.transient_motion_rows_hint = Some(401); - session.transient_burst_search_enabled = true; - - assert!(session.should_fail_closed_tiny_committed_keyframe_recovery_in_burst( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 70, - motion_rows: 12, - mean_abs_diff_x100: 654, - } - )); - } - - #[test] - fn tiny_committed_keyframe_recovery_does_not_block_meaningful_growth_during_burst() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 68; - session.last_motion_rows_hint = Some(6); - session.transient_motion_rows_hint = Some(401); - session.transient_burst_search_enabled = true; - - assert!(!session.should_fail_closed_tiny_committed_keyframe_recovery_in_burst( - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 81, - motion_rows: 23, - mean_abs_diff_x100: 696, - } - )); - } - - #[test] - fn underconsumed_observed_recovery_fails_closed_when_nearby_committed_candidate_reaches_recent_continuity() - { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(20); - session.transient_motion_rows_hint = Some(75); - session.transient_burst_search_enabled = true; - - let candidates_before_prune = vec![ - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::ObservedSample, - viewport_top_y: 289, - motion_rows: 8, - mean_abs_diff_x100: 0, - }, - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 289, - motion_rows: 8, - mean_abs_diff_x100: 0, - }, - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 291, - motion_rows: 30, - mean_abs_diff_x100: 0, - }, - ]; - let candidates_after_prune = vec![ - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::ObservedSample, - viewport_top_y: 289, - motion_rows: 8, - mean_abs_diff_x100: 0, - }, - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 289, - motion_rows: 8, - mean_abs_diff_x100: 0, - }, - ]; - - assert!(session.should_fail_closed_underconsumed_observed_recovery_in_burst( - &candidates_before_prune, - &candidates_after_prune, - )); - } - - #[test] - fn underconsumed_observed_recovery_does_not_block_small_recorded_burst_commit() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(4); - session.transient_motion_rows_hint = Some(466); - session.transient_burst_search_enabled = true; - - let candidates_before_prune = vec![ - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::ObservedSample, - viewport_top_y: 14, - motion_rows: 2, - mean_abs_diff_x100: 6, - }, - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 14, - motion_rows: 2, - mean_abs_diff_x100: 6, - }, - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 14, - motion_rows: 6, - mean_abs_diff_x100: 16, - }, - ]; - let candidates_after_prune = candidates_before_prune[..2].to_vec(); - - assert!(!session.should_fail_closed_underconsumed_observed_recovery_in_burst( - &candidates_before_prune, - &candidates_after_prune, - )); - } - - #[test] - fn low_confidence_committed_only_recovery_without_local_anchor_fails_closed_during_burst() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 134; - session.last_motion_rows_hint = Some(43); - session.transient_motion_rows_hint = Some(1_142); - session.transient_burst_search_enabled = true; - - let candidates = vec![ - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 190, - motion_rows: 56, - mean_abs_diff_x100: 621, - }, - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 157, - motion_rows: 73, - mean_abs_diff_x100: 557, - }, - ]; - - assert!(session.should_fail_closed_far_committed_only_recovery_without_local_anchor( - candidates[1], - &candidates, - )); - } - - #[test] - fn small_continuity_preview_local_registration_blocks_larger_committed_only_recovery() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 62; - session.last_motion_rows_hint = Some(2); - session.transient_motion_rows_hint = Some(225); - session.transient_burst_search_enabled = true; - session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { - frame: make_sparse_textlike_window(256, 120, 31), - viewport_top_y: 62, - }); - session.last_preview_only_local_registration_result = Some("matched"); - session.last_preview_only_local_registration_motion_rows = Some(3); - - let candidates = vec![ - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 74, - motion_rows: 12, - mean_abs_diff_x100: 0, - }, - DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 78, - motion_rows: 14, - mean_abs_diff_x100: 0, - }, - ]; - - assert!(session.should_fail_closed_far_committed_only_recovery_without_local_anchor( - candidates[0], - &candidates, - )); - } - - #[test] - fn suppressed_large_preview_local_registration_blocks_underconsumed_committed_only_recovery() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 202; - session.last_motion_rows_hint = Some(8); - session.transient_motion_rows_hint = Some(575); - session.transient_burst_search_enabled = true; - session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { - frame: make_sparse_textlike_window(256, 120, 31), - viewport_top_y: 202, - }); - session.last_preview_only_local_registration_result = Some("matched"); - session.last_preview_only_local_registration_motion_rows = Some(272); - - let candidates = [DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 220, - motion_rows: 32, - mean_abs_diff_x100: 765, - }]; - - assert!( - session - .should_fail_closed_underconsumed_committed_only_recovery_after_suppressed_preview_local_match( - candidates[0], - session.growth_rows_for_candidate_viewport_top_y(candidates[0].viewport_top_y), - ) - ); - } - - #[test] - fn corroborated_sample_registrations_block_committed_only_recovery_without_viewport_anchor() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 237; - session.last_motion_rows_hint = Some(20); - session.transient_motion_rows_hint = Some(145); - session.transient_burst_search_enabled = true; - session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { - frame: make_sparse_textlike_window(256, 120, 237), - viewport_top_y: 237, - }); - session.last_observed_sample_registration_result = Some("matched"); - session.last_observed_sample_registration_motion_rows = Some(135); - session.last_preview_only_local_registration_result = Some("matched"); - session.last_preview_only_local_registration_motion_rows = Some(116); - - let preferred = DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 353, - motion_rows: 116, - mean_abs_diff_x100: 0, - }; - - assert!(session - .should_fail_closed_committed_only_recovery_after_corroborated_sample_registration_without_viewport_anchor( - preferred, - session.growth_rows_for_candidate_viewport_top_y(preferred.viewport_top_y), - )); - } - - #[test] - fn corroborated_sample_registrations_block_older_keyframe_recovery_by_growth_band() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 237; - session.last_motion_rows_hint = Some(20); - session.transient_motion_rows_hint = Some(249); - session.transient_burst_search_enabled = true; - session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { - frame: make_sparse_textlike_window(256, 120, 237), - viewport_top_y: 237, - }); - session.last_observed_sample_registration_result = Some("matched"); - session.last_observed_sample_registration_motion_rows = Some(258); - session.last_preview_only_local_registration_result = Some("matched"); - session.last_preview_only_local_registration_motion_rows = Some(180); - - let preferred = DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 464, - motion_rows: 271, - mean_abs_diff_x100: 700, - }; - - assert!(session - .should_fail_closed_committed_only_recovery_after_corroborated_sample_registration_without_viewport_anchor( - preferred, - session.growth_rows_for_candidate_viewport_top_y(preferred.viewport_top_y), - )); - } - - #[test] - fn observed_burst_outpacing_recent_preview_local_commit_blocks_committed_only_recovery() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 237; - session.last_motion_rows_hint = Some(20); - session.transient_motion_rows_hint = Some(145); - session.transient_burst_search_enabled = true; - session.last_observed_sample_registration_result = Some("matched"); - session.last_observed_sample_registration_motion_rows = Some(135); - session.last_preview_only_local_registration_result = Some("no_match"); - - session.growth_history.push(super::GrowthCommit { - frame: make_sparse_textlike_window(256, 120, 237), - growth_rows: 20, - viewport_top_y: 237, - decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample - .decision_source(), - detected_motion_rows: Some(20), - effective_motion_rows_hint: Some(145), - }); - - let preferred = DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 353, - motion_rows: 116, - mean_abs_diff_x100: 0, - }; - - assert!(session - .should_fail_closed_committed_only_recovery_when_observed_burst_outpaces_recent_preview_local_commit( - preferred, - session.growth_rows_for_candidate_viewport_top_y(preferred.viewport_top_y), - )); - } - - #[test] - fn suppressed_large_preview_local_registration_helper_skips_hint_band_committed_recovery() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.current_viewport_top_y = 202; - session.last_motion_rows_hint = Some(8); - session.transient_motion_rows_hint = Some(575); - session.transient_burst_search_enabled = true; - session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { - frame: make_sparse_textlike_window(256, 120, 31), - viewport_top_y: 202, - }); - session.last_preview_only_local_registration_result = Some("matched"); - session.last_preview_only_local_registration_motion_rows = Some(272); - - let candidates = [DownwardViewportCandidate { - source: DownwardViewportCandidateSource::CommittedKeyframe, - viewport_top_y: 500, - motion_rows: 310, - mean_abs_diff_x100: 0, - }]; - - assert!( - !session - .should_fail_closed_underconsumed_committed_only_recovery_after_suppressed_preview_local_match( - candidates[0], - session.growth_rows_for_candidate_viewport_top_y(candidates[0].viewport_top_y), - ) - ); - } - - #[test] - fn weak_tiny_committed_keyframe_match_retries_full_range_during_burst() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(14); - session.transient_motion_rows_hint = Some(380); - session.transient_burst_search_enabled = true; - - assert!(session.should_retry_committed_keyframe_registration_across_full_range( - DownwardRegistration::Matched(DirectionMatch { - mean_abs_diff_x100: 733, - motion_rows: 7, - }), - )); - assert_eq!( - session.prefer_full_range_committed_keyframe_registration( - DownwardRegistration::Matched(DirectionMatch { - mean_abs_diff_x100: 733, - motion_rows: 7, - }), - DownwardRegistration::Matched(DirectionMatch { - mean_abs_diff_x100: 0, - motion_rows: 50, - }), - ), - DownwardRegistration::Matched(DirectionMatch { - mean_abs_diff_x100: 0, - motion_rows: 50, - }), - ); - } - - #[test] - fn modest_committed_keyframe_match_does_not_retry_full_range_during_burst() { - let mut session = - ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); - - session.last_motion_rows_hint = Some(9); - session.transient_motion_rows_hint = Some(1_284); - session.transient_burst_search_enabled = true; - - assert!(!session.should_retry_committed_keyframe_registration_across_full_range( - DownwardRegistration::Matched(DirectionMatch { - mean_abs_diff_x100: 301, - motion_rows: 27, - }), - )); - } - - #[test] - fn session_preview_matches_export_after_downward_growth() { - let document = [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [50, 0, 0, 255], - [60, 0, 0, 255], - ]; - let mut session = ScrollSession::new(make_window(&document, 3, 0, 4), 3).unwrap(); - let _ = session.observe_downward_sample(make_window(&document, 3, 1, 4)).unwrap(); - let _ = session.observe_downward_sample(make_window(&document, 3, 2, 4)).unwrap(); - - assert_eq!(session.preview_image().height(), session.export_image().height()); - assert_eq!(session.preview_image().get_pixel(0, 0), session.export_image().get_pixel(0, 0)); - assert_eq!( - session.preview_image().get_pixel(0, session.preview_image().height() - 1), - session.export_image().get_pixel(0, session.export_image().height() - 1) - ); - } - - #[test] - fn session_undo_restores_previous_stitched_image() { - let base = make_test_image( - 3, - &[[10, 0, 0, 255], [20, 0, 0, 255], [30, 0, 0, 255], [40, 0, 0, 255], [50, 0, 0, 255]], - ); - let moved = make_test_image( - 3, - &[[20, 0, 0, 255], [30, 0, 0, 255], [40, 0, 0, 255], [50, 0, 0, 255], [60, 0, 0, 255]], - ); - let mut session = ScrollSession::new(base.clone(), 320).unwrap(); - - assert_eq!( - session.observe_downward_sample(moved).unwrap(), - ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } - ); - assert!(session.undo_last_append()); - assert_eq!(session.export_image(), &base); - } -} +#[cfg(test)] +mod tests; diff --git a/packages/rsnap-overlay/src/scroll_capture/bench_support.rs b/packages/rsnap-overlay/src/scroll_capture/bench_support.rs new file mode 100644 index 00000000..c89ebf83 --- /dev/null +++ b/packages/rsnap-overlay/src/scroll_capture/bench_support.rs @@ -0,0 +1,253 @@ +//! Deterministic scroll-capture fixtures and harnesses used by Criterion benches. + +use image::{Rgba, RgbaImage, imageops}; + +use crate::scroll_capture::{ + OverlapSearchConfig, ScrollDirection, ScrollObserveOutcome, ScrollSession, + evaluate_overlap_direction, max_directional_motion_rows, scroll_capture_fingerprint, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// Benchmark fixture shapes that exercise the common and wide capture paths. +pub enum ScrollCaptureBenchScenario { + /// Standard-width capture data with modest scroll movement. + Baseline, + /// Wider capture data with a larger viewport and scroll delta. + Wide, +} + +impl ScrollCaptureBenchScenario { + /// All supported benchmark scenarios in stable iteration order. + pub const ALL: [Self; 2] = [Self::Baseline, Self::Wide]; + + #[must_use] + /// Returns the stable bench-function suffix for this scenario. + pub const fn as_str(self) -> &'static str { + match self { + Self::Baseline => "baseline", + Self::Wide => "wide", + } + } + + const fn spec(self) -> ScrollCaptureBenchFixtureSpec { + match self { + Self::Baseline => ScrollCaptureBenchFixtureSpec { + width: 192, + document_rows: 320, + window_rows: 128, + motion_rows: 12, + preview_width_px: 320, + }, + Self::Wide => ScrollCaptureBenchFixtureSpec { + width: 320, + document_rows: 448, + window_rows: 160, + motion_rows: 20, + preview_width_px: 320, + }, + } + } +} + +#[derive(Clone, Copy, Debug, Default)] +/// Fingerprint benchmark output used for deterministic performance checks. +pub struct ScrollCaptureFingerprintMetrics { + /// Total byte length of the generated fingerprint payload. + pub byte_len: usize, + /// Stable checksum of the generated fingerprint payload. + pub checksum: u32, +} + +#[derive(Clone, Copy, Debug, Default)] +/// Overlap-match benchmark output for a single downward sample. +pub struct ScrollCaptureOverlapMetrics { + /// Whether the overlap search produced a valid match. + pub matched: bool, + /// Detected scroll motion in rows. + pub motion_rows: u32, + /// Rows that remained overlapped after applying the detected motion. + pub overlap_rows: u32, + /// Mean absolute difference metric for the matched overlap window. + pub mean_abs_diff_x100: u32, +} + +#[derive(Clone, Copy, Debug, Default)] +/// Session-commit benchmark output for a single growth observation. +pub struct ScrollCaptureSessionMetrics { + /// Whether the sample committed new growth into the session. + pub committed: bool, + /// Number of rows added to the stitched export. + pub growth_rows: u32, + /// Export image height after the observation completes. + pub export_height: u32, + /// Preview image height after the observation completes. + pub preview_height: u32, +} + +/// Reusable scroll-capture benchmark harness backed by deterministic image fixtures. +pub struct ScrollCaptureBenchHarness { + fixture: ScrollCaptureBenchFixture, + overlap_config: OverlapSearchConfig, +} + +impl ScrollCaptureBenchHarness { + #[must_use] + /// Builds the benchmark harness for the selected fixture scenario. + pub fn new(scenario: ScrollCaptureBenchScenario) -> Self { + Self { + fixture: ScrollCaptureBenchFixture::new(scenario.spec()), + overlap_config: OverlapSearchConfig::default(), + } + } + + #[must_use] + /// Runs the fingerprint path and returns stable summary metrics. + pub fn run_fingerprint(&self) -> ScrollCaptureFingerprintMetrics { + let bytes = scroll_capture_fingerprint(&self.fixture.fingerprint_frame); + + ScrollCaptureFingerprintMetrics { byte_len: bytes.len(), checksum: checksum_bytes(&bytes) } + } + + #[must_use] + /// Runs the overlap matcher and returns the resulting comparison metrics. + pub fn run_overlap_match(&self) -> ScrollCaptureOverlapMetrics { + let max_motion_rows = max_directional_motion_rows( + &self.fixture.base_frame, + &self.fixture.next_frame, + self.overlap_config, + ); + let matched = evaluate_overlap_direction( + &self.fixture.base_frame, + &self.fixture.next_frame, + ScrollDirection::Down, + 1..=max_motion_rows, + self.overlap_config, + ); + + matched.map_or( + ScrollCaptureOverlapMetrics { + matched: false, + motion_rows: 0, + overlap_rows: 0, + mean_abs_diff_x100: u32::MAX, + }, + |matched| ScrollCaptureOverlapMetrics { + matched: true, + motion_rows: matched.motion_rows, + overlap_rows: self + .fixture + .window_rows + .min(self.fixture.base_frame.height()) + .saturating_sub(matched.motion_rows), + mean_abs_diff_x100: matched.mean_abs_diff_x100, + }, + ) + } + + #[must_use] + /// Runs one downward observation through the session-commit path. + pub fn run_session_commit(&self) -> ScrollCaptureSessionMetrics { + let mut session = self.fixture.new_session(); + let outcome = session + .observe_downward_sample(self.fixture.next_frame.clone()) + .expect("scroll-capture benchmark fixture should observe successfully"); + let (committed, growth_rows) = match outcome { + ScrollObserveOutcome::Committed { growth_rows, .. } => (true, growth_rows), + _ => (false, 0), + }; + + ScrollCaptureSessionMetrics { + committed, + growth_rows, + export_height: session.export_image().height(), + preview_height: session.preview_image().height(), + } + } +} + +#[derive(Clone, Copy)] +struct ScrollCaptureBenchFixtureSpec { + width: u32, + document_rows: u32, + window_rows: u32, + motion_rows: u32, + preview_width_px: u32, +} + +struct ScrollCaptureBenchFixture { + base_frame: RgbaImage, + next_frame: RgbaImage, + fingerprint_frame: RgbaImage, + window_rows: u32, + preview_width_px: u32, +} + +impl ScrollCaptureBenchFixture { + fn new(spec: ScrollCaptureBenchFixtureSpec) -> Self { + let document = build_document(spec.width, spec.document_rows); + let base_frame = crop_window(&document, 24, spec.window_rows); + let next_frame = crop_window(&document, 24 + spec.motion_rows, spec.window_rows); + let fingerprint_frame = + crop_window(&document, 24 + spec.motion_rows.saturating_mul(2), spec.window_rows); + + Self { + base_frame, + next_frame, + fingerprint_frame, + window_rows: spec.window_rows, + preview_width_px: spec.preview_width_px, + } + } + + fn new_session(&self) -> ScrollSession { + ScrollSession::new(self.base_frame.clone(), self.preview_width_px) + .expect("scroll-capture benchmark fixture should build a valid session") + } +} + +fn crop_window(document: &RgbaImage, start_row: u32, rows: u32) -> RgbaImage { + imageops::crop_imm(document, 0, start_row, document.width(), rows).to_image() +} + +fn build_document(width: u32, rows: u32) -> RgbaImage { + let mut image = RgbaImage::new(width, rows); + + for y in 0..rows { + for x in 0..width { + let stripe = (y / 8) % 6; + let lane = (x / 12) % 5; + let mut r = + ((x.wrapping_mul(13) + y.wrapping_mul(17) + stripe.wrapping_mul(29)) % 251) as u8; + let mut g = + ((x.wrapping_mul(7) + y.wrapping_mul(19) + lane.wrapping_mul(23)) % 251) as u8; + let mut b = (((x / 2).wrapping_mul(11) + + y.wrapping_mul(5) + + stripe.wrapping_mul(31) + + lane.wrapping_mul(17)) + % 251) as u8; + + if x < 10 || x + 10 >= width { + r = 8; + g = 8; + b = 8; + } + if y % 32 == 0 { + r = r.saturating_add(21); + g = g.saturating_add(9); + } + if (x / 24 + y / 16).is_multiple_of(2) { + b = b.saturating_add(13); + } + + image.put_pixel(x, y, Rgba([r, g, b, 255])); + } + } + + image +} + +fn checksum_bytes(bytes: &[u8]) -> u32 { + bytes.iter().fold(0_u32, |acc, byte| { + acc.wrapping_mul(16_777_619).wrapping_add(u32::from(*byte).wrapping_add(1)) + }) +} diff --git a/packages/rsnap-overlay/src/scroll_capture/downward_resolution.rs b/packages/rsnap-overlay/src/scroll_capture/downward_resolution.rs new file mode 100644 index 00000000..9bdec554 --- /dev/null +++ b/packages/rsnap-overlay/src/scroll_capture/downward_resolution.rs @@ -0,0 +1,1918 @@ +#![allow(clippy::wildcard_imports)] + +use super::*; + +impl ScrollSession { + pub(super) fn evaluate_reference_overlap_direction( + &self, + previous: &RgbaImage, + next: &RgbaImage, + direction: ScrollDirection, + motion_rows_hint: Option, + ) -> Option { + let config = OverlapSearchConfig::default(); + let preferred_range = + self.preferred_motion_range_from_hint(previous, next, motion_rows_hint, config)?; + + evaluate_overlap_direction(previous, next, direction, preferred_range, config) + } + + pub(super) fn evaluate_reference_downward_registration( + &self, + previous: &RgbaImage, + next: &RgbaImage, + motion_rows_hint: Option, + allow_full_range_fallback: bool, + ) -> DownwardRegistration { + let config = OverlapSearchConfig::default(); + let preferred_range = self.preferred_downward_motion_range_from_hint( + previous, + next, + motion_rows_hint, + config, + ); + + self.evaluate_reference_downward_registration_with_preferred_range( + previous, + next, + motion_rows_hint, + preferred_range, + allow_full_range_fallback, + ) + } + + pub(super) fn evaluate_reference_downward_registration_with_preferred_ranges( + &self, + previous: &RgbaImage, + next: &RgbaImage, + motion_rows_hint: Option, + preferred_ranges: &[RangeInclusive], + allow_full_range_fallback: bool, + ) -> (DownwardRegistration, Option<&'static str>) { + let config = OverlapSearchConfig::default(); + let max_overlap = previous.height().min(next.height()); + let max_motion_rows = max_directional_motion_rows(previous, next, config); + let mut candidates = collect_overlap_direction_matches_in_ranges( + previous, + next, + ScrollDirection::Down, + preferred_ranges, + config, + ); + let mut no_match_reason = if candidates.is_empty() { Some("no_candidates") } else { None }; + + if candidates.is_empty() + && allow_full_range_fallback + && (motion_rows_hint.is_none() || self.transient_burst_search_enabled) + { + candidates = collect_overlap_direction_matches( + previous, + next, + ScrollDirection::Down, + 1..=max_motion_rows, + config, + ); + no_match_reason = if candidates.is_empty() { Some("no_candidates") } else { None }; + } + + candidates.retain(|matched| { + downward_registration_has_meaningful_overlap(*matched, max_overlap, config) + }); + + if candidates.is_empty() { + no_match_reason.get_or_insert("insufficient_overlap"); + } + + let classification = classify_downward_registration_candidates(&candidates); + let upward_veto = self.evaluate_reference_overlap_direction( + previous, + next, + ScrollDirection::Up, + motion_rows_hint, + ); + + match (classification, upward_veto) { + (DownwardRegistration::Matched(down), Some(up)) + if up.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) + <= down.mean_abs_diff_x100 => + { + (DownwardRegistration::NoMatch, Some("upward_veto")) + }, + (DownwardRegistration::NoMatch, _) => (DownwardRegistration::NoMatch, no_match_reason), + (other, _) => (other, None), + } + } + + pub(super) fn evaluate_reference_downward_registration_with_preferred_range( + &self, + previous: &RgbaImage, + next: &RgbaImage, + motion_rows_hint: Option, + preferred_range: Option>, + allow_full_range_fallback: bool, + ) -> DownwardRegistration { + let config = OverlapSearchConfig::default(); + let max_overlap = previous.height().min(next.height()); + let max_motion_rows = max_directional_motion_rows(previous, next, config); + let mut candidates = preferred_range.as_ref().map_or_else(Vec::new, |range| { + collect_overlap_direction_matches( + previous, + next, + ScrollDirection::Down, + range.clone(), + config, + ) + }); + let mut no_match_reason = if candidates.is_empty() { Some("no_candidates") } else { None }; + + if candidates.is_empty() + && allow_full_range_fallback + && (motion_rows_hint.is_none() || self.transient_burst_search_enabled) + { + candidates = collect_overlap_direction_matches( + previous, + next, + ScrollDirection::Down, + 1..=max_motion_rows, + config, + ); + no_match_reason = if candidates.is_empty() { Some("no_candidates") } else { None }; + } + + candidates.retain(|matched| { + downward_registration_has_meaningful_overlap(*matched, max_overlap, config) + }); + + if candidates.is_empty() { + no_match_reason.get_or_insert("insufficient_overlap"); + } + + let classification = classify_downward_registration_candidates(&candidates); + let upward_veto = self.evaluate_reference_overlap_direction( + previous, + next, + ScrollDirection::Up, + motion_rows_hint, + ); + + match (classification, upward_veto) { + (DownwardRegistration::Matched(down), Some(up)) + if up.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) + <= down.mean_abs_diff_x100 => + { + DownwardRegistration::NoMatch + }, + (DownwardRegistration::NoMatch, _) => { + let _ = no_match_reason; + + DownwardRegistration::NoMatch + }, + (other, _) => other, + } + } + + pub(super) fn sequential_downward_motion_ranges( + &self, + previous: &RgbaImage, + next: &RgbaImage, + config: OverlapSearchConfig, + ) -> Vec> { + let local_motion_rows_hint = self.last_motion_rows_hint; + let mut ranges = Vec::new(); + + if let Some(local_range) = self.preferred_local_downward_motion_range_from_hint( + previous, + next, + local_motion_rows_hint, + config, + ) { + ranges.push(local_range); + } + + if self.initial_downward_bootstrap_active() && self.last_motion_rows_hint.is_none() { + return ranges; + } + + if let Some(transient_range) = self.transient_downward_motion_range(previous, next, config) + && !ranges.contains(&transient_range) + { + ranges.push(transient_range); + } + + ranges + } + + pub(super) fn clear_last_downward_sample_registration(&mut self) { + self.last_downward_sample_registration_result = None; + self.last_downward_sample_registration_source = None; + self.last_downward_sample_registration_motion_rows = None; + self.last_downward_sample_registration_provisional_viewport_top_y = None; + self.last_observed_sample_registration_result = None; + self.last_observed_sample_registration_reason = None; + self.last_observed_sample_registration_motion_rows = None; + self.last_observed_sample_registration_mean_abs_diff_x100 = None; + self.last_preview_only_local_registration_result = None; + self.last_preview_only_local_registration_reason = None; + self.last_preview_only_local_registration_motion_rows = None; + self.last_preview_only_local_registration_mean_abs_diff_x100 = None; + self.last_downward_viewport_candidate_count = None; + self.last_downward_viewport_candidates_before_prune = None; + self.last_downward_viewport_candidates_after_prune = None; + self.blocked_underconsumed_observed_recovery_in_burst = false; + self.blocked_lagging_exactly_corroborated_preview_local_tail_in_burst = false; + self.blocked_followup_after_suppressed_huge_preview_local_jump = false; + self.blocked_followup_after_extreme_preview_local_tail = false; + self.blocked_far_committed_only_recovery_after_corroborated_huge_local_jump = false; + } + + pub(super) fn record_last_downward_sample_registration( + &mut self, + result: &'static str, + source: Option, + motion_rows: Option, + ) { + self.last_downward_sample_registration_result = Some(result); + self.last_downward_sample_registration_source = + source.map(DownwardSampleMatchSource::label); + self.last_downward_sample_registration_motion_rows = motion_rows; + } + + pub(super) fn record_last_sample_eval_context(&mut self) { + self.last_sample_eval_last_motion_rows_hint = self.last_motion_rows_hint; + self.last_sample_eval_transient_motion_rows_hint = self.transient_motion_rows_hint; + self.last_sample_eval_effective_motion_rows_hint = self.effective_motion_rows_hint(); + self.last_sample_eval_transient_burst_search_enabled = self.transient_burst_search_enabled; + } + + pub(super) fn transient_downward_motion_range( + &self, + previous: &RgbaImage, + next: &RgbaImage, + config: OverlapSearchConfig, + ) -> Option> { + let transient_motion_rows_hint = self.normalized_transient_motion_rows_hint()?; + let max_motion_rows = max_directional_motion_rows(previous, next, config); + + if transient_motion_rows_hint == 0 || transient_motion_rows_hint > max_motion_rows { + return None; + } + + let tolerance = (transient_motion_rows_hint / 2) + .clamp( + LOCAL_DOWNWARD_SEARCH_MOTION_TOLERANCE_ROWS, + LOCAL_DOWNWARD_SEARCH_MAX_TOLERANCE_ROWS, + ) + .min(max_motion_rows); + let min_motion_rows = transient_motion_rows_hint.saturating_sub(tolerance).max(1); + let max_motion_rows = + transient_motion_rows_hint.saturating_add(tolerance).min(max_motion_rows); + + Some(min_motion_rows..=max_motion_rows) + } + + pub(super) fn preferred_local_downward_motion_range_from_hint( + &self, + previous: &RgbaImage, + next: &RgbaImage, + motion_rows_hint: Option, + config: OverlapSearchConfig, + ) -> Option> { + let max_motion_rows = max_directional_motion_rows(previous, next, config); + + if let Some(last_growth_rows) = motion_rows_hint { + let tolerance = (last_growth_rows / 2) + .clamp( + LOCAL_DOWNWARD_SEARCH_MOTION_TOLERANCE_ROWS, + LOCAL_DOWNWARD_SEARCH_MAX_TOLERANCE_ROWS, + ) + .min(max_motion_rows); + let min_motion_rows = last_growth_rows.saturating_sub(tolerance).max(1); + let max_motion_rows = last_growth_rows.saturating_add(tolerance).min(max_motion_rows); + + return Some(min_motion_rows..=max_motion_rows); + } + + Some(1..=INITIAL_DOWNWARD_MAX_MOTION_ROWS.min(max_motion_rows).max(1)) + } + + pub(super) fn diagnose_reference_overlap_direction( + &self, + previous: &RgbaImage, + next: &RgbaImage, + direction: ScrollDirection, + motion_rows_hint: Option, + ) -> DirectionMatchEval { + let config = OverlapSearchConfig::default(); + let preferred_range = self + .preferred_motion_range_from_hint(previous, next, motion_rows_hint, config) + .map(OverlapSearchRange::from); + + self.diagnose_reference_overlap_direction_with_preferred_range( + previous, + next, + direction, + preferred_range, + false, + ) + } + + pub(super) fn diagnose_reference_overlap_direction_with_preferred_range( + &self, + previous: &RgbaImage, + next: &RgbaImage, + direction: ScrollDirection, + preferred_range: Option, + allow_downward_full_range_fallback: bool, + ) -> DirectionMatchEval { + let config = OverlapSearchConfig::default(); + let max_motion_rows = max_directional_motion_rows(previous, next, config); + let preferred_only_match = preferred_range.and_then(|range| { + evaluate_overlap_direction(previous, next, direction, range.as_range(), config) + }); + let mut final_match = preferred_only_match; + let mut used_full_range_fallback = false; + + if final_match.is_none() && allow_downward_full_range_fallback { + final_match = + evaluate_overlap_direction(previous, next, direction, 1..=max_motion_rows, config); + used_full_range_fallback = final_match.is_some(); + } + + DirectionMatchEval { + preferred_range, + max_motion_rows, + preferred_only_match, + final_match, + used_full_range_fallback, + } + } + + pub(super) fn evaluate_reference_overlap_direction_preferred_only( + &self, + previous: &RgbaImage, + next: &RgbaImage, + direction: ScrollDirection, + motion_rows_hint: Option, + ) -> Option { + let config = OverlapSearchConfig::default(); + let preferred_range = + self.preferred_motion_range_from_hint(previous, next, motion_rows_hint, config)?; + + evaluate_overlap_direction(previous, next, direction, preferred_range, config) + } + + pub(super) fn preferred_motion_range_from_hint( + &self, + previous: &RgbaImage, + next: &RgbaImage, + motion_rows_hint: Option, + config: OverlapSearchConfig, + ) -> Option> { + let max_motion_rows = max_directional_motion_rows(previous, next, config); + + if let Some(last_growth_rows) = motion_rows_hint { + let tolerance = DOWNWARD_SEARCH_MOTION_TOLERANCE_ROWS.min(max_motion_rows); + let min_motion_rows = last_growth_rows.saturating_sub(tolerance).max(1); + let max_motion_rows = last_growth_rows.saturating_add(tolerance).min(max_motion_rows); + + return Some(min_motion_rows..=max_motion_rows); + } + + Some(1..=INITIAL_DOWNWARD_MAX_MOTION_ROWS.min(max_motion_rows).max(1)) + } + + pub(super) fn preferred_downward_motion_range_from_hint( + &self, + previous: &RgbaImage, + next: &RgbaImage, + motion_rows_hint: Option, + config: OverlapSearchConfig, + ) -> Option> { + let max_motion_rows = max_directional_motion_rows(previous, next, config); + + if let Some(last_growth_rows) = motion_rows_hint { + let tolerance = (last_growth_rows / 2) + .clamp( + DOWNWARD_KEYFRAME_SEARCH_MOTION_TOLERANCE_ROWS, + DOWNWARD_KEYFRAME_SEARCH_MAX_TOLERANCE_ROWS, + ) + .min(max_motion_rows); + let min_motion_rows = last_growth_rows.saturating_sub(tolerance).max(1); + let max_motion_rows = last_growth_rows.saturating_add(tolerance).min(max_motion_rows); + + return Some(min_motion_rows..=max_motion_rows); + } + + Some(1..=INITIAL_DOWNWARD_MAX_MOTION_ROWS.min(max_motion_rows).max(1)) + } + + pub(super) fn resolve_downward_viewport_candidate( + &mut self, + frame: &RgbaImage, + observed_match: DownwardSampleMatch, + ) -> DownwardViewportResolution { + let pending_suppressed_huge_preview_only_local_followup = + self.pending_suppressed_huge_preview_only_local_followup.take(); + let pending_suppressed_huge_preview_only_local_followup_remaining_blocks = + self.pending_suppressed_huge_preview_only_local_followup_remaining_blocks; + + self.pending_suppressed_huge_preview_only_local_followup_remaining_blocks = 0; + + let pending_extreme_preview_only_local_tail_followup = + self.pending_extreme_preview_only_local_tail_followup.take(); + let pending_extreme_preview_only_local_tail_followup_remaining_blocks = + self.pending_extreme_preview_only_local_tail_followup_remaining_blocks; + + self.pending_extreme_preview_only_local_tail_followup_remaining_blocks = 0; + + let provisional_viewport_top_y = + self.provisional_viewport_top_y_for_downward_sample_match(observed_match); + let mut candidates = Vec::with_capacity(DOWNWARD_KEYFRAME_SEARCH_LIMIT.saturating_add(1)); + let mut suppressed_observed_candidate = None; + let mut suppressed_preview_only_local_candidate = None; + + self.last_downward_sample_registration_provisional_viewport_top_y = + provisional_viewport_top_y; + + if let Some(viewport_top_y) = provisional_viewport_top_y { + let candidate = DownwardViewportCandidate { + source: observed_match.source.into(), + viewport_top_y, + motion_rows: observed_match.matched.motion_rows, + mean_abs_diff_x100: observed_match.matched.mean_abs_diff_x100, + }; + let suppress_observed = self.should_suppress_observed_sample_candidate(candidate); + let suppress_preview_local = + self.should_suppress_preview_only_local_candidate(candidate); + + if !suppress_observed && !suppress_preview_local { + candidates.push(candidate); + } else if suppress_observed + && candidate.source == DownwardViewportCandidateSource::ObservedSample + { + suppressed_observed_candidate = Some(candidate); + } else if suppress_preview_local + && candidate.source == DownwardViewportCandidateSource::PreviewOnlyLocalSample + { + suppressed_preview_only_local_candidate = Some(candidate); + } + } + + self.collect_committed_downward_viewport_candidates(frame, &mut candidates); + self.apply_pending_preview_local_followup_blocks( + suppressed_preview_only_local_candidate, + pending_suppressed_huge_preview_only_local_followup, + pending_suppressed_huge_preview_only_local_followup_remaining_blocks, + pending_extreme_preview_only_local_tail_followup, + pending_extreme_preview_only_local_tail_followup_remaining_blocks, + &mut candidates, + ); + self.restore_corroborated_observed_candidate( + suppressed_observed_candidate, + &mut candidates, + ); + + let preview_only_local_candidate_before_prune = + candidates.iter().copied().find(|candidate| { + candidate.source == DownwardViewportCandidateSource::PreviewOnlyLocalSample + }); + let candidates_before_prune = candidates.clone(); + + self.last_downward_viewport_candidates_before_prune = + Some(format_downward_viewport_candidates(&candidates)); + + self.prune_committed_keyframe_candidates_outside_local_continuity(&mut candidates); + self.restore_repeated_small_preview_only_local_candidate_after_empty_prune( + preview_only_local_candidate_before_prune, + &mut candidates, + ); + + if self.should_fail_closed_lagging_exactly_corroborated_preview_local_tail_in_burst( + &candidates, + ) { + self.blocked_lagging_exactly_corroborated_preview_local_tail_in_burst = true; + + candidates.clear(); + } + if self.should_fail_closed_underconsumed_observed_recovery_in_burst( + &candidates_before_prune, + &candidates, + ) { + self.blocked_underconsumed_observed_recovery_in_burst = true; + + candidates.clear(); + } + + self.last_downward_viewport_candidate_count = Some(candidates.len()); + self.last_downward_viewport_candidates_after_prune = + Some(format_downward_viewport_candidates(&candidates)); + + select_downward_viewport_candidate(&mut candidates) + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn apply_pending_preview_local_followup_blocks( + &mut self, + suppressed_preview_only_local_candidate: Option, + pending_suppressed_huge_preview_only_local_followup: Option, + pending_suppressed_huge_preview_only_local_followup_remaining_blocks: u8, + pending_extreme_preview_only_local_tail_followup: Option, + pending_extreme_preview_only_local_tail_followup_remaining_blocks: u8, + candidates: &mut Vec, + ) { + if self + .should_fail_closed_suppressed_huge_preview_local_jump_corroborated_by_observed_and_committed( + suppressed_preview_only_local_candidate, + candidates, + ) { + self.pending_suppressed_huge_preview_only_local_followup = + suppressed_preview_only_local_candidate; + self.pending_suppressed_huge_preview_only_local_followup_remaining_blocks = self + .suppressed_huge_preview_only_local_followup_block_budget( + suppressed_preview_only_local_candidate, + ); + + candidates.clear(); + } + if self.should_fail_closed_committed_followup_after_suppressed_huge_preview_local_jump( + pending_suppressed_huge_preview_only_local_followup, + candidates, + ) { + if let Some(pending_candidate) = pending_suppressed_huge_preview_only_local_followup + && pending_suppressed_huge_preview_only_local_followup_remaining_blocks > 1 + { + self.pending_suppressed_huge_preview_only_local_followup = Some(pending_candidate); + self.pending_suppressed_huge_preview_only_local_followup_remaining_blocks = + pending_suppressed_huge_preview_only_local_followup_remaining_blocks - 1; + } + + self.blocked_followup_after_suppressed_huge_preview_local_jump = true; + + candidates.clear(); + } + if self.should_fail_closed_committed_followup_after_extreme_preview_local_tail_block( + pending_extreme_preview_only_local_tail_followup, + candidates, + ) { + if let Some(pending_candidate) = pending_extreme_preview_only_local_tail_followup + && pending_extreme_preview_only_local_tail_followup_remaining_blocks > 1 + { + self.pending_extreme_preview_only_local_tail_followup = Some(pending_candidate); + self.pending_extreme_preview_only_local_tail_followup_remaining_blocks = + pending_extreme_preview_only_local_tail_followup_remaining_blocks - 1; + } + + self.blocked_followup_after_extreme_preview_local_tail = true; + + candidates.clear(); + } + } + + pub(super) fn should_fail_closed_suppressed_huge_preview_local_jump_corroborated_by_observed_and_committed( + &self, + suppressed_preview_only_local_candidate: Option, + committed_candidates: &[DownwardViewportCandidate], + ) -> bool { + let Some(candidate) = suppressed_preview_only_local_candidate else { + return false; + }; + let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { + return false; + }; + + if candidate.source != DownwardViewportCandidateSource::PreviewOnlyLocalSample { + return false; + } + + let large_far_recovery_threshold = last_motion_rows_hint + .saturating_mul(3) + .max(PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS.saturating_mul(2)); + + self.transient_burst_search_enabled + && self.last_observed_sample_registration_result == Some("matched") + && self.last_observed_sample_registration_motion_rows == Some(candidate.motion_rows) + && candidate.motion_rows > large_far_recovery_threshold + && self.growth_history.last().is_some_and(|commit| { + commit.decision_source + == DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source() + && commit.growth_rows + >= last_motion_rows_hint + .saturating_sub(PREVIEW_ONLY_LOCAL_NEAR_CONTINUITY_ROWS) + }) && committed_candidates.iter().any(|committed| { + committed.source == DownwardViewportCandidateSource::CommittedKeyframe + && committed.motion_rows == candidate.motion_rows + && committed.viewport_top_y == candidate.viewport_top_y + }) + } + + pub(super) fn should_fail_closed_committed_followup_after_suppressed_huge_preview_local_jump( + &self, + pending_suppressed_preview_only_local_candidate: Option, + candidates: &[DownwardViewportCandidate], + ) -> bool { + let Some(pending_candidate) = pending_suppressed_preview_only_local_candidate else { + return false; + }; + + if pending_candidate.source != DownwardViewportCandidateSource::PreviewOnlyLocalSample { + return false; + } + + self.transient_burst_search_enabled + && self.last_preview_only_local_registration_result == Some("no_match") + && self.last_observed_sample_registration_result == Some("matched") + && self.last_observed_sample_registration_motion_rows + == Some(pending_candidate.motion_rows) + && candidates.iter().all(|candidate| { + candidate.source == DownwardViewportCandidateSource::CommittedKeyframe + }) && candidates.iter().any(|candidate| { + candidate.viewport_top_y == pending_candidate.viewport_top_y + && candidate.motion_rows == pending_candidate.motion_rows + }) + } + + pub(super) fn should_fail_closed_committed_followup_after_extreme_preview_local_tail_block( + &self, + pending_preview_only_local_candidate: Option, + candidates: &[DownwardViewportCandidate], + ) -> bool { + let Some(pending_candidate) = pending_preview_only_local_candidate else { + return false; + }; + + if pending_candidate.source != DownwardViewportCandidateSource::PreviewOnlyLocalSample { + return false; + } + + self.transient_burst_search_enabled + && candidates.iter().all(|candidate| { + candidate.source == DownwardViewportCandidateSource::CommittedKeyframe + }) && candidates.iter().any(|candidate| { + candidate.viewport_top_y == pending_candidate.viewport_top_y + && candidate.motion_rows == pending_candidate.motion_rows + }) + } + + pub(super) fn suppressed_huge_preview_only_local_followup_block_budget( + &self, + candidate: Option, + ) -> u8 { + let Some(candidate) = candidate else { + return 3; + }; + let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { + return 3; + }; + + if candidate.source != DownwardViewportCandidateSource::PreviewOnlyLocalSample { + return 3; + } + + let continuity_rows = last_motion_rows_hint.max(1); + let far_recovery_ratio = + candidate.motion_rows.saturating_add(continuity_rows.saturating_sub(1)) + / continuity_rows; + + u8::try_from(far_recovery_ratio.clamp(3, 5)).unwrap_or(5) + } + + pub(super) fn restore_corroborated_observed_candidate( + &self, + suppressed_observed_candidate: Option, + candidates: &mut Vec, + ) { + let Some(candidate) = suppressed_observed_candidate else { + return; + }; + + if !self.observed_candidate_can_recover_from_committed_corroboration(candidate) { + return; + } + if candidates.iter().any(|other| { + other.source == DownwardViewportCandidateSource::CommittedKeyframe + && other.viewport_top_y == candidate.viewport_top_y + && other.motion_rows == candidate.motion_rows + }) { + candidates.push(candidate); + } + } + + pub(super) fn observed_candidate_can_recover_from_committed_corroboration( + &self, + candidate: DownwardViewportCandidate, + ) -> bool { + if candidate.source != DownwardViewportCandidateSource::ObservedSample { + return false; + } + + let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { + return false; + }; + let corroboration_cap = + last_motion_rows_hint.saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS); + + self.growth_rows_for_candidate_viewport_top_y(candidate.viewport_top_y) <= corroboration_cap + } + + pub(super) fn restore_repeated_small_preview_only_local_candidate_after_empty_prune( + &mut self, + preview_only_local_candidate_before_prune: Option, + candidates_after_prune: &mut Vec, + ) { + let Some(candidate) = preview_only_local_candidate_before_prune else { + self.last_blocked_preview_only_local_candidate = None; + + return; + }; + + if candidate.source != DownwardViewportCandidateSource::PreviewOnlyLocalSample + || !candidates_after_prune.is_empty() + || !self.repeated_preview_only_local_candidate_can_restore_after_empty_prune(candidate) + { + self.last_blocked_preview_only_local_candidate = None; + + return; + } + + let repeats = match self.last_blocked_preview_only_local_candidate { + Some(previous) if previous.candidate == candidate => previous.repeats.saturating_add(1), + _ => 1, + }; + + self.last_blocked_preview_only_local_candidate = + Some(BlockedPreviewOnlyLocalCandidate { candidate, repeats }); + + if repeats >= 2 { + candidates_after_prune.push(candidate); + + self.last_blocked_preview_only_local_candidate = None; + } + } + + pub(super) fn repeated_preview_only_local_candidate_can_restore_after_empty_prune( + &self, + candidate: DownwardViewportCandidate, + ) -> bool { + candidate.motion_rows <= REPEATED_PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS + && self.transient_burst_search_enabled + && self.transient_burst_motion_hint_exceeds_local_authority(candidate.motion_rows) + && self.last_motion_rows_hint.is_some() + } + + pub(super) fn should_fail_closed_lagging_exactly_corroborated_preview_local_tail_in_burst( + &self, + candidates_after_prune: &[DownwardViewportCandidate], + ) -> bool { + if !self.transient_burst_search_enabled { + return false; + } + + let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { + return false; + }; + let Some(preview_only_local_candidate) = + candidates_after_prune.iter().copied().find(|candidate| { + candidate.source == DownwardViewportCandidateSource::PreviewOnlyLocalSample + }) + else { + return false; + }; + + preview_only_local_candidate.motion_rows + <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS.saturating_div(2) + && preview_only_local_candidate.motion_rows + < last_motion_rows_hint + .saturating_sub(UNDERCONSUMED_OBSERVED_BURST_RECOVERY_GAP_ROWS) + && candidates_after_prune.iter().any(|candidate| { + candidate.source == DownwardViewportCandidateSource::CommittedKeyframe + && candidate.viewport_top_y == preview_only_local_candidate.viewport_top_y + && candidate.motion_rows == preview_only_local_candidate.motion_rows + && candidate.mean_abs_diff_x100 + <= preview_only_local_candidate + .mean_abs_diff_x100 + .saturating_add(DIRECTION_WARNING_MARGIN_X100) + }) + } + + pub(super) fn should_fail_closed_underconsumed_observed_recovery_in_burst( + &self, + candidates_before_prune: &[DownwardViewportCandidate], + candidates_after_prune: &[DownwardViewportCandidate], + ) -> bool { + let Some(observed_candidate) = candidates_after_prune + .iter() + .copied() + .find(|candidate| candidate.source == DownwardViewportCandidateSource::ObservedSample) + else { + return false; + }; + let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { + return false; + }; + + if self.last_preview_only_downward_local_sample.is_some() + || !self + .transient_burst_motion_hint_exceeds_local_authority(observed_candidate.motion_rows) + || last_motion_rows_hint + < observed_candidate + .motion_rows + .saturating_add(UNDERCONSUMED_OBSERVED_BURST_RECOVERY_GAP_ROWS) + { + return false; + } + + let has_same_motion_committed_corroboration = + candidates_after_prune.iter().any(|candidate| { + candidate.source == DownwardViewportCandidateSource::CommittedKeyframe + && candidate.viewport_top_y == observed_candidate.viewport_top_y + && candidate.motion_rows == observed_candidate.motion_rows + }); + + if !has_same_motion_committed_corroboration { + return false; + } + + candidates_before_prune.iter().any(|candidate| { + candidate.source == DownwardViewportCandidateSource::CommittedKeyframe + && candidate.motion_rows > observed_candidate.motion_rows + && candidate.motion_rows >= last_motion_rows_hint + && candidate.viewport_top_y >= observed_candidate.viewport_top_y + && candidate.viewport_top_y.abs_diff(observed_candidate.viewport_top_y) + <= DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS + && candidate.mean_abs_diff_x100 + <= observed_candidate + .mean_abs_diff_x100 + .saturating_add(DIRECTION_WARNING_MARGIN_X100) + }) + } + + pub(super) fn prune_committed_keyframe_candidates_outside_local_continuity( + &mut self, + candidates: &mut Vec, + ) { + let has_committed_candidate = candidates.iter().any(|candidate| { + candidate.source == DownwardViewportCandidateSource::CommittedKeyframe + }); + let mut local_anchor = best_local_downward_viewport_candidate(candidates); + + if local_anchor.is_some_and(|anchor| { + has_committed_candidate + && anchor.source == DownwardViewportCandidateSource::PreviewOnlyLocalSample + && self.transient_burst_motion_hint_exceeds_local_authority(anchor.motion_rows) + && !self + .preview_only_local_anchor_has_exact_committed_corroboration(anchor, candidates) + && !self.preview_only_local_candidate_has_material_progress(anchor) + && ((anchor.motion_rows <= TINY_PREVIEW_ONLY_LOCAL_BURST_RECOVERY_MAX_MOTION_ROWS + && self.consecutive_transient_burst_missing_downward_candidate_frames < 2) + || candidates.iter().any(|candidate| { + self.committed_candidate_can_plausibly_replace_underconsumed_preview_local_anchor( + anchor, + *candidate, + ) + })) + }) { + candidates.retain(|candidate| { + candidate.source != DownwardViewportCandidateSource::PreviewOnlyLocalSample + }); + + local_anchor = best_local_downward_viewport_candidate(candidates); + } + + let Some(local_anchor) = local_anchor else { + candidates.retain(|candidate| { + candidate.source != DownwardViewportCandidateSource::CommittedKeyframe + || !self.transient_burst_search_enabled + || !self.fallback_downward_growth_exceeds_continuity_budget( + candidate.viewport_top_y, + ) || self.transient_burst_growth_matches_pending_hint_band(candidate.viewport_top_y) + || self.growth_rows_for_candidate_viewport_top_y(candidate.viewport_top_y) + <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS + }); + + if let Some(max_bootstrap_growth_rows) = + self.bootstrap_committed_keyframe_growth_cap_rows() + { + candidates.retain(|candidate| { + candidate.source != DownwardViewportCandidateSource::CommittedKeyframe + || self.growth_rows_for_candidate_viewport_top_y(candidate.viewport_top_y) + <= max_bootstrap_growth_rows + }); + } + + self.prune_committed_keyframe_candidates_without_local_anchor(candidates); + + return; + }; + let allowed_overrun_rows = self + .max_committed_keyframe_local_overrun_rows(local_anchor) + .max(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS); + let max_allowed_motion_rows = self.max_committed_keyframe_motion_rows(local_anchor); + let max_allowed_viewport_top_y = local_anchor + .viewport_top_y + .saturating_add(i32::try_from(allowed_overrun_rows).unwrap_or(i32::MAX)); + let local_observed_has_same_motion_committed_corroboration = local_anchor.source + == DownwardViewportCandidateSource::ObservedSample + && candidates.iter().any(|candidate| { + candidate.source == DownwardViewportCandidateSource::CommittedKeyframe + && candidate.viewport_top_y == local_anchor.viewport_top_y + && candidate.motion_rows == local_anchor.motion_rows + }); + + candidates.retain(|candidate| { + candidate.source != DownwardViewportCandidateSource::CommittedKeyframe + || (candidate.viewport_top_y <= max_allowed_viewport_top_y + && candidate.motion_rows <= max_allowed_motion_rows) + || (!local_observed_has_same_motion_committed_corroboration + && self.committed_candidate_can_override_untrustworthy_observed_local_recovery( + local_anchor, + *candidate, + )) + }); + self.prune_committed_keyframe_candidates_for_transient_burst(candidates); + } + + pub(super) fn preview_only_local_anchor_has_exact_committed_corroboration( + &self, + local_anchor: DownwardViewportCandidate, + candidates: &[DownwardViewportCandidate], + ) -> bool { + local_anchor.source == DownwardViewportCandidateSource::PreviewOnlyLocalSample + && candidates.iter().any(|candidate| { + candidate.source == DownwardViewportCandidateSource::CommittedKeyframe + && candidate.viewport_top_y == local_anchor.viewport_top_y + && candidate.motion_rows == local_anchor.motion_rows + && candidate.mean_abs_diff_x100 + <= local_anchor + .mean_abs_diff_x100 + .saturating_add(DIRECTION_WARNING_MARGIN_X100) + }) + } + + pub(super) fn prune_committed_keyframe_candidates_without_local_anchor( + &mut self, + candidates: &mut Vec, + ) { + if !candidates + .iter() + .all(|candidate| candidate.source == DownwardViewportCandidateSource::CommittedKeyframe) + { + return; + } + + let Some(preferred) = candidates.iter().copied().min_by(|left, right| { + left.motion_rows + .cmp(&right.motion_rows) + .then(left.mean_abs_diff_x100.cmp(&right.mean_abs_diff_x100)) + .then(left.viewport_top_y.cmp(&right.viewport_top_y)) + }) else { + return; + }; + + if self.should_fail_closed_far_committed_only_recovery_without_local_anchor( + preferred, candidates, + ) { + if self + .should_fail_closed_far_committed_only_recovery_after_corroborated_huge_local_jump( + preferred, + self.growth_rows_for_candidate_viewport_top_y(preferred.viewport_top_y), + ) { + self.blocked_far_committed_only_recovery_after_corroborated_huge_local_jump = true; + } + + candidates.clear(); + + return; + } + + candidates.retain(|candidate| *candidate == preferred); + } + + pub(super) fn should_fail_closed_far_committed_only_recovery_without_local_anchor( + &self, + preferred: DownwardViewportCandidate, + candidates: &[DownwardViewportCandidate], + ) -> bool { + let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { + return false; + }; + + if !self.transient_burst_search_enabled { + return false; + } + + let preferred_growth_rows = + self.growth_rows_for_candidate_viewport_top_y(preferred.viewport_top_y); + + if self + .should_fail_closed_underconsumed_committed_only_recovery_after_suppressed_preview_local_match( + preferred, + preferred_growth_rows, + ) { + return true; + } + if self + .should_fail_closed_committed_only_recovery_after_corroborated_sample_registration_without_viewport_anchor( + preferred, + preferred_growth_rows, + ) + { + return true; + } + if self + .should_fail_closed_committed_only_recovery_when_observed_burst_outpaces_recent_preview_local_commit( + preferred, + preferred_growth_rows, + ) + { + return true; + } + if self.should_fail_closed_far_committed_only_recovery_after_corroborated_huge_local_jump( + preferred, + preferred_growth_rows, + ) { + return true; + } + if self.last_preview_only_downward_local_sample.is_some() + && self.last_preview_only_local_registration_result == Some("matched") + && last_motion_rows_hint <= DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS + && self.last_preview_only_local_registration_motion_rows.is_some_and( + |local_motion_rows| { + local_motion_rows + <= last_motion_rows_hint + .saturating_add(PREVIEW_ONLY_LOCAL_NEAR_CONTINUITY_ROWS) + && preferred_growth_rows + > local_motion_rows.saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS) + }, + ) { + return true; + } + if last_motion_rows_hint > DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS.saturating_mul(2) { + let all_candidates_low_confidence = candidates.iter().all(|candidate| { + candidate.mean_abs_diff_x100 > DIRECTION_WARNING_MARGIN_X100.saturating_mul(4) + }); + + return preferred_growth_rows <= last_motion_rows_hint && all_candidates_low_confidence; + } + + let far_growth_threshold = PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS + .max(last_motion_rows_hint.saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS)); + + self.growth_rows_for_candidate_viewport_top_y(preferred.viewport_top_y) + > far_growth_threshold + && candidates.iter().all(|candidate| { + self.growth_rows_for_candidate_viewport_top_y(candidate.viewport_top_y) + > far_growth_threshold + }) + } + + pub(super) fn should_fail_closed_far_committed_only_recovery_after_corroborated_huge_local_jump( + &self, + preferred: DownwardViewportCandidate, + preferred_growth_rows: u32, + ) -> bool { + let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { + return false; + }; + + if preferred.source != DownwardViewportCandidateSource::CommittedKeyframe { + return false; + } + + let large_far_recovery_threshold = last_motion_rows_hint + .saturating_mul(3) + .max(PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS.saturating_mul(2)); + let observed_material_lag_threshold = PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS + .max(last_motion_rows_hint.saturating_mul(2)); + let observed_corroborates_or_materially_lags = + self.last_observed_sample_registration_result == Some("matched") + && self.last_observed_sample_registration_motion_rows.is_some_and( + |observed_motion_rows| { + observed_motion_rows == preferred.motion_rows + || observed_motion_rows.saturating_add(observed_material_lag_threshold) + < preferred.motion_rows + }, + ); + + self.transient_burst_search_enabled + && self.last_preview_only_local_registration_result == Some("matched") + && self.last_preview_only_local_registration_motion_rows == Some(preferred.motion_rows) + && observed_corroborates_or_materially_lags + && preferred.motion_rows > large_far_recovery_threshold + && preferred_growth_rows > large_far_recovery_threshold + && self.growth_history.last().is_some_and(|commit| { + commit.decision_source + == DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source() + && commit.growth_rows + >= last_motion_rows_hint + .saturating_sub(PREVIEW_ONLY_LOCAL_NEAR_CONTINUITY_ROWS) + }) + } + + pub(super) fn should_fail_closed_underconsumed_committed_only_recovery_after_suppressed_preview_local_match( + &self, + preferred: DownwardViewportCandidate, + preferred_growth_rows: u32, + ) -> bool { + let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { + return false; + }; + let Some(local_motion_rows) = self.last_preview_only_local_registration_motion_rows else { + return false; + }; + + self.last_preview_only_downward_local_sample.is_some() + && self.last_preview_only_local_registration_result == Some("matched") + && self.transient_burst_motion_hint_exceeds_local_authority(preferred.motion_rows) + && !self.transient_burst_growth_matches_pending_hint_band(preferred.viewport_top_y) + && local_motion_rows > PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS + && local_motion_rows + > preferred + .motion_rows + .saturating_add(UNDERCONSUMED_OBSERVED_BURST_RECOVERY_GAP_ROWS) + && preferred_growth_rows + <= last_motion_rows_hint + .saturating_mul(2) + .max(PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS) + } + + pub(super) fn should_fail_closed_committed_only_recovery_after_corroborated_sample_registration_without_viewport_anchor( + &self, + preferred: DownwardViewportCandidate, + preferred_growth_rows: u32, + ) -> bool { + let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { + return false; + }; + let Some(observed_motion_rows) = self.last_observed_sample_registration_motion_rows else { + return false; + }; + let Some(local_motion_rows) = self.last_preview_only_local_registration_motion_rows else { + return false; + }; + let corroborated_motion_floor = + last_motion_rows_hint.saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS); + let corroborated_motion_ceiling = observed_motion_rows + .max(local_motion_rows) + .saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS); + + preferred.source == DownwardViewportCandidateSource::CommittedKeyframe + && self.transient_burst_search_enabled + && self.last_preview_only_downward_local_sample.is_some() + && self.last_observed_sample_registration_result == Some("matched") + && self.last_preview_only_local_registration_result == Some("matched") + && last_motion_rows_hint <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS + && observed_motion_rows > corroborated_motion_floor + && local_motion_rows > corroborated_motion_floor + && preferred_growth_rows > corroborated_motion_floor + && preferred.motion_rows >= local_motion_rows + && preferred_growth_rows <= corroborated_motion_ceiling + } + + pub(super) fn should_fail_closed_committed_only_recovery_when_observed_burst_outpaces_recent_preview_local_commit( + &self, + preferred: DownwardViewportCandidate, + preferred_growth_rows: u32, + ) -> bool { + let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { + return false; + }; + let Some(observed_motion_rows) = self.last_observed_sample_registration_motion_rows else { + return false; + }; + let recent_preview_local_commit = self.growth_history.last().is_some_and(|commit| { + commit.decision_source + == DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source() + && commit.growth_rows + >= last_motion_rows_hint.saturating_sub(PREVIEW_ONLY_LOCAL_NEAR_CONTINUITY_ROWS) + }); + let corroborated_motion_floor = + last_motion_rows_hint.saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS); + + preferred.source == DownwardViewportCandidateSource::CommittedKeyframe + && self.transient_burst_search_enabled + && recent_preview_local_commit + && self.last_observed_sample_registration_result == Some("matched") + && self.last_preview_only_local_registration_result == Some("no_match") + && last_motion_rows_hint <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS + && observed_motion_rows > corroborated_motion_floor + && preferred_growth_rows > corroborated_motion_floor + && preferred.motion_rows.saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS) + < observed_motion_rows + } + + pub(super) fn should_suppress_preview_only_local_candidate( + &self, + candidate: DownwardViewportCandidate, + ) -> bool { + candidate.source == DownwardViewportCandidateSource::PreviewOnlyLocalSample + && self.transient_burst_motion_hint_exceeds_local_authority(candidate.motion_rows) + && candidate.motion_rows > PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS + && !self.preview_only_local_candidate_remains_trustworthy_in_burst(candidate) + } + + pub(super) fn should_suppress_observed_sample_candidate( + &self, + candidate: DownwardViewportCandidate, + ) -> bool { + candidate.source == DownwardViewportCandidateSource::ObservedSample + && self.transient_burst_search_enabled + && self.fallback_downward_growth_exceeds_continuity_budget(candidate.viewport_top_y) + && !self.observed_sample_candidate_remains_trustworthy_in_burst(candidate) + } + + pub(super) fn observed_sample_candidate_remains_trustworthy_in_burst( + &self, + candidate: DownwardViewportCandidate, + ) -> bool { + if candidate.source != DownwardViewportCandidateSource::ObservedSample { + return false; + } + + let growth_rows = self.growth_rows_for_candidate_viewport_top_y(candidate.viewport_top_y); + + self.transient_burst_motion_hint_exceeds_local_authority(candidate.motion_rows) + && self.last_motion_rows_hint.is_some_and(|last_hint| { + candidate.motion_rows.saturating_add(UNDERCONSUMED_OBSERVED_BURST_RECOVERY_GAP_ROWS) + >= last_hint && candidate.motion_rows <= last_hint + }) && candidate.mean_abs_diff_x100 <= DIRECTION_WARNING_MARGIN_X100.saturating_mul(6) + && self.transient_pending_growth_cap_rows().is_some_and(|cap| growth_rows <= cap) + } + + pub(super) fn preview_only_local_candidate_has_material_progress( + &self, + candidate: DownwardViewportCandidate, + ) -> bool { + if self.seeded_preview_only_local_catch_up_candidate_can_commit(candidate) { + return true; + } + + candidate.source == DownwardViewportCandidateSource::PreviewOnlyLocalSample && { + let growth_rows = + self.growth_rows_for_candidate_viewport_top_y(candidate.viewport_top_y); + + growth_rows >= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS + || self.last_motion_rows_hint.is_some_and(|last_hint| { + last_hint >= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS + && growth_rows.saturating_add(PREVIEW_ONLY_LOCAL_NEAR_CONTINUITY_ROWS) + >= last_hint + }) || self.last_motion_rows_hint.is_some_and(|last_hint| { + last_hint >= DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS + && candidate.motion_rows.abs_diff(last_hint) + <= PREVIEW_ONLY_LOCAL_NEAR_CONTINUITY_ROWS + }) + } + } + + pub(super) fn preview_only_local_candidate_remains_trustworthy_in_burst( + &self, + candidate: DownwardViewportCandidate, + ) -> bool { + if candidate.source != DownwardViewportCandidateSource::PreviewOnlyLocalSample { + return true; + } + if candidate.motion_rows <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS { + return true; + } + + self.transient_burst_growth_matches_pending_hint_band(candidate.viewport_top_y) + && self.last_motion_rows_hint.is_some_and(|last_hint| { + candidate.motion_rows + <= last_hint.saturating_add(PREVIEW_ONLY_LOCAL_RECOVERY_MAX_TOLERANCE_ROWS) + }) + } + + pub(super) fn seeded_preview_only_local_catch_up_candidate_can_commit( + &self, + candidate: DownwardViewportCandidate, + ) -> bool { + candidate.source == DownwardViewportCandidateSource::PreviewOnlyLocalSample + && self.seeded_preview_only_local_after_observed_burst_commit + && candidate.viewport_top_y > self.current_viewport_top_y + && candidate.motion_rows <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS + } + + pub(super) fn prune_committed_keyframe_candidates_for_transient_burst( + &mut self, + candidates: &mut Vec, + ) { + if !self.transient_burst_search_enabled { + return; + } + + let Some(local_candidate) = candidates + .iter() + .copied() + .filter(|candidate| candidate.source == DownwardViewportCandidateSource::ObservedSample) + .min_by(|left, right| { + left.mean_abs_diff_x100 + .cmp(&right.mean_abs_diff_x100) + .then(left.motion_rows.cmp(&right.motion_rows)) + }) + else { + return; + }; + let Some(previous_growth_rows) = self.last_motion_rows_hint else { + return; + }; + + if local_candidate.motion_rows <= previous_growth_rows { + return; + } + + candidates.retain(|candidate| { + candidate.source != DownwardViewportCandidateSource::CommittedKeyframe + || candidate.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) + < local_candidate.mean_abs_diff_x100 + }); + } + + pub(super) fn max_committed_keyframe_local_overrun_rows( + &self, + local_anchor: DownwardViewportCandidate, + ) -> u32 { + self.max_committed_keyframe_motion_rows(local_anchor).clamp( + DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS, + DOWNWARD_COMMITTED_KEYFRAME_LOCAL_OVERRUN_MAX_ROWS, + ) + } + + pub(super) fn max_committed_keyframe_motion_rows( + &self, + local_anchor: DownwardViewportCandidate, + ) -> u32 { + let continuity_rows = self + .last_motion_rows_hint + .unwrap_or(local_anchor.motion_rows) + .max(local_anchor.motion_rows); + let tolerance_rows = (continuity_rows / 2).clamp(1, DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS); + + continuity_rows.saturating_add(tolerance_rows) + } + + pub(super) fn committed_candidate_can_plausibly_replace_underconsumed_preview_local_anchor( + &self, + local_anchor: DownwardViewportCandidate, + committed_candidate: DownwardViewportCandidate, + ) -> bool { + if committed_candidate.source != DownwardViewportCandidateSource::CommittedKeyframe { + return false; + } + + let allowed_overrun_rows = self + .max_committed_keyframe_local_overrun_rows(local_anchor) + .max(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS); + let max_allowed_motion_rows = self.max_committed_keyframe_motion_rows(local_anchor); + let max_allowed_viewport_top_y = local_anchor + .viewport_top_y + .saturating_add(i32::try_from(allowed_overrun_rows).unwrap_or(i32::MAX)); + let local_anchor_tracks_recent_continuity = self + .last_motion_rows_hint + .is_some_and(|last_hint| local_anchor.motion_rows >= last_hint); + let committed_is_not_materially_worse_than_local_anchor = committed_candidate + .mean_abs_diff_x100 + <= local_anchor.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100); + + (committed_candidate.viewport_top_y <= max_allowed_viewport_top_y + && committed_candidate.motion_rows <= max_allowed_motion_rows) + && (!local_anchor_tracks_recent_continuity + || committed_is_not_materially_worse_than_local_anchor) + || self.transient_burst_growth_matches_pending_hint_band( + committed_candidate.viewport_top_y, + ) || self.committed_candidate_can_override_untrustworthy_observed_local_recovery( + local_anchor, + committed_candidate, + ) + } + + pub(super) fn committed_candidate_can_override_untrustworthy_observed_local_recovery( + &self, + local_anchor: DownwardViewportCandidate, + committed_candidate: DownwardViewportCandidate, + ) -> bool { + let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { + return false; + }; + let Some(transient_growth_cap_rows) = self.transient_pending_growth_cap_rows() else { + return false; + }; + + if committed_candidate.source != DownwardViewportCandidateSource::CommittedKeyframe { + return false; + } + + let local_growth_rows = + self.growth_rows_for_candidate_viewport_top_y(local_anchor.viewport_top_y); + let committed_growth_rows = + self.growth_rows_for_candidate_viewport_top_y(committed_candidate.viewport_top_y); + + local_anchor.source == DownwardViewportCandidateSource::ObservedSample + && self.transient_burst_motion_hint_exceeds_local_authority(local_anchor.motion_rows) + && local_anchor.mean_abs_diff_x100 > DIRECTION_WARNING_MARGIN_X100.saturating_mul(4) + && local_anchor.motion_rows + <= last_motion_rows_hint.saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS) + && (committed_growth_rows + <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS.saturating_mul(2) + || self.transient_burst_growth_matches_pending_hint_band( + committed_candidate.viewport_top_y, + )) && committed_candidate.mean_abs_diff_x100 + <= DIRECTION_WARNING_MARGIN_X100.saturating_mul(2) + && committed_candidate + .mean_abs_diff_x100 + .saturating_add(DIRECTION_WARNING_MARGIN_X100.saturating_mul(3)) + < local_anchor.mean_abs_diff_x100 + && committed_candidate.motion_rows + > local_anchor + .motion_rows + .saturating_add(UNDERCONSUMED_OBSERVED_BURST_RECOVERY_GAP_ROWS) + && committed_growth_rows + > local_growth_rows.saturating_add(UNDERCONSUMED_OBSERVED_BURST_RECOVERY_GAP_ROWS) + && committed_growth_rows <= transient_growth_cap_rows + } + + pub(super) fn bootstrap_committed_keyframe_growth_cap_rows(&self) -> Option { + if !self.initial_downward_bootstrap_active() { + return None; + } + + self.transient_pending_growth_cap_rows() + } + + pub(super) fn transient_pending_growth_cap_rows(&self) -> Option { + let hint = self.normalized_transient_motion_rows_hint()?; + let tolerance = (hint / 2).clamp(1, PREVIEW_ONLY_LOCAL_RECOVERY_MAX_TOLERANCE_ROWS); + + Some(hint.saturating_add(tolerance)) + } + + pub(super) fn transient_burst_growth_matches_pending_hint_band( + &self, + candidate_viewport_top_y: i32, + ) -> bool { + if !self.transient_burst_search_enabled { + return false; + } + + let Some(transient_hint) = self.normalized_transient_motion_rows_hint() else { + return false; + }; + let growth_rows = self.growth_rows_for_candidate_viewport_top_y(candidate_viewport_top_y); + let min_growth_rows = + (transient_hint / 2).max(self.last_motion_rows_hint.unwrap_or_default()); + + self.transient_pending_growth_cap_rows() + .is_some_and(|cap| growth_rows >= min_growth_rows && growth_rows <= cap) + } + + pub(super) fn collect_committed_downward_viewport_candidates( + &self, + frame: &RgbaImage, + candidates: &mut Vec, + ) { + self.collect_committed_downward_viewport_candidates_with_mode( + frame, + candidates, + CommittedDownwardViewportCandidateMode::IncludeRecentHistory, + ); + } + + pub(super) fn collect_fallback_downward_viewport_candidates( + &self, + frame: &RgbaImage, + candidates: &mut Vec, + ) { + self.collect_committed_downward_viewport_candidates_with_mode( + frame, + candidates, + CommittedDownwardViewportCandidateMode::LastCommittedOnly, + ); + } + + pub(super) fn collect_committed_downward_viewport_candidates_with_mode( + &self, + frame: &RgbaImage, + candidates: &mut Vec, + mode: CommittedDownwardViewportCandidateMode, + ) { + self.push_downward_viewport_candidate( + &self.last_committed_frame, + self.current_viewport_top_y, + frame, + DownwardViewportCandidateSource::CommittedKeyframe, + candidates, + ); + + if mode == CommittedDownwardViewportCandidateMode::LastCommittedOnly + || DOWNWARD_KEYFRAME_SEARCH_LIMIT <= 1 + { + return; + } + + for commit in self + .growth_history + .iter() + .rev() + .skip(1) + .take(DOWNWARD_KEYFRAME_SEARCH_LIMIT.saturating_sub(1)) + { + self.push_downward_viewport_candidate( + &commit.frame, + commit.viewport_top_y, + frame, + DownwardViewportCandidateSource::CommittedKeyframe, + candidates, + ); + } + } + + pub(super) fn push_downward_viewport_candidate( + &self, + reference: &RgbaImage, + reference_viewport_top_y: i32, + frame: &RgbaImage, + source: DownwardViewportCandidateSource, + candidates: &mut Vec, + ) { + let predicted_motion_rows = self.downward_keyframe_motion_hint(reference_viewport_top_y); + let allow_full_range_fallback = + !(self.initial_downward_bootstrap_active() && predicted_motion_rows.is_none()); + let mut registration = self.evaluate_reference_downward_registration( + reference, + frame, + predicted_motion_rows, + allow_full_range_fallback, + ); + + if source == DownwardViewportCandidateSource::CommittedKeyframe + && self.should_retry_committed_keyframe_registration_across_full_range(registration) + { + let full_range_registration = self + .evaluate_reference_downward_registration_with_preferred_range( + reference, + frame, + predicted_motion_rows, + None, + true, + ); + + registration = self.prefer_full_range_committed_keyframe_registration( + registration, + full_range_registration, + ); + } + + if let DownwardRegistration::Matched(matched) = registration { + if self.bootstrap_motion_exceeds_pending_hint(matched.motion_rows) { + return; + } + + let max_overlap = reference.height().min(frame.height()); + let min_keyframe_overlap_rows = OverlapSearchConfig::default() + .min_overlap_rows + .max(max_overlap / DOWNWARD_KEYFRAME_MIN_OVERLAP_DIVISOR) + .max(1); + let overlap_rows = max_overlap.saturating_sub(matched.motion_rows); + + if overlap_rows < min_keyframe_overlap_rows { + return; + } + + let viewport_top_y = reference_viewport_top_y + .saturating_add(i32::try_from(matched.motion_rows).unwrap_or_default()); + + if viewport_top_y <= self.current_viewport_top_y { + return; + } + + candidates.push(DownwardViewportCandidate { + source, + viewport_top_y, + motion_rows: matched.motion_rows, + mean_abs_diff_x100: matched.mean_abs_diff_x100, + }); + } + } + + pub(super) fn should_retry_committed_keyframe_registration_across_full_range( + &self, + registration: DownwardRegistration, + ) -> bool { + let DownwardRegistration::Matched(matched) = registration else { + return false; + }; + let Some(last_motion_rows_hint) = self.last_motion_rows_hint else { + return false; + }; + let low_confidence_match = + matched.mean_abs_diff_x100 > DIRECTION_WARNING_MARGIN_X100.saturating_mul(4); + let tiny_underconsumed_match = self + .transient_burst_motion_hint_exceeds_local_authority(matched.motion_rows) + && matched.mean_abs_diff_x100 > DIRECTION_WARNING_MARGIN_X100.saturating_mul(4) + && matched.motion_rows + <= last_motion_rows_hint.saturating_add(DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS); + let large_overshot_match = matched.motion_rows > last_motion_rows_hint.saturating_mul(2); + + low_confidence_match && (tiny_underconsumed_match || large_overshot_match) + } + + pub(super) fn prefer_full_range_committed_keyframe_registration( + &self, + preferred_range_registration: DownwardRegistration, + full_range_registration: DownwardRegistration, + ) -> DownwardRegistration { + match (preferred_range_registration, full_range_registration) { + (DownwardRegistration::Matched(preferred), DownwardRegistration::Matched(full)) + if full.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) + < preferred.mean_abs_diff_x100 + && preferred.motion_rows.abs_diff(full.motion_rows) + > UNDERCONSUMED_OBSERVED_BURST_RECOVERY_GAP_ROWS => + { + DownwardRegistration::Matched(full) + }, + (preferred, _) => preferred, + } + } + + pub(super) fn downward_keyframe_motion_hint( + &self, + reference_viewport_top_y: i32, + ) -> Option { + let last_motion_rows = self.last_motion_rows_hint?; + let already_traversed_rows = u32::try_from( + self.current_viewport_top_y.saturating_sub(reference_viewport_top_y).max(0), + ) + .unwrap_or_default(); + + Some(already_traversed_rows.saturating_add(last_motion_rows)) + } + + pub(super) fn fallback_downward_growth_blocked_while_resume_frontier_active( + &mut self, + candidate_viewport_top_y: i32, + motion_rows: u32, + preview_changed: bool, + decision_source: &'static str, + ) -> Option { + let resume_frontier_top_y = self.resume_frontier_top_y?; + let growth_rows = if candidate_viewport_top_y <= resume_frontier_top_y { + 0 + } else { + u32::try_from(candidate_viewport_top_y - resume_frontier_top_y).unwrap_or_default() + }; + + self.log_decision( + "scroll_capture.fallback_downward_blocked_while_resume_frontier_active", + ScrollDirection::Down, + Some(MotionObservation { direction: ScrollDirection::Down, motion_rows }), + Some(candidate_viewport_top_y), + Some(growth_rows), + Some(decision_source), + ); + + Some(preview_update_outcome(preview_changed)) + } + + pub(super) fn fallback_downward_growth_exceeds_continuity_budget( + &self, + candidate_viewport_top_y: i32, + ) -> bool { + let growth_rows = self.growth_rows_for_candidate_viewport_top_y(candidate_viewport_top_y); + let Some(base_continuity_rows) = self.last_motion_rows_hint else { + return false; + }; + let local_overrun_rows = base_continuity_rows + .saturating_mul(2) + .clamp(FALLBACK_DOWNWARD_GROWTH_MIN_ROWS, FALLBACK_DOWNWARD_GROWTH_MAX_ROWS); + let preview_local_rows = self + .last_preview_only_downward_local_sample + .as_ref() + .map(|sample| { + u32::try_from( + sample.viewport_top_y.saturating_sub(self.current_viewport_top_y).max(0), + ) + .unwrap_or_default() + }) + .unwrap_or_default(); + let max_growth_rows = preview_local_rows.saturating_add(local_overrun_rows); + + growth_rows > max_growth_rows + } + + pub(super) fn observe_fallback_downward_growth( + &mut self, + frame: RgbaImage, + preview_changed: bool, + ) -> Result { + let mut candidates = Vec::with_capacity(DOWNWARD_KEYFRAME_SEARCH_LIMIT); + + self.collect_fallback_downward_viewport_candidates(&frame, &mut candidates); + + match select_downward_viewport_candidate(&mut candidates) { + DownwardViewportResolution::NoMatch => { + self.refresh_preview_only_downward_local_sample( + &frame, + self.stable_preview_only_downward_local_viewport_top_y(), + ); + self.log_decision( + "scroll_capture.fallback_downward_no_match", + ScrollDirection::Down, + None, + None, + Some(0), + Some("no_committed_keyframe_match"), + ); + + Ok(preview_update_outcome(preview_changed)) + }, + DownwardViewportResolution::Selected(candidate) => { + if self.fallback_downward_growth_exceeds_continuity_budget(candidate.viewport_top_y) + { + self.refresh_preview_only_downward_local_sample( + &frame, + self.stable_preview_only_downward_local_viewport_top_y(), + ); + self.log_decision( + "scroll_capture.fallback_downward_growth_blocked", + ScrollDirection::Down, + Some(MotionObservation { + direction: ScrollDirection::Down, + motion_rows: candidate.motion_rows, + }), + Some(candidate.viewport_top_y), + Some( + self.growth_rows_for_candidate_viewport_top_y(candidate.viewport_top_y), + ), + Some("fallback_committed_candidate_exceeded_local_continuity_budget"), + ); + + return Ok(preview_update_outcome(preview_changed)); + } + + if let Some(outcome) = self + .fallback_downward_growth_blocked_while_resume_frontier_active( + candidate.viewport_top_y, + candidate.motion_rows, + preview_changed, + "resume_frontier_active_blocks_keyframe_fallback_downward_match", + ) { + return Ok(outcome); + } + + self.observe_downward_growth_to_viewport( + frame, + candidate.viewport_top_y, + preview_changed, + Some(MotionObservation { + direction: ScrollDirection::Down, + motion_rows: candidate.motion_rows, + }), + candidate.source.fallback_decision_source(), + ) + }, + DownwardViewportResolution::Ambiguous { preferred, competing } => { + self.refresh_preview_only_downward_local_sample( + &frame, + self.stable_preview_only_downward_local_viewport_top_y(), + ); + self.log_decision( + "scroll_capture.fallback_ambiguous_downward_registration", + ScrollDirection::Down, + Some(MotionObservation { + direction: ScrollDirection::Down, + motion_rows: preferred.motion_rows, + }), + Some(preferred.viewport_top_y), + Some(0), + Some(preferred.competing_block_reason(competing)), + ); + + Ok(preview_update_outcome(preview_changed)) + }, + } + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn apply_growth( + &mut self, + frame: RgbaImage, + growth_rows: u32, + viewport_top_y: i32, + decision_source: &'static str, + detected_motion_rows: Option, + effective_motion_rows_hint: Option, + previous_motion_rows_hint: Option, + ) -> Result { + let fingerprint = scroll_capture_fingerprint(&frame); + let strip = crop_bottom_rows(&frame, growth_rows) + .ok_or_else(|| eyre::eyre!("failed to extract growth strip"))?; + let preview_strip = resize_strip_to_preview_width(&strip, self.preview_width_px); + + self.export_image = append_vertical_image(&self.export_image, &strip)?; + self.preview_image = append_vertical_image(&self.preview_image, &preview_strip)?; + + self.bottom_segments.push(strip); + self.bottom_preview_segments.push(preview_strip); + + self.current_viewport_top_y = viewport_top_y; + self.observed_viewport_top_y = viewport_top_y; + + self.record_last_sample(&frame, fingerprint); + self.record_last_downward_observed_sample(&frame, scroll_capture_fingerprint(&frame)); + + if self.should_seed_preview_only_local_after_observed_burst_commit( + decision_source, + growth_rows, + previous_motion_rows_hint, + ) { + self.record_preview_only_downward_local_sample(&frame, viewport_top_y); + + self.seeded_preview_only_local_after_observed_burst_commit = true; + } else if self.should_preserve_preview_only_local_after_preview_only_burst_commit( + decision_source, + growth_rows, + previous_motion_rows_hint, + ) { + self.record_preview_only_downward_local_sample(&frame, viewport_top_y); + + self.seeded_preview_only_local_after_observed_burst_commit = false; + self.last_blocked_preview_only_local_candidate = None; + } else { + self.clear_preview_only_downward_local_sample(); + } + + self.last_unconfirmed_upward_fingerprint = None; + self.last_committed_frame = frame.clone(); + self.resume_frontier_top_y = None; + self.resume_frontier_requires_reacquire = false; + + self.growth_history.push(GrowthCommit { + frame, + growth_rows, + viewport_top_y, + decision_source, + detected_motion_rows, + effective_motion_rows_hint, + }); + + Ok(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows }) + } + + pub(super) fn should_seed_preview_only_local_after_observed_burst_commit( + &self, + decision_source: &'static str, + growth_rows: u32, + previous_motion_rows_hint: Option, + ) -> bool { + decision_source == DownwardViewportCandidateSource::ObservedSample.decision_source() + && self.transient_burst_search_enabled + && previous_motion_rows_hint.is_some_and(|previous| { + previous >= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS && growth_rows < previous + }) + } + + pub(super) fn should_preserve_preview_only_local_after_preview_only_burst_commit( + &self, + decision_source: &'static str, + growth_rows: u32, + previous_motion_rows_hint: Option, + ) -> bool { + decision_source == DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source() + && previous_motion_rows_hint.is_some_and(|previous| { + if self.transient_burst_search_enabled { + growth_rows >= DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS + && growth_rows + >= previous.saturating_sub(PREVIEW_ONLY_LOCAL_NEAR_CONTINUITY_ROWS) + && growth_rows + <= previous + .saturating_add(PREVIEW_ONLY_LOCAL_RECOVERY_MAX_TOLERANCE_ROWS) + } else { + previous <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS + && growth_rows > 1 && growth_rows <= PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS + && growth_rows <= previous + } + }) + } + + pub(super) fn rebuild_export_image(&self) -> Result { + let mut ordered = Vec::with_capacity(self.bottom_segments.len().saturating_add(1)); + + ordered.push(&self.anchor_frame); + + for strip in &self.bottom_segments { + ordered.push(strip); + } + + stack_vertical_images(&ordered) + } + + pub(super) fn rebuild_preview_image(&self) -> Result { + let mut ordered = Vec::with_capacity(self.bottom_preview_segments.len().saturating_add(1)); + + ordered.push(&self.anchor_preview); + + for strip in &self.bottom_preview_segments { + ordered.push(strip); + } + + stack_vertical_images(&ordered) + } +} diff --git a/packages/rsnap-overlay/src/scroll_capture/support.rs b/packages/rsnap-overlay/src/scroll_capture/support.rs new file mode 100644 index 00000000..dc8a20f0 --- /dev/null +++ b/packages/rsnap-overlay/src/scroll_capture/support.rs @@ -0,0 +1,906 @@ +use std::ops::RangeInclusive; +#[cfg(target_os = "macos")] +use std::ptr; + +use color_eyre::eyre::{self, Result}; +use image::{ + RgbaImage, + imageops::{self, FilterType}, +}; +#[cfg(target_os = "macos")] +use objc2::{AnyThread, runtime::AnyObject}; +#[cfg(target_os = "macos")] +use objc2_core_foundation::CFData; +#[cfg(target_os = "macos")] +use objc2_core_foundation::CFRetained; +#[cfg(target_os = "macos")] +use objc2_core_graphics::{ + CGBitmapInfo, CGColorRenderingIntent, CGColorSpace, CGDataProvider, CGImage, CGImageAlphaInfo, + CGImageByteOrderInfo, +}; +#[cfg(target_os = "macos")] +use objc2_foundation::{NSArray, NSDictionary}; +#[cfg(target_os = "macos")] +use objc2_vision::{VNImageOption, VNImageRequestHandler, VNTranslationalImageRegistrationRequest}; + +#[cfg(test)] +use super::OverlapMatch; +use super::{ + DIRECTION_WARNING_MARGIN_X100, DOWNWARD_REGISTRATION_AMBIGUOUS_GAP_ROWS, + DOWNWARD_REGISTRATION_MIN_OVERLAP_DIVISOR, DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS, + DirectionMatch, DownwardRegistration, DownwardViewportCandidate, + DownwardViewportCandidateSource, DownwardViewportResolution, + INFORMATIVE_SPAN_HORIZONTAL_PADDING_PX, INFORMATIVE_SPAN_ROW_SAMPLES, + INFORMATIVE_SPAN_SCORE_FLOOR_X100, InformativeSpan, OverlapSearchConfig, + RESUME_DIRECT_PROOF_MAX_MEAN_ABS_DIFF_X100, ScrollDirection, ScrollFrameFingerprint, + ScrollObserveOutcome, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OverlapOrientation { + PreviousBottomToNextTop, + PreviousTopToNextBottom, +} + +#[must_use] +pub(crate) fn scroll_capture_fingerprint(image: &RgbaImage) -> Vec { + ScrollFrameFingerprint::from_image(image).into_bytes() +} + +#[must_use] +pub(crate) fn scroll_capture_fingerprint_delta(left: &[u8], right: &[u8]) -> u32 { + if left.len() != right.len() || left.is_empty() || !left.len().is_multiple_of(4) { + return u32::MAX; + } + + let mut total_abs_diff = 0_u64; + let mut comparisons = 0_u64; + + for (left_pixel, right_pixel) in left.chunks_exact(4).zip(right.chunks_exact(4)) { + total_abs_diff = total_abs_diff + .saturating_add(u64::from(left_pixel[0].abs_diff(right_pixel[0]))) + .saturating_add(u64::from(left_pixel[1].abs_diff(right_pixel[1]))) + .saturating_add(u64::from(left_pixel[2].abs_diff(right_pixel[2]))) + .saturating_add(u64::from(left_pixel[3].abs_diff(right_pixel[3]))); + comparisons = comparisons.saturating_add(4); + } + + if comparisons == 0 { u32::MAX } else { (total_abs_diff / comparisons) as u32 } +} + +#[cfg(test)] +#[must_use] +pub(crate) fn detect_vertical_overlap( + previous: &RgbaImage, + next: &RgbaImage, + config: OverlapSearchConfig, +) -> OverlapMatch { + detect_vertical_overlap_in_range( + previous, + next, + 1..=previous.height().min(next.height()), + ScrollDirection::Down, + config, + overlap_global_informative_span(previous, next), + ) +} + +pub(crate) fn compose_provisional_preview_image( + base_preview: &RgbaImage, + latest_frame: Option<&RgbaImage>, + motion_rows_hint: Option, + preview_width_px: u32, +) -> RgbaImage { + let Some(frame) = latest_frame else { + return base_preview.clone(); + }; + let Some(motion_rows_hint) = motion_rows_hint else { + return base_preview.clone(); + }; + let hinted_growth_rows = motion_rows_hint.min(frame.height()); + + if hinted_growth_rows == 0 { + return base_preview.clone(); + } + + let Some(strip) = crop_bottom_rows(frame, hinted_growth_rows) else { + return base_preview.clone(); + }; + let preview_strip = resize_strip_to_preview_width(&strip, preview_width_px); + + append_vertical_image(base_preview, &preview_strip).unwrap_or_else(|_| base_preview.clone()) +} + +fn worker_pairwise_overlap_search_config() -> OverlapSearchConfig { + OverlapSearchConfig { + min_overlap_rows: 24, + max_column_samples: 96, + max_row_samples: 96, + max_mean_abs_diff_x100: 850, + } +} + +#[cfg(target_os = "macos")] +pub(super) fn classify_vision_downward_sample_motion_against( + previous: &RgbaImage, + next: &RgbaImage, +) -> Option { + let previous_cg = cg_image_from_rgba_image(previous).ok()?; + let next_cg = cg_image_from_rgba_image(next).ok()?; + let options = NSDictionary::::new(); + let request = unsafe { + VNTranslationalImageRegistrationRequest::initWithTargetedCGImage_options( + VNTranslationalImageRegistrationRequest::alloc(), + previous_cg.as_ref(), + options.as_ref(), + ) + }; + let request_array = NSArray::from_retained_slice(&[request + .clone() + .into_super() + .into_super() + .into_super() + .into_super()]); + let handler = unsafe { + VNImageRequestHandler::initWithCGImage_options( + VNImageRequestHandler::alloc(), + next_cg.as_ref(), + options.as_ref(), + ) + }; + + handler.performRequests_error(request_array.as_ref()).ok()?; + + let results = unsafe { request.results() }?; + + if results.count() == 0 { + return None; + } + + let translation = unsafe { results.objectAtIndex(0).alignmentTransform() }; + let motion_rows = translation.ty.round(); + + if !motion_rows.is_finite() || motion_rows <= 0.0 { + return None; + } + + let motion_rows = motion_rows as u32; + let config = OverlapSearchConfig::default(); + let matched = evaluate_overlap_direction( + previous, + next, + ScrollDirection::Down, + motion_rows..=motion_rows, + config, + )?; + let max_overlap = previous.height().min(next.height()); + + downward_registration_has_meaningful_overlap(matched, max_overlap, config).then_some(matched) +} + +#[cfg(not(target_os = "macos"))] +pub(super) fn classify_vision_downward_sample_motion_against( + _previous: &RgbaImage, + _next: &RgbaImage, +) -> Option { + None +} + +pub(super) fn estimate_pairwise_downward_shift_rows( + previous: &RgbaImage, + current: &RgbaImage, +) -> Option { + if previous.dimensions() != current.dimensions() { + return None; + } + + let (_width, height) = previous.dimensions(); + + if height < 3 { + return None; + } + + let max_shift = height.saturating_sub(1); + + evaluate_overlap_direction( + previous, + current, + ScrollDirection::Down, + 1..=max_shift, + worker_pairwise_overlap_search_config(), + ) + .map(|matched| matched.motion_rows) +} + +#[cfg(target_os = "macos")] +fn cg_image_from_rgba_image(image: &RgbaImage) -> Result> { + let width = image.width() as usize; + let height = image.height() as usize; + + if width == 0 || height == 0 { + return Err(eyre::eyre!("vision registration image has zero dimensions")); + } + + let bytes = CFData::from_bytes(image.as_raw()); + let provider = CGDataProvider::with_cf_data(Some(bytes.as_ref())) + .ok_or_else(|| eyre::eyre!("failed to create CGDataProvider for Vision registration"))?; + let color_space = CGColorSpace::new_device_rgb() + .ok_or_else(|| eyre::eyre!("failed to create RGB colorspace for Vision registration"))?; + let bitmap_info = CGBitmapInfo(CGImageAlphaInfo::Last.0 | CGImageByteOrderInfo::Order32Big.0); + + unsafe { + CGImage::new( + width, + height, + 8, + 32, + width.saturating_mul(4), + Some(color_space.as_ref()), + bitmap_info, + Some(provider.as_ref()), + ptr::null(), + false, + CGColorRenderingIntent::RenderingIntentDefault, + ) + } + .ok_or_else(|| eyre::eyre!("failed to create CGImage for Vision registration")) +} + +pub(super) fn select_downward_viewport_candidate( + candidates: &mut [DownwardViewportCandidate], +) -> DownwardViewportResolution { + if candidates.is_empty() { + return DownwardViewportResolution::NoMatch; + } + + if let Some(preferred_local) = prefer_local_downward_viewport_candidate(candidates) { + let competing = candidates.iter().copied().find(|candidate| { + candidate != &preferred_local + && candidate.viewport_top_y.abs_diff(preferred_local.viewport_top_y) + >= DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS + && candidate.mean_abs_diff_x100 + <= preferred_local + .mean_abs_diff_x100 + .saturating_add(DIRECTION_WARNING_MARGIN_X100) + }); + + return match competing { + Some(competing) => { + DownwardViewportResolution::Ambiguous { preferred: preferred_local, competing } + }, + None => DownwardViewportResolution::Selected(preferred_local), + }; + } + + candidates.sort_by(|left, right| { + left.mean_abs_diff_x100 + .cmp(&right.mean_abs_diff_x100) + .then(left.source.priority().cmp(&right.source.priority())) + .then(left.motion_rows.cmp(&right.motion_rows)) + }); + + let preferred = candidates[0]; + let competing = candidates.iter().copied().skip(1).find(|candidate| { + candidate.viewport_top_y.abs_diff(preferred.viewport_top_y) + >= DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS + && candidate.mean_abs_diff_x100 + <= preferred.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) + }); + + match competing { + Some(competing) => DownwardViewportResolution::Ambiguous { preferred, competing }, + None => DownwardViewportResolution::Selected(preferred), + } +} + +pub(super) fn format_downward_viewport_candidates( + candidates: &[DownwardViewportCandidate], +) -> String { + candidates + .iter() + .map(|candidate| { + format!( + "{:?}@{}/{}:{}", + candidate.source, + candidate.viewport_top_y, + candidate.motion_rows, + candidate.mean_abs_diff_x100 + ) + }) + .collect::>() + .join(",") +} + +fn prefer_local_downward_viewport_candidate( + candidates: &[DownwardViewportCandidate], +) -> Option { + let local = best_local_downward_viewport_candidate(candidates)?; + let committed = candidates + .iter() + .copied() + .filter(|candidate| candidate.source == DownwardViewportCandidateSource::CommittedKeyframe) + .min_by(|left, right| { + left.mean_abs_diff_x100 + .cmp(&right.mean_abs_diff_x100) + .then(left.motion_rows.cmp(&right.motion_rows)) + }); + let Some(committed) = committed else { + return Some(local); + }; + let committed_is_nearby = committed.viewport_top_y.abs_diff(local.viewport_top_y) + < DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS; + let committed_is_only_modestly_better = + committed.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) + >= local.mean_abs_diff_x100; + + if committed_is_nearby && committed_is_only_modestly_better { Some(local) } else { None } +} + +pub(super) fn best_local_downward_viewport_candidate( + candidates: &[DownwardViewportCandidate], +) -> Option { + candidates + .iter() + .copied() + .filter(|candidate| candidate.source != DownwardViewportCandidateSource::CommittedKeyframe) + .min_by(|left, right| { + left.mean_abs_diff_x100 + .cmp(&right.mean_abs_diff_x100) + .then(left.source.priority().cmp(&right.source.priority())) + .then(left.motion_rows.cmp(&right.motion_rows)) + }) +} + +pub(super) fn evaluate_overlap_direction( + previous: &RgbaImage, + next: &RgbaImage, + direction: ScrollDirection, + range: RangeInclusive, + config: OverlapSearchConfig, +) -> Option { + collect_overlap_direction_matches(previous, next, direction, range, config).into_iter().next() +} + +pub(super) fn collect_overlap_direction_matches( + previous: &RgbaImage, + next: &RgbaImage, + direction: ScrollDirection, + range: RangeInclusive, + config: OverlapSearchConfig, +) -> Vec { + let Some(informative_span) = overlap_global_informative_span(previous, next) else { + return Vec::new(); + }; + let max_overlap = previous.height().min(next.height()); + let effective_min_overlap = + if max_overlap <= config.min_overlap_rows { 1 } else { config.min_overlap_rows.max(1) }; + let max_motion_rows = max_overlap.saturating_sub(effective_min_overlap).max(1); + let search_start = (*range.start()).max(1).min(max_motion_rows); + let search_end = (*range.end()).max(search_start).min(max_motion_rows); + let orientation = match direction { + ScrollDirection::Down => OverlapOrientation::PreviousBottomToNextTop, + ScrollDirection::Up => OverlapOrientation::PreviousTopToNextBottom, + }; + let mut matches = Vec::with_capacity(search_end.saturating_sub(search_start) as usize + 1); + + for motion_rows in search_start..=search_end { + let overlap_rows = max_overlap.saturating_sub(motion_rows); + + if overlap_rows < effective_min_overlap { + continue; + } + + let diff = motion_mean_abs_diff_x100( + previous, + next, + motion_rows, + config, + orientation, + informative_span, + ); + + if diff > config.max_mean_abs_diff_x100 { + continue; + } + + matches.push(DirectionMatch { mean_abs_diff_x100: diff, motion_rows }); + } + + matches.sort_by(|left, right| { + left.mean_abs_diff_x100 + .cmp(&right.mean_abs_diff_x100) + .then(left.motion_rows.cmp(&right.motion_rows)) + }); + + matches +} + +pub(super) fn collect_overlap_direction_matches_in_ranges( + previous: &RgbaImage, + next: &RgbaImage, + direction: ScrollDirection, + ranges: &[RangeInclusive], + config: OverlapSearchConfig, +) -> Vec { + let mut matches = Vec::new(); + + for range in ranges { + matches.extend(collect_overlap_direction_matches( + previous, + next, + direction, + range.clone(), + config, + )); + } + + if matches.len() <= 1 { + return matches; + } + + matches.sort_by(|left, right| { + left.motion_rows + .cmp(&right.motion_rows) + .then(left.mean_abs_diff_x100.cmp(&right.mean_abs_diff_x100)) + }); + + let mut deduped: Vec = Vec::with_capacity(matches.len()); + + for matched in matches { + if let Some(previous) = deduped.last_mut() + && previous.motion_rows == matched.motion_rows + { + if matched.mean_abs_diff_x100 < previous.mean_abs_diff_x100 { + *previous = matched; + } + + continue; + } + + deduped.push(matched); + } + + deduped.sort_by(|left, right| { + left.mean_abs_diff_x100 + .cmp(&right.mean_abs_diff_x100) + .then(left.motion_rows.cmp(&right.motion_rows)) + }); + + deduped +} + +pub(super) fn classify_downward_registration_candidates( + candidates: &[DirectionMatch], +) -> DownwardRegistration { + let Some(best) = candidates.first().copied() else { + return DownwardRegistration::NoMatch; + }; + let competing = candidates.iter().copied().skip(1).find(|candidate| { + candidate.motion_rows.abs_diff(best.motion_rows) >= DOWNWARD_REGISTRATION_AMBIGUOUS_GAP_ROWS + }); + + match competing { + Some(competing) + if best.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) + >= competing.mean_abs_diff_x100 => + { + DownwardRegistration::Ambiguous { best, competing } + }, + _ => DownwardRegistration::Matched(best), + } +} + +pub(super) fn downward_registration_has_meaningful_overlap( + matched: DirectionMatch, + max_overlap: u32, + config: OverlapSearchConfig, +) -> bool { + let overlap_rows = max_overlap.saturating_sub(matched.motion_rows); + let effective_min_overlap = + if max_overlap <= config.min_overlap_rows { 1 } else { config.min_overlap_rows.max(1) }; + let min_overlap_rows = + effective_min_overlap.max(max_overlap / DOWNWARD_REGISTRATION_MIN_OVERLAP_DIVISOR).max(1); + + overlap_rows >= min_overlap_rows +} + +pub(super) fn preview_update_outcome(preview_changed: bool) -> ScrollObserveOutcome { + if preview_changed { + ScrollObserveOutcome::PreviewUpdated + } else { + ScrollObserveOutcome::NoChange + } +} + +pub(super) fn resume_direct_match_is_trustworthy(matched: DirectionMatch) -> bool { + matched.mean_abs_diff_x100 <= RESUME_DIRECT_PROOF_MAX_MEAN_ABS_DIFF_X100 +} + +pub(super) fn preferred_upward_override_match( + up_match: Option, + down_match: Option, +) -> Option { + match (up_match, down_match) { + (Some(up), Some(_down)) if resume_direct_match_is_trustworthy(up) => Some(up), + (Some(up), None) if resume_direct_match_is_trustworthy(up) => Some(up), + _ => None, + } +} + +pub(super) fn preferred_upward_input_override_match( + sample_match: Option, + committed_match: Option, +) -> Option<(DirectionMatch, bool)> { + match (sample_match, committed_match) { + (Some(sample), Some(committed)) + if committed.motion_rows <= sample.motion_rows + && committed.mean_abs_diff_x100 + <= sample.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) => + { + Some((committed, true)) + }, + (Some(sample), Some(_committed)) => Some((sample, false)), + (Some(sample), None) => Some((sample, false)), + (None, Some(committed)) => Some((committed, true)), + (None, None) => None, + } +} + +pub(super) fn upward_confirmation_match_for_downward_input( + up_match: Option, + down_match: Option, + has_committed_growth: bool, +) -> Option { + if !has_committed_growth { + return None; + } + + match (up_match, down_match) { + (Some(up), Some(down)) + if resume_direct_match_is_trustworthy(up) + && up.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) + <= down.mean_abs_diff_x100 => + { + Some(up) + }, + (Some(up), None) if resume_direct_match_is_trustworthy(up) => Some(up), + _ => None, + } +} + +pub(super) fn rewind_active_upward_override_match( + sample_match: Option, + committed_match: Option, + rewind_active: bool, +) -> Option<(DirectionMatch, bool)> { + if !rewind_active { + return None; + } + + match (sample_match, committed_match) { + (Some(sample), Some(committed)) + if committed.motion_rows < sample.motion_rows + && committed.mean_abs_diff_x100 + <= sample.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) => + { + Some((committed, true)) + }, + (Some(sample), _) => Some((sample, false)), + (None, Some(committed)) => Some((committed, true)), + (None, None) => None, + } +} + +pub(super) fn rewind_active_upward_motion_should_fail_closed( + sample_up_match: Option, + committed_up_match: Option, + committed_down_match: Option, + rewind_active: bool, +) -> bool { + if !rewind_active { + return false; + } + if committed_up_match.is_some() { + return false; + } + + matches!( + (sample_up_match, committed_down_match), + (Some(sample_up), Some(committed_down)) + if committed_down.mean_abs_diff_x100 + <= sample_up.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) + && committed_down.motion_rows >= sample_up.motion_rows + ) +} + +pub(super) fn max_directional_motion_rows( + previous: &RgbaImage, + next: &RgbaImage, + config: OverlapSearchConfig, +) -> u32 { + let max_overlap = previous.height().min(next.height()); + let effective_min_overlap = + if max_overlap <= config.min_overlap_rows { 1 } else { config.min_overlap_rows.max(1) }; + + max_overlap.saturating_sub(effective_min_overlap).max(1) +} + +#[cfg(test)] +fn detect_vertical_overlap_in_range( + previous: &RgbaImage, + next: &RgbaImage, + range: RangeInclusive, + direction: ScrollDirection, + config: OverlapSearchConfig, + informative_span: Option, +) -> OverlapMatch { + if previous.width() == 0 || next.width() == 0 || previous.height() == 0 || next.height() == 0 { + return OverlapMatch { rows: 0, matched: false, mean_abs_diff_x100: u32::MAX }; + } + + let Some(informative_span) = informative_span else { + return OverlapMatch { rows: 0, matched: false, mean_abs_diff_x100: u32::MAX }; + }; + let max_overlap = previous.height().min(next.height()); + let effective_min_overlap = + if max_overlap <= config.min_overlap_rows { 1 } else { config.min_overlap_rows.max(1) }; + let max_motion_rows = max_overlap.saturating_sub(effective_min_overlap).max(1); + let search_start = (*range.start()).max(1).min(max_motion_rows); + let search_end = (*range.end()).max(search_start).min(max_motion_rows); + let orientation = match direction { + ScrollDirection::Down => OverlapOrientation::PreviousBottomToNextTop, + ScrollDirection::Up => OverlapOrientation::PreviousTopToNextBottom, + }; + let mut best = OverlapMatch { rows: 0, matched: false, mean_abs_diff_x100: u32::MAX }; + + for motion_rows in search_start..=search_end { + let overlap_rows = max_overlap.saturating_sub(motion_rows); + + if overlap_rows < effective_min_overlap { + continue; + } + + let diff = motion_mean_abs_diff_x100( + previous, + next, + motion_rows, + config, + orientation, + informative_span, + ); + + if diff > config.max_mean_abs_diff_x100 { + continue; + } + if !best.matched + || diff < best.mean_abs_diff_x100 + || (diff == best.mean_abs_diff_x100 && overlap_rows > best.rows) + { + best = OverlapMatch { rows: overlap_rows, matched: true, mean_abs_diff_x100: diff }; + } + } + + best +} + +pub(super) fn resize_strip_to_preview_width(strip: &RgbaImage, preview_width_px: u32) -> RgbaImage { + if strip.width() <= preview_width_px { + return strip.clone(); + } + + let preview_height = ((strip.height() as f32 / strip.width() as f32) * preview_width_px as f32) + .round() + .max(1.0) as u32; + + imageops::resize(strip, preview_width_px, preview_height, FilterType::Triangle) +} + +pub(super) fn crop_bottom_rows(frame: &RgbaImage, rows: u32) -> Option { + let rows = rows.min(frame.height()); + + if rows == 0 { + return None; + } + + let start_y = frame.height().saturating_sub(rows); + + Some(imageops::crop_imm(frame, 0, start_y, frame.width(), rows).to_image()) +} + +pub(super) fn stack_vertical_images(images: &[&RgbaImage]) -> Result { + let Some(first) = images.first() else { + return Err(eyre::eyre!("cannot stack an empty image list")); + }; + let width = first.width(); + let total_height = images.iter().try_fold(0_u32, |acc, image| { + if image.width() != width { + return Err(eyre::eyre!( + "image width mismatch while stacking: expected {} got {}", + width, + image.width() + )); + } + + acc.checked_add(image.height()).ok_or_else(|| eyre::eyre!("stacked image height overflow")) + })?; + let total_bytes = images.iter().try_fold(0_usize, |acc, image| { + acc.checked_add(image.as_raw().len()) + .ok_or_else(|| eyre::eyre!("stacked image byte length overflow")) + })?; + let mut raw = Vec::with_capacity(total_bytes); + + for image in images { + raw.extend_from_slice(image.as_raw()); + } + + RgbaImage::from_raw(width, total_height, raw) + .ok_or_else(|| eyre::eyre!("failed to construct stacked image buffer")) +} + +pub(super) fn append_vertical_image(base: &RgbaImage, strip: &RgbaImage) -> Result { + if base.width() != strip.width() { + return Err(eyre::eyre!( + "image width mismatch while appending: expected {} got {}", + base.width(), + strip.width() + )); + } + + stack_vertical_images(&[base, strip]) +} + +fn motion_mean_abs_diff_x100( + previous: &RgbaImage, + next: &RgbaImage, + motion_rows: u32, + config: OverlapSearchConfig, + orientation: OverlapOrientation, + informative_span: InformativeSpan, +) -> u32 { + let width = previous.width().min(next.width()); + let max_overlap = previous.height().min(next.height()); + let overlap_rows = max_overlap.saturating_sub(motion_rows); + + if overlap_rows == 0 { + return u32::MAX; + } + + let column_samples = width.min(config.max_column_samples).max(1); + let row_samples = overlap_rows.min(config.max_row_samples).max(1); + let previous_overlap_start_y = previous.height().saturating_sub(overlap_rows); + let next_overlap_start_y = next.height().saturating_sub(overlap_rows); + let previous_start_y = match orientation { + OverlapOrientation::PreviousBottomToNextTop => previous_overlap_start_y, + OverlapOrientation::PreviousTopToNextBottom => 0, + }; + let next_start_y = match orientation { + OverlapOrientation::PreviousBottomToNextTop => 0, + OverlapOrientation::PreviousTopToNextBottom => next_overlap_start_y, + }; + let x_start = informative_span.start_x.min(width.saturating_sub(1)); + let x_end = informative_span.end_exclusive_x.min(width).max(x_start + 1); + let effective_width = x_end.saturating_sub(x_start).max(1); + let column_samples = effective_width.min(column_samples).max(1); + let mut total_abs_diff = 0_u64; + let mut comparisons = 0_u64; + + for row in 0..row_samples { + let local_y = evenly_spaced_sample(0, overlap_rows, row, row_samples); + let previous_y = + previous_start_y.saturating_add(local_y).min(previous.height().saturating_sub(1)); + let next_y = next_start_y.saturating_add(local_y).min(next.height().saturating_sub(1)); + + for column in 0..column_samples { + let x = evenly_spaced_sample(x_start, x_end, column, column_samples); + let previous_pixel = previous.get_pixel(x, previous_y).0; + let next_pixel = next.get_pixel(x, next_y).0; + + total_abs_diff = total_abs_diff + .saturating_add(u64::from(previous_pixel[0].abs_diff(next_pixel[0]))) + .saturating_add(u64::from(previous_pixel[1].abs_diff(next_pixel[1]))) + .saturating_add(u64::from(previous_pixel[2].abs_diff(next_pixel[2]))); + comparisons = comparisons.saturating_add(3); + } + } + + if comparisons == 0 { + return u32::MAX; + } + + ((total_abs_diff.saturating_mul(100)) / comparisons) as u32 +} + +fn overlap_global_informative_span(left: &RgbaImage, right: &RgbaImage) -> Option { + let left_span = informative_column_span(left, 0, left.height()); + let right_span = informative_column_span(right, 0, right.height()); + let width = left.width().min(right.width()); + + match (left_span, right_span) { + (Some(left_span), Some(right_span)) => { + let start_x = left_span.start_x.max(right_span.start_x); + let end_exclusive_x = + left_span.end_exclusive_x.min(right_span.end_exclusive_x).min(width); + + (end_exclusive_x > start_x).then_some(InformativeSpan { start_x, end_exclusive_x }) + }, + (Some(span), None) | (None, Some(span)) => { + let end_exclusive_x = span.end_exclusive_x.min(width).max(span.start_x + 1); + + (end_exclusive_x > span.start_x) + .then_some(InformativeSpan { start_x: span.start_x, end_exclusive_x }) + }, + (None, None) => None, + } +} + +pub(super) fn informative_column_span( + image: &RgbaImage, + start_y: u32, + rows: u32, +) -> Option { + if image.width() == 0 || image.height() == 0 || rows == 0 { + return None; + } + + let clamped_rows = rows.min(image.height().saturating_sub(start_y)).max(1); + let row_samples = clamped_rows.min(INFORMATIVE_SPAN_ROW_SAMPLES.max(2)).max(2); + let mut scores = vec![0_u32; image.width() as usize]; + let mut max_score = 0_u32; + + for row in 0..row_samples.saturating_sub(1) { + let local_y = evenly_spaced_sample(0, clamped_rows, row, row_samples); + let next_local_y = (local_y.saturating_add(1)).min(clamped_rows.saturating_sub(1)); + let y = start_y.saturating_add(local_y).min(image.height().saturating_sub(1)); + let next_y = start_y.saturating_add(next_local_y).min(image.height().saturating_sub(1)); + + for x in 0..image.width() { + let pixel = image.get_pixel(x, y).0; + let next_pixel = image.get_pixel(x, next_y).0; + let score = u32::from(pixel[0].abs_diff(next_pixel[0])) + .saturating_add(u32::from(pixel[1].abs_diff(next_pixel[1]))) + .saturating_add(u32::from(pixel[2].abs_diff(next_pixel[2]))); + let slot = &mut scores[x as usize]; + + *slot = slot.saturating_add(score); + max_score = max_score.max(*slot); + } + } + + if max_score == 0 { + return None; + } + + let threshold = (max_score / 6).max(INFORMATIVE_SPAN_SCORE_FLOOR_X100); + let mut start_x = None; + let mut end_x = None; + + for (x, score) in scores.iter().enumerate() { + if *score >= threshold { + start_x.get_or_insert(x as u32); + + end_x = Some((x as u32).saturating_add(1)); + } + } + + let start_x = start_x?; + let end_exclusive_x = end_x?; + let padding = INFORMATIVE_SPAN_HORIZONTAL_PADDING_PX.min(image.width() / 8); + let start_x = start_x.saturating_sub(padding); + let end_exclusive_x = + end_exclusive_x.saturating_add(padding).min(image.width()).max(start_x.saturating_add(1)); + + Some(InformativeSpan { start_x, end_exclusive_x }) +} + +pub(super) fn evenly_spaced_sample(start: u32, end_exclusive: u32, index: u32, count: u32) -> u32 { + let span = end_exclusive.saturating_sub(start).max(1); + + if count <= 1 { + return start.min(end_exclusive.saturating_sub(1)); + } + + let numerator = + (u64::from(index) * u64::from(span.saturating_sub(1))) / u64::from(count.saturating_sub(1)); + + start.saturating_add(numerator as u32).min(end_exclusive.saturating_sub(1)) +} diff --git a/packages/rsnap-overlay/src/scroll_capture/tests.rs b/packages/rsnap-overlay/src/scroll_capture/tests.rs new file mode 100644 index 00000000..89b7ebaf --- /dev/null +++ b/packages/rsnap-overlay/src/scroll_capture/tests.rs @@ -0,0 +1,2920 @@ +use image::Rgba; + +use crate::scroll_capture::{ + self, DirectionMatch, DownwardRegistration, DownwardSampleMatch, DownwardSampleMatchSource, + DownwardViewportCandidate, DownwardViewportCandidateSource, DownwardViewportResolution, + GrowthCommit, MotionObservation, OverlapSearchConfig, PreviewOnlyDownwardLocalSample, + ScrollDirection, ScrollFrameFingerprint, ScrollObserveOutcome, ScrollSession, +}; + +fn make_test_image(width: u32, rows: &[[u8; 4]]) -> image::RgbaImage { + let mut image = image::RgbaImage::new(width, rows.len() as u32); + + for (y, row) in rows.iter().enumerate() { + for x in 0..width { + image.put_pixel(x, y as u32, Rgba(*row)); + } + } + + image +} + +fn make_window( + document: &[[u8; 4]], + width: u32, + start_row: usize, + window_rows: usize, +) -> image::RgbaImage { + make_test_image(width, &document[start_row..start_row + window_rows]) +} + +fn make_sparse_textlike_window(width: u32, height: u32, start_row: u32) -> image::RgbaImage { + let stripe_x = 104_u32; + let mut image = image::RgbaImage::from_pixel(width, height, Rgba([255, 255, 255, 255])); + + for y in 0..height { + let document_row = start_row.saturating_add(y); + let shade = ((document_row.saturating_mul(17)) % 180) as u8; + + for x in stripe_x..stripe_x.saturating_add(6) { + image.put_pixel(x, y, Rgba([shade, shade, shade, 255])); + } + for x in stripe_x.saturating_add(10)..stripe_x.saturating_add(13) { + if document_row % 19 < 9 { + image.put_pixel(x, y, Rgba([40, 40, 40, 255])); + } + } + } + + image +} + +fn make_sparse_textlike_window_with_moving_edge_scrollbar( + width: u32, + height: u32, + start_row: u32, + thumb_top: u32, +) -> image::RgbaImage { + let track_left = width.saturating_sub(18); + let thumb_height = (height / 4).max(12).min(height.max(1)); + let thumb_top = thumb_top.min(height.saturating_sub(thumb_height)); + let thumb_right = width.saturating_sub(3).max(track_left.saturating_add(4)); + let mut image = make_sparse_textlike_window(width, height, start_row); + + for y in 0..height { + for x in track_left..width { + image.put_pixel(x, y, Rgba([224, 224, 224, 255])); + } + } + for y in thumb_top..thumb_top.saturating_add(thumb_height) { + for x in track_left.saturating_add(3)..thumb_right { + image.put_pixel(x, y, Rgba([28, 28, 28, 255])); + } + } + + image +} + +fn make_browser_like_window(width: u32, height: u32, start_row: u32) -> image::RgbaImage { + let scrollbar_left = width.saturating_sub(18); + let content_left = 56_u32; + let content_right = width.saturating_sub(48); + let heading_width = 220_u32; + let paragraph_width = content_right.saturating_sub(content_left); + let mut image = make_sparse_textlike_window(width, height, start_row); + + for y in 0..height { + let document_row = start_row.saturating_add(y); + + if document_row % 420 < 18 { + for x in content_left..content_left.saturating_add(heading_width) { + image.put_pixel(x, y, Rgba([26, 26, 26, 255])); + } + } else if document_row % 420 >= 54 && document_row % 420 < 220 { + if document_row % 24 < 3 { + let trim = ((document_row / 24) % 5) * 18; + + for x in + content_left..content_left.saturating_add(paragraph_width.saturating_sub(trim)) + { + image.put_pixel(x, y, Rgba([72, 72, 72, 255])); + } + } + } else if document_row % 420 >= 270 && document_row % 420 < 360 && document_row % 20 < 2 { + for x in content_left.saturating_add(20) + ..content_left.saturating_add(paragraph_width.saturating_sub(70)) + { + image.put_pixel(x, y, Rgba([98, 98, 98, 255])); + } + } + + for x in scrollbar_left..width { + image.put_pixel(x, y, Rgba([232, 232, 232, 255])); + } + } + + let thumb_height = (height / 5).max(16); + let thumb_top = (start_row / 3) % height.max(thumb_height + 1); + let thumb_top = thumb_top.min(height.saturating_sub(thumb_height)); + + for y in thumb_top..thumb_top.saturating_add(thumb_height) { + for x in scrollbar_left.saturating_add(3)..width.saturating_sub(4) { + image.put_pixel(x, y, Rgba([96, 96, 96, 255])); + } + } + + image +} + +#[test] +fn overlap_detection_prefers_largest_matching_suffix() { + let previous = make_test_image( + 5, + &[ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + ], + ); + let next = make_test_image( + 5, + &[[40, 0, 0, 255], [50, 0, 0, 255], [60, 0, 0, 255], [70, 0, 0, 255], [80, 0, 0, 255]], + ); + let overlap = scroll_capture::detect_vertical_overlap( + &previous, + &next, + OverlapSearchConfig { min_overlap_rows: 1, ..Default::default() }, + ); + + assert!(overlap.matched); + assert_eq!(overlap.rows, 3); +} + +#[test] +fn fingerprint_wrapper_returns_zero_delta_for_identical_images() { + let image = image::RgbaImage::from_pixel(12, 12, Rgba([9, 8, 7, 255])); + let left = scroll_capture::scroll_capture_fingerprint(&image); + let right = scroll_capture::scroll_capture_fingerprint(&image); + + assert_eq!(scroll_capture::scroll_capture_fingerprint_delta(&left, &right), 0); +} + +#[test] +fn fingerprint_struct_distance_detects_changed_image() { + let base = image::RgbaImage::from_pixel(12, 12, Rgba([9, 8, 7, 255])); + let changed = image::RgbaImage::from_pixel(12, 12, Rgba([30, 8, 7, 255])); + let left = ScrollFrameFingerprint::from_image(&base); + let right = ScrollFrameFingerprint::from_image(&changed); + + assert!(left.distance(&right) > 0); +} + +#[test] +fn session_commits_downward_growth_on_first_matching_sample() { + let base = make_test_image( + 3, + &[[10, 0, 0, 255], [20, 0, 0, 255], [30, 0, 0, 255], [40, 0, 0, 255], [50, 0, 0, 255]], + ); + let moved = make_test_image( + 3, + &[[20, 0, 0, 255], [30, 0, 0, 255], [40, 0, 0, 255], [50, 0, 0, 255], [60, 0, 0, 255]], + ); + let mut session = ScrollSession::new(base.clone(), 320).unwrap(); + let outcome = session.observe_downward_sample(moved).unwrap(); + + assert_eq!( + outcome, + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); + assert_eq!(session.export_image().height(), 6); + assert_eq!(session.export_image().get_pixel(0, 5), &Rgba([60, 0, 0, 255])); +} + +#[cfg(target_os = "macos")] +#[test] +fn worker_pairwise_vision_commits_substantial_downward_growth_with_corroboration() { + let base = make_sparse_textlike_window(512, 640, 0); + let moved = make_sparse_textlike_window(512, 640, 90); + let matched = scroll_capture::classify_vision_downward_sample_motion_against(&base, &moved) + .expect("vision registration should detect the substantial downward motion"); + let mut session = ScrollSession::new(base, 320).unwrap(); + let outcome = session.observe_worker_pairwise_vision_frame(moved).unwrap(); + + assert!(matched.motion_rows >= 32); + assert_eq!( + outcome, + ScrollObserveOutcome::Committed { + direction: ScrollDirection::Down, + growth_rows: matched.motion_rows, + } + ); + assert_eq!(session.export_image().height(), 640 + matched.motion_rows); + assert_eq!(session.current_viewport_top_y(), i32::try_from(matched.motion_rows).unwrap()); +} + +#[test] +fn pairwise_downward_shift_estimate_matches_sparse_textlike_motion() { + let base = make_sparse_textlike_window(512, 640, 0); + let moved = make_sparse_textlike_window(512, 640, 58); + + assert_eq!(scroll_capture::estimate_pairwise_downward_shift_rows(&base, &moved), Some(58)); +} + +#[test] +fn pairwise_downward_shift_estimate_matches_browser_like_motion_above_legacy_cap() { + let base = make_browser_like_window(512, 640, 0); + let moved = make_browser_like_window(512, 640, 320); + + assert_eq!(scroll_capture::estimate_pairwise_downward_shift_rows(&base, &moved), Some(320)); +} + +#[test] +fn pairwise_downward_shift_estimate_tracks_successive_browser_like_steps() { + let frames = [0_u32, 180, 360, 540, 720] + .into_iter() + .map(|start_row| make_browser_like_window(512, 640, start_row)) + .collect::>(); + + for window in frames.windows(2) { + assert_eq!( + scroll_capture::estimate_pairwise_downward_shift_rows(&window[0], &window[1]), + Some(180) + ); + } +} + +#[cfg(target_os = "macos")] +#[test] +fn worker_pairwise_vision_uses_latest_committed_live_frame_for_followup_growth() { + let base = make_sparse_textlike_window(512, 640, 0); + let step_one = make_sparse_textlike_window(512, 640, 180); + let step_two = make_sparse_textlike_window(512, 640, 360); + let first_match = + scroll_capture::classify_vision_downward_sample_motion_against(&base, &step_one) + .expect("first pairwise registration should detect downward motion"); + let followup_match = + scroll_capture::classify_vision_downward_sample_motion_against(&step_one, &step_two) + .expect("followup pairwise registration should detect downward motion"); + let mut session = ScrollSession::new(base, 320).unwrap(); + + assert_eq!( + session.observe_worker_pairwise_vision_frame(step_one).unwrap(), + ScrollObserveOutcome::Committed { + direction: ScrollDirection::Down, + growth_rows: first_match.motion_rows, + } + ); + assert_eq!( + session.observe_worker_pairwise_vision_frame(step_two).unwrap(), + ScrollObserveOutcome::Committed { + direction: ScrollDirection::Down, + growth_rows: followup_match.motion_rows, + } + ); + assert_eq!( + session.export_image().height(), + 640 + first_match.motion_rows + followup_match.motion_rows + ); + assert_eq!( + session.current_viewport_top_y(), + i32::try_from(first_match.motion_rows + followup_match.motion_rows).unwrap() + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn worker_pairwise_vision_handles_repeated_frame_between_growth_steps() { + let base = make_sparse_textlike_window(512, 640, 0); + let step_one = make_sparse_textlike_window(512, 640, 180); + let step_two = make_sparse_textlike_window(512, 640, 360); + let first_match = + scroll_capture::classify_vision_downward_sample_motion_against(&base, &step_one) + .expect("first pairwise registration should detect downward motion"); + let followup_match = + scroll_capture::classify_vision_downward_sample_motion_against(&step_one, &step_two) + .expect("followup pairwise registration should detect downward motion"); + let mut session = ScrollSession::new(base, 320).unwrap(); + + assert_eq!( + session.observe_worker_pairwise_vision_frame(step_one.clone()).unwrap(), + ScrollObserveOutcome::Committed { + direction: ScrollDirection::Down, + growth_rows: first_match.motion_rows, + } + ); + assert_eq!( + session.observe_worker_pairwise_vision_frame(step_one).unwrap(), + ScrollObserveOutcome::NoChange + ); + assert_eq!( + session.observe_worker_pairwise_vision_frame(step_two).unwrap(), + ScrollObserveOutcome::Committed { + direction: ScrollDirection::Down, + growth_rows: followup_match.motion_rows, + } + ); + assert_eq!( + session.export_image().height(), + 640 + first_match.motion_rows + followup_match.motion_rows + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn worker_pairwise_vision_recovers_after_blocked_overshot_frame() { + let base = make_browser_like_window(512, 640, 0); + let blocked = make_browser_like_window(512, 640, 760); + let followup = make_browser_like_window(512, 640, 844); + let matched = scroll_capture::classify_vision_downward_sample_motion_against( + &blocked, &followup, + ) + .expect("pairwise registration should detect the followup step after the blocked overshot"); + let mut session = ScrollSession::new(base, 320).unwrap(); + + assert_eq!( + session.observe_worker_pairwise_vision_frame(blocked).unwrap(), + ScrollObserveOutcome::NoChange + ); + assert_eq!(session.export_image().height(), 640); + assert_eq!(session.current_viewport_top_y(), 0); + assert_eq!( + session.observe_worker_pairwise_vision_frame(followup).unwrap(), + ScrollObserveOutcome::Committed { + direction: ScrollDirection::Down, + growth_rows: matched.motion_rows, + } + ); + assert_eq!(session.export_image().height(), 640 + matched.motion_rows); + assert_eq!(session.current_viewport_top_y(), i32::try_from(matched.motion_rows).unwrap()); +} + +#[cfg(target_os = "macos")] +#[test] +fn worker_pairwise_vision_clears_preview_local_followup_carryover_on_no_change() { + let base = make_sparse_textlike_window(512, 640, 0); + let mut session = ScrollSession::new(base.clone(), 320).unwrap(); + + session.record_preview_only_downward_local_sample(&base, 123); + + session.pending_suppressed_huge_preview_only_local_followup = Some(DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 160, + motion_rows: 160, + mean_abs_diff_x100: 0, + }); + session.pending_suppressed_huge_preview_only_local_followup_remaining_blocks = 2; + session.pending_extreme_preview_only_local_tail_followup = Some(DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 161, + motion_rows: 1, + mean_abs_diff_x100: 0, + }); + session.pending_extreme_preview_only_local_tail_followup_remaining_blocks = 1; + + assert_eq!( + session.observe_worker_pairwise_vision_frame(base).unwrap(), + ScrollObserveOutcome::NoChange + ); + assert!(session.last_preview_only_downward_local_sample.is_none()); + assert!(session.pending_suppressed_huge_preview_only_local_followup.is_none()); + assert_eq!(session.pending_suppressed_huge_preview_only_local_followup_remaining_blocks, 0); + assert!(session.pending_extreme_preview_only_local_tail_followup.is_none()); + assert_eq!(session.pending_extreme_preview_only_local_tail_followup_remaining_blocks, 0); +} + +#[cfg(target_os = "macos")] +#[test] +fn worker_pairwise_vision_clears_preview_local_followup_carryover_on_commit() { + let base = make_sparse_textlike_window(512, 640, 0); + let moved = make_sparse_textlike_window(512, 640, 180); + let matched = scroll_capture::classify_vision_downward_sample_motion_against(&base, &moved) + .expect("pairwise registration should detect downward motion"); + let mut session = ScrollSession::new(base, 320).unwrap(); + + session.record_preview_only_downward_local_sample(&moved, 180); + + session.pending_suppressed_huge_preview_only_local_followup = Some(DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 160, + motion_rows: 160, + mean_abs_diff_x100: 0, + }); + session.pending_suppressed_huge_preview_only_local_followup_remaining_blocks = 2; + session.pending_extreme_preview_only_local_tail_followup = Some(DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 161, + motion_rows: 1, + mean_abs_diff_x100: 0, + }); + session.pending_extreme_preview_only_local_tail_followup_remaining_blocks = 1; + + assert_eq!( + session.observe_worker_pairwise_vision_frame(moved).unwrap(), + ScrollObserveOutcome::Committed { + direction: ScrollDirection::Down, + growth_rows: matched.motion_rows, + } + ); + assert!(session.last_preview_only_downward_local_sample.is_none()); + assert!(session.pending_suppressed_huge_preview_only_local_followup.is_none()); + assert_eq!(session.pending_suppressed_huge_preview_only_local_followup_remaining_blocks, 0); + assert!(session.pending_extreme_preview_only_local_tail_followup.is_none()); + assert_eq!(session.pending_extreme_preview_only_local_tail_followup_remaining_blocks, 0); +} + +#[cfg(target_os = "macos")] +#[test] +fn worker_pairwise_vision_commits_successive_slowdown_steps() { + let frames = [0_u32, 180, 300, 380, 420] + .into_iter() + .map(|start_row| make_sparse_textlike_window(512, 640, start_row)) + .collect::>(); + let mut session = ScrollSession::new(frames[0].clone(), 320).unwrap(); + let mut expected_export_height = 640_u32; + let mut expected_viewport_top_y = 0_i32; + + for window in frames.windows(2) { + let previous = &window[0]; + let next = window[1].clone(); + let matched = + scroll_capture::classify_vision_downward_sample_motion_against(previous, &next) + .expect("pairwise registration should detect each slowdown step"); + + assert_eq!( + session.observe_worker_pairwise_vision_frame(next).unwrap(), + ScrollObserveOutcome::Committed { + direction: ScrollDirection::Down, + growth_rows: matched.motion_rows, + } + ); + + expected_export_height = expected_export_height.saturating_add(matched.motion_rows); + expected_viewport_top_y += i32::try_from(matched.motion_rows).unwrap(); + } + + assert_eq!(session.export_image().height(), expected_export_height); + assert_eq!(session.current_viewport_top_y(), expected_viewport_top_y); +} + +#[cfg(target_os = "macos")] +#[test] +fn worker_pairwise_vision_commits_browser_like_growth_above_legacy_cap() { + let base = make_browser_like_window(512, 640, 0); + let moved = make_browser_like_window(512, 640, 320); + let matched = scroll_capture::classify_vision_downward_sample_motion_against(&base, &moved) + .expect("vision registration should detect the browser-like downward motion"); + let mut session = ScrollSession::new(base, 320).unwrap(); + + assert!(matched.motion_rows > 256); + assert_eq!( + session.observe_worker_pairwise_vision_frame(moved).unwrap(), + ScrollObserveOutcome::Committed { + direction: ScrollDirection::Down, + growth_rows: matched.motion_rows, + } + ); + assert_eq!(session.export_image().height(), 640 + matched.motion_rows); + assert_eq!(session.current_viewport_top_y(), i32::try_from(matched.motion_rows).unwrap()); +} + +#[cfg(target_os = "macos")] +#[test] +fn worker_pairwise_vision_commits_successive_browser_like_steps() { + let frames = [0_u32, 180, 360, 540, 720] + .into_iter() + .map(|start_row| make_browser_like_window(512, 640, start_row)) + .collect::>(); + let mut session = ScrollSession::new(frames[0].clone(), 320).unwrap(); + let mut expected_export_height = 640_u32; + let mut expected_viewport_top_y = 0_i32; + + for window in frames.windows(2) { + let previous = &window[0]; + let next = window[1].clone(); + let matched = + scroll_capture::classify_vision_downward_sample_motion_against(previous, &next) + .expect("pairwise registration should detect each browser-like step"); + + assert_eq!( + session.observe_worker_pairwise_vision_frame(next).unwrap(), + ScrollObserveOutcome::Committed { + direction: ScrollDirection::Down, + growth_rows: matched.motion_rows, + } + ); + + expected_export_height = expected_export_height.saturating_add(matched.motion_rows); + expected_viewport_top_y += i32::try_from(matched.motion_rows).unwrap(); + } + + assert_eq!(session.export_image().height(), expected_export_height); + assert_eq!(session.current_viewport_top_y(), expected_viewport_top_y); +} + +#[cfg(target_os = "macos")] +#[test] +fn worker_pairwise_vision_handles_repeated_browser_like_frame_between_growth_steps() { + let base = make_browser_like_window(512, 640, 0); + let step_one = make_browser_like_window(512, 640, 180); + let step_two = make_browser_like_window(512, 640, 360); + let first_match = + scroll_capture::classify_vision_downward_sample_motion_against(&base, &step_one) + .expect("first browser-like step should register downward motion"); + let followup_match = + scroll_capture::classify_vision_downward_sample_motion_against(&step_one, &step_two) + .expect("followup browser-like step should register downward motion"); + let mut session = ScrollSession::new(base, 320).unwrap(); + + assert_eq!( + session.observe_worker_pairwise_vision_frame(step_one.clone()).unwrap(), + ScrollObserveOutcome::Committed { + direction: ScrollDirection::Down, + growth_rows: first_match.motion_rows, + } + ); + assert_eq!( + session.observe_worker_pairwise_vision_frame(step_one).unwrap(), + ScrollObserveOutcome::NoChange + ); + assert_eq!( + session.observe_worker_pairwise_vision_frame(step_two).unwrap(), + ScrollObserveOutcome::Committed { + direction: ScrollDirection::Down, + growth_rows: followup_match.motion_rows, + } + ); + assert_eq!( + session.export_image().height(), + 640 + first_match.motion_rows + followup_match.motion_rows + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn worker_pairwise_vision_browser_like_followup_uses_adjacent_worker_frame() { + let base = make_browser_like_window(512, 640, 0); + let blocked = make_browser_like_window(512, 640, 700); + let followup = make_browser_like_window(512, 640, 784); + let matched = scroll_capture::classify_vision_downward_sample_motion_against( + &blocked, &followup, + ) + .expect("browser-like pairwise registration should use the immediately previous worker frame"); + let mut session = ScrollSession::new(base, 320).unwrap(); + + assert_eq!( + session.observe_worker_pairwise_vision_frame(blocked).unwrap(), + ScrollObserveOutcome::NoChange + ); + assert_eq!( + session.observe_worker_pairwise_vision_frame(followup).unwrap(), + ScrollObserveOutcome::Committed { + direction: ScrollDirection::Down, + growth_rows: matched.motion_rows, + } + ); + assert_eq!(session.export_image().height(), 640 + matched.motion_rows); + assert_eq!(session.current_viewport_top_y(), i32::try_from(matched.motion_rows).unwrap()); +} + +#[test] +fn session_supports_multiple_downward_growth_steps() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + ]; + let mut session = ScrollSession::new(make_window(&document, 3, 0, 5), 320).unwrap(); + + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 2, 5)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); + assert_eq!(session.export_image().height(), 7); + assert_eq!(session.export_image().get_pixel(0, 0), &Rgba([10, 0, 0, 255])); + assert_eq!(session.export_image().get_pixel(0, 6), &Rgba([70, 0, 0, 255])); +} + +#[test] +fn downward_hot_path_falls_back_when_scroll_step_grows() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + [80, 0, 0, 255], + [90, 0, 0, 255], + ]; + let mut session = ScrollSession::new(make_window(&document, 3, 0, 5), 320).unwrap(); + + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 4, 5)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 3 } + ); + assert_eq!(session.export_image().height(), 9); + assert_eq!(session.export_image().get_pixel(0, 0), &Rgba([10, 0, 0, 255])); + assert_eq!(session.export_image().get_pixel(0, 8), &Rgba([90, 0, 0, 255])); +} + +#[test] +fn session_reports_upward_motion_without_growing() { + let base = make_test_image( + 3, + &[[20, 0, 0, 255], [30, 0, 0, 255], [40, 0, 0, 255], [50, 0, 0, 255], [60, 0, 0, 255]], + ); + let moved = make_test_image( + 3, + &[[10, 0, 0, 255], [20, 0, 0, 255], [30, 0, 0, 255], [40, 0, 0, 255], [50, 0, 0, 255]], + ); + let mut session = ScrollSession::new(base.clone(), 320).unwrap(); + let outcome = session.observe_downward_sample(moved).unwrap(); + + assert!(matches!( + outcome, + ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } + | ScrollObserveOutcome::PreviewUpdated + | ScrollObserveOutcome::NoChange + )); + assert_eq!(session.export_image(), &base); +} + +#[test] +fn pure_upward_sequence_never_commits_growth() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + [80, 0, 0, 255], + [90, 0, 0, 255], + [100, 0, 0, 255], + ]; + let mut session = ScrollSession::new(make_window(&document, 3, 5, 5), 320).unwrap(); + let initial_height = session.export_image().height(); + + for start_row in (0..5).rev() { + assert!(matches!( + session.observe_downward_sample(make_window(&document, 3, start_row, 5)).unwrap(), + ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } + | ScrollObserveOutcome::PreviewUpdated + | ScrollObserveOutcome::NoChange + )); + assert_eq!(session.export_image().height(), initial_height); + } +} + +#[test] +fn low_information_motion_does_not_commit_growth() { + let base = make_test_image( + 3, + &[[10, 0, 0, 255], [10, 0, 0, 255], [11, 0, 0, 255], [11, 0, 0, 255], [12, 0, 0, 255]], + ); + let moved = make_test_image( + 3, + &[[10, 0, 0, 255], [11, 0, 0, 255], [11, 0, 0, 255], [12, 0, 0, 255], [12, 0, 0, 255]], + ); + let mut session = ScrollSession::new(base.clone(), 320).unwrap(); + let outcome = session.observe_downward_sample(moved).unwrap(); + + assert!(matches!( + outcome, + ScrollObserveOutcome::PreviewUpdated + | ScrollObserveOutcome::NoChange + | ScrollObserveOutcome::UnsupportedDirection { .. } + )); + assert_eq!(session.export_image(), &base); +} + +#[test] +fn session_commits_growth_with_sparse_informative_columns() { + let base = make_sparse_textlike_window(256, 120, 0); + let moved = make_sparse_textlike_window(256, 120, 9); + let mut session = ScrollSession::new(base, 320).unwrap(); + let outcome = session.observe_downward_sample(moved).unwrap(); + + assert_eq!( + outcome, + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 9 } + ); + assert_eq!(session.export_image().height(), 129); +} + +#[test] +fn session_commits_growth_with_sparse_columns_and_moving_edge_scrollbar() { + let base = make_sparse_textlike_window_with_moving_edge_scrollbar(256, 120, 0, 8); + let moved = make_sparse_textlike_window_with_moving_edge_scrollbar(256, 120, 9, 40); + let mut session = ScrollSession::new(base, 320).unwrap(); + let outcome = session.observe_downward_sample(moved).unwrap(); + + assert_eq!( + outcome, + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 9 } + ); + assert_eq!(session.export_image().height(), 129); +} + +#[test] +fn repeated_periodic_content_fails_closed_when_downward_registration_is_ambiguous() { + let document: Vec<[u8; 4]> = (0..256) + .map(|row| { + let bucket = (row % 32) as u8; + + [ + bucket.saturating_mul(7), + 255_u8.saturating_sub(bucket.saturating_mul(3)), + bucket.saturating_mul(5), + 255, + ] + }) + .collect(); + let base = make_window(&document, 8, 0, 96); + let moved = make_window(&document, 8, 24, 96); + let mut session = ScrollSession::new(base.clone(), 320).unwrap(); + + assert!(matches!( + session.observe_downward_sample(moved).unwrap(), + ScrollObserveOutcome::PreviewUpdated | ScrollObserveOutcome::NoChange + )); + assert_eq!(session.export_image(), &base); + assert_eq!(session.current_viewport_top_y, 0); +} + +#[test] +fn sparse_textlike_small_downward_steps_eventually_append() { + let base = make_sparse_textlike_window(256, 120, 0); + let mut session = ScrollSession::new(base, 320).unwrap(); + let initial_height = session.export_image().height(); + let mut committed = 0_u32; + + for start_row in 1..=8 { + if matches!( + session + .observe_downward_sample(make_sparse_textlike_window(256, 120, start_row)) + .unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, .. } + ) { + committed = committed.saturating_add(1); + } + } + + assert!(committed > 0); + assert!(session.export_image().height() > initial_height); +} + +#[test] +fn observed_sample_requires_meaningful_overlap_before_committing_large_motion() { + let document = (0_u16..320) + .map(|row| { + [((row * 17) % 251) as u8, ((row * 47) % 251) as u8, ((row * 89) % 251) as u8, 255] + }) + .collect::>(); + let base = make_window(&document, 3, 0, 160); + let mut session = ScrollSession::new(base.clone(), 320).unwrap(); + + session.last_motion_rows_hint = Some(128); + + let far = make_window(&document, 3, 130, 160); + let export_before = session.export_image().clone(); + let preview_before = session.preview_image().clone(); + + assert!(matches!( + session.observe_downward_sample(far).unwrap(), + ScrollObserveOutcome::PreviewUpdated | ScrollObserveOutcome::NoChange + )); + assert_eq!(session.export_image(), &export_before); + assert_eq!(session.preview_image(), &preview_before); + assert_eq!(session.current_viewport_top_y, 0); +} + +#[test] +fn periodic_far_downward_frame_does_not_use_full_range_fallback_after_local_miss() { + let document = (0_u16..128) + .map(|row| { + let phase = (row % 40) as u8; + + [phase.saturating_mul(5), phase.saturating_mul(7), phase.saturating_mul(11), 255] + }) + .collect::>(); + let mut session = ScrollSession::new(make_window(&document, 3, 0, 48), 320).unwrap(); + + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 9, 48)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 9 } + ); + + let far = make_window(&document, 3, 40, 48); + let match_eval = session.diagnose_reference_overlap_direction( + &session.last_sample_frame, + &far, + ScrollDirection::Down, + session.last_motion_rows_hint, + ); + + assert_eq!(session.last_motion_rows_hint, Some(9)); + assert!(match_eval.preferred_only_match.is_none()); + assert!(match_eval.final_match.is_none()); + assert!(!match_eval.used_full_range_fallback); + + let export_before = session.export_image().clone(); + let preview_before = session.preview_image().clone(); + let outcome = session.observe_downward_sample(far).unwrap(); + + assert!(matches!( + outcome, + ScrollObserveOutcome::PreviewUpdated + | ScrollObserveOutcome::NoChange + | ScrollObserveOutcome::UnsupportedDirection { .. } + )); + assert_eq!(session.export_image(), &export_before); + assert_eq!(session.preview_image(), &preview_before); +} + +#[test] +fn committed_growth_rewrites_motion_hint_to_actual_growth_rows() { + let document = (0_u16..160) + .map(|row| { + [((row * 17) % 251) as u8, ((row * 47) % 251) as u8, ((row * 89) % 251) as u8, 255] + }) + .collect::>(); + let mut session = ScrollSession::new(make_window(&document, 3, 0, 48), 320).unwrap(); + + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 20, 48)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 20 } + ); + assert_eq!(session.last_motion_rows_hint, Some(20)); + assert_eq!( + session + .observe_downward_growth_to_viewport( + make_window(&document, 3, 24, 48), + 24, + true, + Some(MotionObservation { direction: ScrollDirection::Down, motion_rows: 64 }), + "test_residual_growth_rewrites_hint", + ) + .unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 4 } + ); + assert_eq!(session.last_motion_rows_hint, Some(4)); +} + +#[test] +fn hinted_downward_registration_does_not_escape_to_far_full_range_match() { + let document = (0_u16..320) + .map(|row| { + [((row * 17) % 251) as u8, ((row * 47) % 251) as u8, ((row * 89) % 251) as u8, 255] + }) + .collect::>(); + let previous = make_window(&document, 3, 0, 160); + let next = make_window(&document, 3, 100, 160); + let session = ScrollSession::new(previous.clone(), 320).unwrap(); + + assert!(matches!( + session.evaluate_reference_downward_registration(&previous, &next, None, true), + DownwardRegistration::Matched(DirectionMatch { motion_rows: 100, .. }) + )); + assert_eq!( + session.evaluate_reference_downward_registration(&previous, &next, Some(20), true), + DownwardRegistration::NoMatch + ); +} + +#[test] +fn active_preview_helpers_stay_committed_even_with_provisional_like_session_state() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + [80, 0, 0, 255], + ]; + let base = make_window(&document, 3, 0, 5); + let latest = make_window(&document, 3, 1, 5); + let mut session = ScrollSession::new(base.clone(), 320).unwrap(); + + session.last_sample_frame = latest.clone(); + session.observed_viewport_top_y = 1; + + assert_eq!(session.preview_display_mode(), "committed"); + assert_eq!(session.preview_display_image(), session.export_image().clone()); +} + +#[test] +fn upward_motion_does_not_reset_downward_progress() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + [80, 0, 0, 255], + ]; + let mut session = ScrollSession::new(make_window(&document, 3, 0, 5), 320).unwrap(); + + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); + assert!(matches!( + session.observe_downward_sample(make_window(&document, 3, 0, 5)).unwrap(), + ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } + | ScrollObserveOutcome::PreviewUpdated + | ScrollObserveOutcome::NoChange + )); + + let resume_outcome = session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(); + + assert!(matches!( + resume_outcome, + ScrollObserveOutcome::NoChange + | ScrollObserveOutcome::PreviewUpdated + | ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } + )); + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 2, 5)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 3, 5)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); + assert_eq!(session.export_image().height(), 8); + assert_eq!(session.export_image().get_pixel(0, 0), &Rgba([10, 0, 0, 255])); + assert_eq!(session.export_image().get_pixel(0, 7), &Rgba([80, 0, 0, 255])); +} + +#[test] +fn upward_input_never_commits_lower_frame_and_does_not_advance_frontier() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + ]; + let mut session = ScrollSession::new(make_window(&document, 3, 0, 5), 320).unwrap(); + + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); + + let height_after_first_append = session.export_image().height(); + + assert!(matches!( + session.observe_upward_sample(make_window(&document, 3, 2, 5)).unwrap(), + ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } + | ScrollObserveOutcome::PreviewUpdated + | ScrollObserveOutcome::NoChange + )); + assert_eq!(session.export_image().height(), height_after_first_append); + assert!(matches!( + session.observe_upward_sample(make_window(&document, 3, 2, 5)).unwrap(), + ScrollObserveOutcome::PreviewUpdated | ScrollObserveOutcome::NoChange + )); + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 2, 5)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); +} + +#[test] +fn upward_rewind_blocks_partial_downward_recovery_until_baseline() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + [80, 0, 0, 255], + ]; + let mut session = ScrollSession::new(make_window(&document, 3, 0, 5), 320).unwrap(); + + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 2, 5)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); + assert!(matches!( + session.observe_downward_sample(make_window(&document, 3, 0, 5)).unwrap(), + ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } + | ScrollObserveOutcome::PreviewUpdated + | ScrollObserveOutcome::NoChange + )); + + let height_after_upward_rewind = session.export_image().height(); + + assert!(matches!( + session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), + ScrollObserveOutcome::NoChange + | ScrollObserveOutcome::PreviewUpdated + | ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } + )); + assert_eq!(session.export_image().height(), height_after_upward_rewind); + + let partial_resume_outcome = + session.observe_downward_sample(make_window(&document, 3, 2, 5)).unwrap(); + + assert!(matches!( + partial_resume_outcome, + ScrollObserveOutcome::NoChange + | ScrollObserveOutcome::PreviewUpdated + | ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } + )); + assert_eq!(session.export_image().height(), height_after_upward_rewind); + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 3, 5)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); +} + +#[test] +fn returning_below_last_committed_viewport_does_not_duplicate_growth() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + [80, 0, 0, 255], + ]; + let mut session = ScrollSession::new(make_window(&document, 3, 0, 5), 320).unwrap(); + + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 2, 5)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); + + let height_before_resume = session.export_image().height(); + + assert!(matches!( + session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), + ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } + | ScrollObserveOutcome::PreviewUpdated + | ScrollObserveOutcome::NoChange + )); + + let return_outcome = session.observe_downward_sample(make_window(&document, 3, 2, 5)).unwrap(); + + assert!(matches!( + return_outcome, + ScrollObserveOutcome::NoChange + | ScrollObserveOutcome::PreviewUpdated + | ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } + )); + assert_eq!(session.export_image().height(), height_before_resume); + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 3, 5)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); + assert_eq!(session.export_image().height(), 8); + assert_eq!(session.export_image().get_pixel(0, 0), &Rgba([10, 0, 0, 255])); + assert_eq!(session.export_image().get_pixel(0, 7), &Rgba([80, 0, 0, 255])); +} + +#[test] +fn downward_input_upward_like_frame_does_not_arm_resume_frontier_or_poison_sample() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + [70, 0, 0, 255], + [80, 0, 0, 255], + ]; + let mut session = ScrollSession::new(make_window(&document, 3, 0, 5), 320).unwrap(); + + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 2, 5)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); + + let sample_before = session.last_sample_frame.clone(); + let sample_fingerprint_before = session.last_sample_fingerprint.clone(); + let height_before = session.export_image().height(); + + assert!(matches!( + session.observe_downward_sample(make_window(&document, 3, 1, 5)).unwrap(), + ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } + | ScrollObserveOutcome::PreviewUpdated + | ScrollObserveOutcome::NoChange + )); + assert_eq!(session.export_image().height(), height_before); + assert_eq!(session.current_viewport_top_y, 2); + assert_eq!(session.observed_viewport_top_y, 2); + assert_eq!(session.resume_frontier_top_y, None); + assert!(!session.resume_frontier_requires_reacquire); + assert_eq!(session.last_sample_frame, sample_before); + assert_eq!(session.last_sample_fingerprint, sample_fingerprint_before); +} + +#[test] +fn viewport_selection_fails_closed_when_observed_and_committed_authority_conflict() { + let observed = DownwardViewportCandidate { + source: DownwardViewportCandidateSource::ObservedSample, + viewport_top_y: 120, + motion_rows: 20, + mean_abs_diff_x100: 100, + }; + let committed = DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 360, + motion_rows: 260, + mean_abs_diff_x100: 90, + }; + let mut candidates = [observed, committed]; + + assert_eq!( + scroll_capture::select_downward_viewport_candidate(&mut candidates), + DownwardViewportResolution::Ambiguous { preferred: committed, competing: observed } + ); +} + +#[test] +fn committed_keyframe_candidate_requires_meaningful_overlap() { + let document = (0_u16..96) + .map(|row| { + [((row * 17) % 251) as u8, ((row * 47) % 251) as u8, ((row * 89) % 251) as u8, 255] + }) + .collect::>(); + let session = ScrollSession::new(make_window(&document, 3, 0, 48), 320).unwrap(); + let mut candidates = Vec::new(); + + session.push_downward_viewport_candidate( + &session.anchor_frame, + 0, + &make_window(&document, 3, 40, 48), + DownwardViewportCandidateSource::CommittedKeyframe, + &mut candidates, + ); + + assert!(candidates.is_empty()); +} + +#[test] +fn committed_fallback_can_recover_from_an_older_recent_keyframe() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + assert_eq!( + session.observe_downward_sample(make_sparse_textlike_window(256, 120, 18)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 18 } + ); + assert_eq!( + session.observe_downward_sample(make_sparse_textlike_window(256, 120, 29)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 11 } + ); + + session.last_committed_frame = + image::RgbaImage::from_pixel(256, 120, Rgba([255, 255, 255, 255])); + + let target = make_sparse_textlike_window(256, 120, 39); + let mut candidates = Vec::new(); + + session.collect_committed_downward_viewport_candidates(&target, &mut candidates); + + assert!(candidates.iter().any(|candidate| { + candidate.source == DownwardViewportCandidateSource::CommittedKeyframe + && candidate.viewport_top_y == 39 + })); +} + +#[test] +fn fallback_committed_candidates_ignore_older_recent_keyframes() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + assert_eq!( + session.observe_downward_sample(make_sparse_textlike_window(256, 120, 18)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 18 } + ); + assert_eq!( + session.observe_downward_sample(make_sparse_textlike_window(256, 120, 29)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 11 } + ); + + session.last_committed_frame = + image::RgbaImage::from_pixel(256, 120, Rgba([255, 255, 255, 255])); + + let target = make_sparse_textlike_window(256, 120, 39); + let mut candidates = Vec::new(); + + session.collect_fallback_downward_viewport_candidates(&target, &mut candidates); + + assert!(candidates.is_empty()); +} + +#[test] +fn fallback_committed_growth_respects_local_continuity_budget() { + let document = (0_u16..220) + .map(|row| { + [((row * 17) % 251) as u8, ((row * 47) % 251) as u8, ((row * 89) % 251) as u8, 255] + }) + .collect::>(); + let mut session = ScrollSession::new(make_window(&document, 3, 0, 64), 320).unwrap(); + + assert_eq!( + session.observe_downward_sample(make_window(&document, 3, 20, 64)).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 20 } + ); + + session.last_motion_rows_hint = Some(2); + session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { + frame: make_window(&document, 3, 24, 64), + viewport_top_y: 24, + }); + + assert!(session.fallback_downward_growth_exceeds_continuity_budget(33)); + assert!(!session.fallback_downward_growth_exceeds_continuity_budget(32)); +} + +#[test] +fn nearby_local_candidate_wins_when_committed_is_only_modestly_better() { + let observed = DownwardViewportCandidate { + source: DownwardViewportCandidateSource::ObservedSample, + viewport_top_y: 132, + motion_rows: 12, + mean_abs_diff_x100: 120, + }; + let committed = DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 130, + motion_rows: 10, + mean_abs_diff_x100: 80, + }; + let mut candidates = [observed, committed]; + + assert_eq!( + scroll_capture::select_downward_viewport_candidate(&mut candidates), + DownwardViewportResolution::Selected(observed) + ); +} + +#[test] +fn burst_observed_sample_candidate_is_suppressed_when_it_far_exceeds_local_continuity_budget() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 14; + session.last_motion_rows_hint = Some(2); + session.transient_motion_rows_hint = Some(1_219); + session.transient_burst_search_enabled = true; + + assert!(session.should_suppress_observed_sample_candidate(DownwardViewportCandidate { + source: DownwardViewportCandidateSource::ObservedSample, + viewport_top_y: 419, + motion_rows: 413, + mean_abs_diff_x100: 0, + })); +} + +#[test] +fn burst_observed_sample_candidate_is_kept_when_it_stays_within_local_continuity_budget() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 14; + session.last_motion_rows_hint = Some(9); + session.transient_motion_rows_hint = Some(74); + session.transient_burst_search_enabled = true; + + assert!(!session.should_suppress_observed_sample_candidate(DownwardViewportCandidate { + source: DownwardViewportCandidateSource::ObservedSample, + viewport_top_y: 30, + motion_rows: 16, + mean_abs_diff_x100: 0, + })); +} + +#[test] +fn burst_observed_sample_candidate_near_recent_continuity_can_exceed_budget_without_suppression() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 130; + session.last_motion_rows_hint = Some(38); + session.transient_motion_rows_hint = Some(1_150); + session.transient_burst_search_enabled = true; + + assert!(!session.should_suppress_observed_sample_candidate(DownwardViewportCandidate { + source: DownwardViewportCandidateSource::ObservedSample, + viewport_top_y: 162, + motion_rows: 32, + mean_abs_diff_x100: 533, + })); +} + +#[test] +fn burst_observed_sample_candidate_near_recent_continuity_still_suppresses_when_diff_is_too_high() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 14; + session.last_motion_rows_hint = Some(9); + session.transient_motion_rows_hint = Some(1_219); + session.transient_burst_search_enabled = true; + + assert!(session.should_suppress_observed_sample_candidate(DownwardViewportCandidate { + source: DownwardViewportCandidateSource::ObservedSample, + viewport_top_y: 31, + motion_rows: 17, + mean_abs_diff_x100: 733, + })); +} + +#[test] +fn corroborated_observed_candidate_can_recover_after_initial_continuity_suppression() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 149; + session.last_motion_rows_hint = Some(16); + session.transient_motion_rows_hint = Some(12); + session.transient_burst_search_enabled = true; + + let candidate = DownwardViewportCandidate { + source: DownwardViewportCandidateSource::ObservedSample, + viewport_top_y: 169, + motion_rows: 20, + mean_abs_diff_x100: 0, + }; + let mut candidates = vec![DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 169, + motion_rows: 20, + mean_abs_diff_x100: 0, + }]; + + assert!(session.should_suppress_observed_sample_candidate(candidate)); + + session.restore_corroborated_observed_candidate(Some(candidate), &mut candidates); + + assert!(candidates.contains(&candidate)); +} + +#[test] +fn tiny_observed_recovery_fails_closed_during_large_transient_burst() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 261; + session.last_motion_rows_hint = Some(24); + session.transient_motion_rows_hint = Some(86); + session.transient_burst_search_enabled = true; + + assert!(session.should_fail_closed_tiny_observed_recovery_in_burst( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::ObservedSample, + viewport_top_y: 263, + motion_rows: 2, + mean_abs_diff_x100: 0, + } + )); +} + +#[test] +fn tiny_observed_recovery_does_not_block_when_recent_continuity_is_small() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 14; + session.last_motion_rows_hint = Some(2); + session.transient_motion_rows_hint = Some(1_217); + session.transient_burst_search_enabled = true; + + assert!(!session.should_fail_closed_tiny_observed_recovery_in_burst( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::ObservedSample, + viewport_top_y: 15, + motion_rows: 1, + mean_abs_diff_x100: 0, + } + )); +} + +#[test] +fn outsized_observed_recovery_after_one_pixel_preview_local_commit_fails_closed() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 74; + session.last_motion_rows_hint = Some(1); + session.transient_motion_rows_hint = Some(277); + session.transient_burst_search_enabled = true; + + session.growth_history.push(super::GrowthCommit { + frame: make_sparse_textlike_window(256, 120, 74), + growth_rows: 1, + viewport_top_y: 74, + decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source(), + detected_motion_rows: Some(1), + effective_motion_rows_hint: Some(277), + }); + + assert!( + session.should_fail_closed_outsized_observed_recovery_after_one_pixel_preview_local_commit( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::ObservedSample, + viewport_top_y: 82, + motion_rows: 8, + mean_abs_diff_x100: 0, + }, + ) + ); +} + +#[test] +fn tiny_observed_burst_block_keeps_preview_local_baseline_stable() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 261; + session.observed_viewport_top_y = 261; + session.last_motion_rows_hint = Some(24); + session.transient_motion_rows_hint = Some(86); + session.transient_burst_search_enabled = true; + + session.refresh_preview_only_downward_local_sample( + &make_sparse_textlike_window(256, 120, 261), + session.stable_preview_only_downward_local_viewport_top_y(), + ); + + assert_eq!( + session + .last_preview_only_downward_local_sample + .as_ref() + .map(|sample| sample.viewport_top_y), + Some(261) + ); +} + +#[test] +fn tiny_preview_only_local_recovery_fails_closed_during_large_transient_burst() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(7); + session.transient_motion_rows_hint = Some(167); + session.transient_burst_search_enabled = true; + + assert!(session.should_fail_closed_tiny_preview_only_local_recovery_in_burst( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 303, + motion_rows: 1, + mean_abs_diff_x100: 232, + } + )); +} + +#[test] +fn tiny_preview_only_local_recovery_does_not_block_recorded_small_commit() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(2); + session.transient_motion_rows_hint = Some(1_217); + session.transient_burst_search_enabled = true; + + assert!(!session.should_fail_closed_tiny_preview_only_local_recovery_in_burst( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 15, + motion_rows: 1, + mean_abs_diff_x100: 97, + } + )); +} + +#[test] +fn small_preview_only_local_recovery_lagging_recent_continuity_fails_closed_during_burst() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(26); + session.transient_motion_rows_hint = Some(356); + session.transient_burst_search_enabled = true; + + assert!(session.should_fail_closed_tiny_preview_only_local_recovery_in_burst( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 84, + motion_rows: 6, + mean_abs_diff_x100: 0, + } + )); +} + +#[test] +fn preview_only_local_tail_after_unresolved_burst_fails_closed() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 360; + session.last_block_reason = Some("no_downward_viewport_candidate_resolved"); + session.last_motion_rows_hint = Some(9); + session.transient_motion_rows_hint = Some(1_002); + session.transient_burst_search_enabled = true; + session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { + frame: make_sparse_textlike_window(256, 120, 360), + viewport_top_y: 360, + }); + + assert!(session.should_fail_closed_preview_only_local_tail_after_unresolved_burst( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 378, + motion_rows: 18, + mean_abs_diff_x100: 0, + } + )); +} + +#[test] +fn preview_only_local_tail_after_unresolved_burst_does_not_block_without_extreme_gap() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 360; + session.last_block_reason = Some("no_downward_viewport_candidate_resolved"); + session.last_motion_rows_hint = Some(9); + session.transient_motion_rows_hint = Some(18); + session.transient_burst_search_enabled = true; + session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { + frame: make_sparse_textlike_window(256, 120, 360), + viewport_top_y: 360, + }); + + assert!(!session.should_fail_closed_preview_only_local_tail_after_unresolved_burst( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 378, + motion_rows: 18, + mean_abs_diff_x100: 0, + } + )); +} + +#[test] +fn preview_only_local_tail_after_unresolved_burst_does_not_block_after_registered_growth_matches_pending_band() + { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 184; + session.last_block_reason = Some("no_downward_viewport_candidate_resolved"); + session.last_motion_rows_hint = Some(1); + session.transient_motion_rows_hint = Some(277); + session.transient_burst_search_enabled = true; + session.pending_unresolved_burst_registered_growth_viewport_top_y = Some(461); + session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { + frame: make_sparse_textlike_window(256, 120, 184), + viewport_top_y: 184, + }); + + assert!(!session.should_fail_closed_preview_only_local_tail_after_unresolved_burst( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 186, + motion_rows: 2, + mean_abs_diff_x100: 125, + } + )); +} + +#[test] +fn exactly_corroborated_preview_local_tail_fails_closed_in_extreme_transient_burst() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(10); + session.transient_motion_rows_hint = Some(1_057); + session.transient_burst_search_enabled = true; + session.last_observed_sample_registration_result = Some("matched"); + session.last_observed_sample_registration_motion_rows = Some(20); + session.last_downward_viewport_candidates_before_prune = + Some("PreviewOnlyLocalSample@472/20:0,CommittedKeyframe@472/20:0".to_string()); + + for (viewport_top_y, growth_rows) in [(442_i32, 8_u32), (452_i32, 10_u32)] { + session.growth_history.push(super::GrowthCommit { + frame: make_sparse_textlike_window(256, 120, u32::try_from(viewport_top_y).unwrap()), + growth_rows, + viewport_top_y, + decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample + .decision_source(), + detected_motion_rows: Some(growth_rows), + effective_motion_rows_hint: Some(1_057), + }); + } + + assert!(session.should_fail_closed_exactly_corroborated_preview_local_tail_in_extreme_burst( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 472, + motion_rows: 20, + mean_abs_diff_x100: 0, + }, + )); +} + +#[test] +fn moderate_transient_preview_local_tail_is_not_blocked_by_extreme_burst_rule() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(20); + session.transient_motion_rows_hint = Some(110); + session.transient_burst_search_enabled = true; + session.last_observed_sample_registration_result = Some("matched"); + session.last_observed_sample_registration_motion_rows = Some(24); + session.last_downward_viewport_candidates_before_prune = + Some("PreviewOnlyLocalSample@261/24:329,CommittedKeyframe@512/275:460".to_string()); + + session.growth_history.push(super::GrowthCommit { + frame: make_sparse_textlike_window(256, 120, 237), + growth_rows: 20, + viewport_top_y: 237, + decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source(), + detected_motion_rows: Some(20), + effective_motion_rows_hint: Some(110), + }); + session.growth_history.push(super::GrowthCommit { + frame: make_sparse_textlike_window(256, 120, 217), + growth_rows: 18, + viewport_top_y: 217, + decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source(), + detected_motion_rows: Some(18), + effective_motion_rows_hint: Some(104), + }); + + assert!(!session.should_fail_closed_exactly_corroborated_preview_local_tail_in_extreme_burst( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 261, + motion_rows: 24, + mean_abs_diff_x100: 329, + }, + )); +} + +#[test] +fn burst_prefers_observed_sample_over_underconsumed_preview_only_local_recovery() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(38); + session.transient_motion_rows_hint = Some(1_150); + session.transient_burst_search_enabled = true; + + let primary = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 120, motion_rows: 32 }, + source: DownwardSampleMatchSource::ObservedSample, + }; + let local = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 0, motion_rows: 8 }, + source: DownwardSampleMatchSource::PreviewOnlyLocalSample, + }; + + assert!(session.should_prefer_observed_sample_over_preview_only_local_recovery(primary, local)); +} + +#[test] +fn burst_keeps_preview_only_local_recovery_when_observed_is_only_modestly_ahead() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(38); + session.transient_motion_rows_hint = Some(1_150); + session.transient_burst_search_enabled = true; + + let primary = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 120, motion_rows: 16 }, + source: DownwardSampleMatchSource::ObservedSample, + }; + let local = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 0, motion_rows: 8 }, + source: DownwardSampleMatchSource::PreviewOnlyLocalSample, + }; + + assert!( + !session.should_prefer_observed_sample_over_preview_only_local_recovery(primary, local) + ); +} + +#[test] +fn tiny_recent_continuity_burst_prefers_preview_local_over_far_ahead_observed_sample() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(2); + session.transient_motion_rows_hint = Some(225); + session.transient_burst_search_enabled = true; + + let primary = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 0, motion_rows: 12 }, + source: DownwardSampleMatchSource::ObservedSample, + }; + let local = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 405, motion_rows: 3 }, + source: DownwardSampleMatchSource::PreviewOnlyLocalSample, + }; + + assert!(session.should_prefer_preview_only_local_recovery_over_observed_sample(primary, local)); +} + +#[test] +fn tiny_recent_continuity_burst_does_not_force_preview_local_when_observed_is_still_nearby() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(2); + session.transient_motion_rows_hint = Some(225); + session.transient_burst_search_enabled = true; + + let primary = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 0, motion_rows: 6 }, + source: DownwardSampleMatchSource::ObservedSample, + }; + let local = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 405, motion_rows: 3 }, + source: DownwardSampleMatchSource::PreviewOnlyLocalSample, + }; + + assert!( + !session.should_prefer_preview_only_local_recovery_over_observed_sample(primary, local) + ); +} + +#[test] +fn tiny_recent_continuity_burst_does_not_force_one_pixel_preview_local_recovery() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(2); + session.transient_motion_rows_hint = Some(1_211); + session.transient_burst_search_enabled = true; + + let primary = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 553, motion_rows: 413 }, + source: DownwardSampleMatchSource::ObservedSample, + }; + let local = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 97, motion_rows: 1 }, + source: DownwardSampleMatchSource::PreviewOnlyLocalSample, + }; + + assert!( + !session.should_prefer_preview_only_local_recovery_over_observed_sample(primary, local) + ); +} + +#[test] +fn repeated_missing_burst_frames_can_prefer_one_pixel_preview_local_recovery() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(2); + session.transient_motion_rows_hint = Some(277); + session.transient_burst_search_enabled = true; + session.consecutive_transient_burst_missing_downward_candidate_frames = 2; + + let primary = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 0, motion_rows: 116 }, + source: DownwardSampleMatchSource::ObservedSample, + }; + let local = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 149, motion_rows: 1 }, + source: DownwardSampleMatchSource::PreviewOnlyLocalSample, + }; + + assert!(session.should_prefer_preview_only_local_recovery_over_observed_sample(primary, local)); +} + +#[test] +fn preview_local_slowdown_followup_can_prefer_one_pixel_preview_local_recovery() { + let previous = make_sparse_textlike_window(256, 120, 16); + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(4); + session.transient_motion_rows_hint = Some(29); + session.transient_burst_search_enabled = true; + session.last_preview_only_downward_local_sample = + Some(PreviewOnlyDownwardLocalSample { frame: previous.clone(), viewport_top_y: 145 }); + + session.growth_history.push(GrowthCommit { + frame: previous, + growth_rows: 4, + viewport_top_y: 145, + decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source(), + detected_motion_rows: Some(4), + effective_motion_rows_hint: Some(8), + }); + + let primary = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 0, motion_rows: 41 }, + source: DownwardSampleMatchSource::ObservedSample, + }; + let local = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 410, motion_rows: 1 }, + source: DownwardSampleMatchSource::PreviewOnlyLocalSample, + }; + + assert!(session.should_prefer_preview_only_local_recovery_over_observed_sample(primary, local)); +} + +#[test] +fn preview_local_slowdown_followup_can_prefer_near_continuity_preview_local_recovery() { + let previous = make_sparse_textlike_window(256, 120, 16); + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(10); + session.transient_motion_rows_hint = Some(1_150); + session.transient_burst_search_enabled = true; + session.last_preview_only_downward_local_sample = + Some(PreviewOnlyDownwardLocalSample { frame: previous.clone(), viewport_top_y: 416 }); + + session.growth_history.push(GrowthCommit { + frame: previous, + growth_rows: 10, + viewport_top_y: 416, + decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source(), + detected_motion_rows: Some(10), + effective_motion_rows_hint: Some(10), + }); + + let primary = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 0, motion_rows: 158 }, + source: DownwardSampleMatchSource::ObservedSample, + }; + let local = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 697, motion_rows: 12 }, + source: DownwardSampleMatchSource::PreviewOnlyLocalSample, + }; + + assert!(session.should_prefer_preview_only_local_recovery_over_observed_sample(primary, local)); +} + +#[test] +fn preview_local_slowdown_followup_without_recent_small_preview_commit_does_not_prefer_local() { + let previous = make_sparse_textlike_window(256, 120, 16); + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(4); + session.transient_motion_rows_hint = Some(29); + session.transient_burst_search_enabled = true; + session.last_preview_only_downward_local_sample = + Some(PreviewOnlyDownwardLocalSample { frame: previous.clone(), viewport_top_y: 145 }); + + session.growth_history.push(GrowthCommit { + frame: previous, + growth_rows: 12, + viewport_top_y: 145, + decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source(), + detected_motion_rows: Some(12), + effective_motion_rows_hint: Some(12), + }); + + let primary = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 0, motion_rows: 41 }, + source: DownwardSampleMatchSource::ObservedSample, + }; + let local = DownwardSampleMatch { + matched: DirectionMatch { mean_abs_diff_x100: 410, motion_rows: 1 }, + source: DownwardSampleMatchSource::PreviewOnlyLocalSample, + }; + + assert!( + !session.should_prefer_preview_only_local_recovery_over_observed_sample(primary, local) + ); +} + +#[test] +fn observed_burst_catch_up_commit_seeds_preview_local_baseline() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.transient_motion_rows_hint = Some(1_150); + session.transient_burst_search_enabled = true; + + assert!(session.should_seed_preview_only_local_after_observed_burst_commit( + "sample_motion_downward_growth_from_observed_keyframe", + 32, + Some(38), + )); +} + +#[test] +fn non_observed_or_non_catch_up_commit_does_not_seed_preview_local_baseline() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.transient_motion_rows_hint = Some(1_150); + session.transient_burst_search_enabled = true; + + assert!(!session.should_seed_preview_only_local_after_observed_burst_commit( + "sample_motion_downward_growth_from_committed_keyframe", + 32, + Some(38), + )); + assert!(!session.should_seed_preview_only_local_after_observed_burst_commit( + "sample_motion_downward_growth_from_observed_keyframe", + 38, + Some(38), + )); +} + +#[test] +fn preview_local_burst_commit_preserves_local_baseline_for_next_frame() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.transient_motion_rows_hint = Some(226); + session.transient_burst_search_enabled = true; + + assert!(session.should_preserve_preview_only_local_after_preview_only_burst_commit( + "sample_motion_downward_growth_from_preview_only_local_sample", + 18, + Some(12), + )); +} + +#[test] +fn preview_local_burst_commit_does_not_preserve_local_baseline_for_tiny_or_far_growth() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.transient_motion_rows_hint = Some(226); + session.transient_burst_search_enabled = true; + + assert!(!session.should_preserve_preview_only_local_after_preview_only_burst_commit( + "sample_motion_downward_growth_from_preview_only_local_sample", + 1, + Some(12), + )); + assert!(!session.should_preserve_preview_only_local_after_preview_only_burst_commit( + "sample_motion_downward_growth_from_preview_only_local_sample", + 36, + Some(12), + )); +} + +#[test] +fn preview_local_non_burst_small_slowdown_preserves_local_baseline_for_next_frame() { + let session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + assert!(session.should_preserve_preview_only_local_after_preview_only_burst_commit( + "sample_motion_downward_growth_from_preview_only_local_sample", + 4, + Some(8), + )); +} + +#[test] +fn preview_local_non_burst_tiny_or_growing_commit_does_not_preserve_local_baseline() { + let session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + assert!(!session.should_preserve_preview_only_local_after_preview_only_burst_commit( + "sample_motion_downward_growth_from_preview_only_local_sample", + 1, + Some(8), + )); + assert!(!session.should_preserve_preview_only_local_after_preview_only_burst_commit( + "sample_motion_downward_growth_from_preview_only_local_sample", + 10, + Some(8), + )); +} + +#[test] +fn corroborated_huge_local_jump_after_preview_local_commit_blocks_far_committed_only_recovery() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 230; + session.last_motion_rows_hint = Some(18); + session.transient_motion_rows_hint = Some(226); + session.transient_burst_search_enabled = true; + session.last_observed_sample_registration_result = Some("matched"); + session.last_observed_sample_registration_motion_rows = Some(164); + session.last_preview_only_local_registration_result = Some("matched"); + session.last_preview_only_local_registration_motion_rows = Some(164); + + session.growth_history.push(super::GrowthCommit { + frame: make_sparse_textlike_window(256, 120, 230), + growth_rows: 18, + viewport_top_y: 230, + decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source(), + detected_motion_rows: Some(18), + effective_motion_rows_hint: Some(226), + }); + + assert!( + session.should_fail_closed_far_committed_only_recovery_after_corroborated_huge_local_jump( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 394, + motion_rows: 164, + mean_abs_diff_x100: 0, + }, + 164, + ) + ); +} + +#[test] +fn materially_smaller_observed_motion_still_blocks_huge_committed_only_recovery() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 230; + session.last_motion_rows_hint = Some(18); + session.transient_motion_rows_hint = Some(282); + session.transient_burst_search_enabled = true; + session.last_observed_sample_registration_result = Some("matched"); + session.last_observed_sample_registration_motion_rows = Some(112); + session.last_preview_only_local_registration_result = Some("matched"); + session.last_preview_only_local_registration_motion_rows = Some(276); + + session.growth_history.push(super::GrowthCommit { + frame: make_sparse_textlike_window(256, 120, 230), + growth_rows: 18, + viewport_top_y: 230, + decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source(), + detected_motion_rows: Some(18), + effective_motion_rows_hint: Some(282), + }); + + assert!( + session.should_fail_closed_far_committed_only_recovery_after_corroborated_huge_local_jump( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 506, + motion_rows: 276, + mean_abs_diff_x100: 0, + }, + 276, + ) + ); +} + +#[test] +fn nearby_committed_recovery_is_not_blocked_when_local_jump_is_not_huge() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 230; + session.last_motion_rows_hint = Some(18); + session.transient_motion_rows_hint = Some(226); + session.transient_burst_search_enabled = true; + session.last_observed_sample_registration_result = Some("matched"); + session.last_observed_sample_registration_motion_rows = Some(38); + session.last_preview_only_local_registration_result = Some("matched"); + session.last_preview_only_local_registration_motion_rows = Some(38); + + session.growth_history.push(super::GrowthCommit { + frame: make_sparse_textlike_window(256, 120, 230), + growth_rows: 18, + viewport_top_y: 230, + decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source(), + detected_motion_rows: Some(18), + effective_motion_rows_hint: Some(226), + }); + + assert!( + !session.should_fail_closed_far_committed_only_recovery_after_corroborated_huge_local_jump( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 268, + motion_rows: 38, + mean_abs_diff_x100: 0, + }, + 38, + ) + ); +} + +#[test] +fn suppressed_huge_preview_local_jump_corroborated_by_observed_and_committed_fails_closed() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 230; + session.last_motion_rows_hint = Some(18); + session.transient_motion_rows_hint = Some(226); + session.transient_burst_search_enabled = true; + session.last_observed_sample_registration_result = Some("matched"); + session.last_observed_sample_registration_motion_rows = Some(164); + + session.growth_history.push(super::GrowthCommit { + frame: make_sparse_textlike_window(256, 120, 230), + growth_rows: 18, + viewport_top_y: 230, + decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source(), + detected_motion_rows: Some(18), + effective_motion_rows_hint: Some(226), + }); + + assert!( + session + .should_fail_closed_suppressed_huge_preview_local_jump_corroborated_by_observed_and_committed( + Some(DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 394, + motion_rows: 164, + mean_abs_diff_x100: 0, + }), + &[DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 394, + motion_rows: 164, + mean_abs_diff_x100: 0, + }], + ) + ); +} + +#[test] +fn suppressed_preview_local_jump_without_exact_committed_corroboration_stays_unblocked() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 230; + session.last_motion_rows_hint = Some(18); + session.transient_motion_rows_hint = Some(226); + session.transient_burst_search_enabled = true; + session.last_observed_sample_registration_result = Some("matched"); + session.last_observed_sample_registration_motion_rows = Some(164); + + session.growth_history.push(super::GrowthCommit { + frame: make_sparse_textlike_window(256, 120, 230), + growth_rows: 18, + viewport_top_y: 230, + decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source(), + detected_motion_rows: Some(18), + effective_motion_rows_hint: Some(226), + }); + + assert!( + !session + .should_fail_closed_suppressed_huge_preview_local_jump_corroborated_by_observed_and_committed( + Some(DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 394, + motion_rows: 164, + mean_abs_diff_x100: 0, + }), + &[DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 398, + motion_rows: 186, + mean_abs_diff_x100: 0, + }], + ) + ); +} + +#[test] +fn committed_followup_after_suppressed_huge_preview_local_jump_fails_closed() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.transient_burst_search_enabled = true; + session.last_preview_only_local_registration_result = Some("no_match"); + session.last_observed_sample_registration_result = Some("matched"); + session.last_observed_sample_registration_motion_rows = Some(164); + + assert!( + session.should_fail_closed_committed_followup_after_suppressed_huge_preview_local_jump( + Some(DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 394, + motion_rows: 164, + mean_abs_diff_x100: 0, + }), + &[ + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 394, + motion_rows: 164, + mean_abs_diff_x100: 0, + }, + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 398, + motion_rows: 186, + mean_abs_diff_x100: 0, + }, + ], + ) + ); +} + +#[test] +fn committed_followup_without_pending_suppressed_preview_local_jump_stays_unblocked() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.transient_burst_search_enabled = true; + session.last_preview_only_local_registration_result = Some("no_match"); + session.last_observed_sample_registration_result = Some("matched"); + session.last_observed_sample_registration_motion_rows = Some(164); + + assert!( + !session.should_fail_closed_committed_followup_after_suppressed_huge_preview_local_jump( + None, + &[DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 394, + motion_rows: 164, + mean_abs_diff_x100: 0, + }], + ) + ); +} + +#[test] +fn committed_followup_after_extreme_preview_local_tail_block_fails_closed() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.transient_burst_search_enabled = true; + + assert!(session.should_fail_closed_committed_followup_after_extreme_preview_local_tail_block( + Some(DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 472, + motion_rows: 20, + mean_abs_diff_x100: 0, + }), + &[ + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 472, + motion_rows: 20, + mean_abs_diff_x100: 0, + }, + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 472, + motion_rows: 30, + mean_abs_diff_x100: 0, + }, + ], + )); +} + +#[test] +fn committed_followup_after_extreme_preview_local_tail_block_ignores_non_exact_match() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.transient_burst_search_enabled = true; + + assert!(!session.should_fail_closed_committed_followup_after_extreme_preview_local_tail_block( + Some(DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 472, + motion_rows: 20, + mean_abs_diff_x100: 0, + }), + &[DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 472, + motion_rows: 30, + mean_abs_diff_x100: 0, + }], + )); +} + +#[test] +fn suppressed_huge_preview_local_followup_block_budget_scales_with_far_recovery_ratio() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(18); + + assert_eq!( + session.suppressed_huge_preview_only_local_followup_block_budget(Some( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 394, + motion_rows: 164, + mean_abs_diff_x100: 0, + }, + )), + 5 + ); + assert_eq!( + session.suppressed_huge_preview_only_local_followup_block_budget(Some( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 290, + motion_rows: 42, + mean_abs_diff_x100: 0, + }, + )), + 3 + ); +} + +#[test] +fn huge_suppressed_jump_window_refreshes_observed_baseline_without_advancing_viewport() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + assert!(!session.should_refresh_downward_observed_baseline_after_huge_suppressed_jump()); + + session.pending_suppressed_huge_preview_only_local_followup = Some(DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 394, + motion_rows: 164, + mean_abs_diff_x100: 0, + }); + + assert!(session.should_refresh_downward_observed_baseline_after_huge_suppressed_jump()); + + session.pending_suppressed_huge_preview_only_local_followup = None; + session.blocked_followup_after_suppressed_huge_preview_local_jump = true; + + assert!(session.should_refresh_downward_observed_baseline_after_huge_suppressed_jump()); + + session.blocked_followup_after_suppressed_huge_preview_local_jump = false; + session.blocked_far_committed_only_recovery_after_corroborated_huge_local_jump = true; + + assert!(session.should_refresh_downward_observed_baseline_after_huge_suppressed_jump()); +} + +#[test] +fn huge_far_committed_block_resets_preview_only_local_baseline() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.refresh_preview_only_downward_local_sample( + &make_sparse_textlike_window(256, 120, 32), + Some(32), + ); + + assert!(session.last_preview_only_downward_local_sample.is_some()); + assert!(!session.should_reset_preview_only_local_baseline_after_huge_far_committed_block()); + + session.blocked_far_committed_only_recovery_after_corroborated_huge_local_jump = true; + + assert!(session.should_reset_preview_only_local_baseline_after_huge_far_committed_block()); +} + +#[test] +fn seeded_preview_only_local_catch_up_candidate_can_commit_small_tail_growth() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 162; + session.seeded_preview_only_local_after_observed_burst_commit = true; + + assert!(session.seeded_preview_only_local_catch_up_candidate_can_commit( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 170, + motion_rows: 8, + mean_abs_diff_x100: 0, + } + )); +} + +#[test] +fn unseeded_preview_only_local_candidate_still_needs_normal_burst_rules() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 162; + + assert!(!session.seeded_preview_only_local_catch_up_candidate_can_commit( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::PreviewOnlyLocalSample, + viewport_top_y: 170, + motion_rows: 8, + mean_abs_diff_x100: 0, + } + )); +} + +#[test] +fn seeded_preview_only_local_recovery_range_includes_one_pixel_tail_growth() { + let previous = make_sparse_textlike_window(256, 120, 0); + let next = make_sparse_textlike_window(256, 120, 1); + let mut session = ScrollSession::new(previous.clone(), 320).unwrap(); + + session.last_motion_rows_hint = Some(4); + session.seeded_preview_only_local_after_observed_burst_commit = true; + + let range = session + .preview_only_local_recovery_motion_range(&previous, &next, OverlapSearchConfig::default()) + .unwrap(); + + assert_eq!(*range.start(), 1); + assert_eq!(*range.end(), 6); +} + +#[test] +fn unseeded_preview_only_local_recovery_range_keeps_hint_floor() { + let previous = make_sparse_textlike_window(256, 120, 0); + let next = make_sparse_textlike_window(256, 120, 1); + let mut session = ScrollSession::new(previous.clone(), 320).unwrap(); + + session.last_motion_rows_hint = Some(4); + + let range = session + .preview_only_local_recovery_motion_range(&previous, &next, OverlapSearchConfig::default()) + .unwrap(); + + assert_eq!(*range.start(), 2); + assert_eq!(*range.end(), 6); +} + +#[test] +fn preview_local_slowdown_followup_range_allows_one_pixel_tail_in_burst() { + let previous = make_sparse_textlike_window(256, 120, 16); + let next = make_sparse_textlike_window(256, 120, 17); + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(4); + session.transient_motion_rows_hint = Some(29); + session.transient_burst_search_enabled = true; + session.last_preview_only_downward_local_sample = + Some(PreviewOnlyDownwardLocalSample { frame: previous.clone(), viewport_top_y: 145 }); + + session.growth_history.push(GrowthCommit { + frame: previous.clone(), + growth_rows: 4, + viewport_top_y: 145, + decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source(), + detected_motion_rows: Some(4), + effective_motion_rows_hint: Some(8), + }); + + let range = session + .preview_only_local_recovery_motion_range(&previous, &next, OverlapSearchConfig::default()) + .unwrap(); + + assert_eq!(*range.start(), 1); + assert_eq!(*range.end(), 6); +} + +#[test] +fn preview_local_followup_without_recent_small_preview_commit_keeps_hint_floor_in_burst() { + let previous = make_sparse_textlike_window(256, 120, 16); + let next = make_sparse_textlike_window(256, 120, 17); + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(4); + session.transient_motion_rows_hint = Some(29); + session.transient_burst_search_enabled = true; + session.last_preview_only_downward_local_sample = + Some(PreviewOnlyDownwardLocalSample { frame: previous.clone(), viewport_top_y: 145 }); + + session.growth_history.push(GrowthCommit { + frame: previous.clone(), + growth_rows: 12, + viewport_top_y: 145, + decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source(), + detected_motion_rows: Some(12), + effective_motion_rows_hint: Some(12), + }); + + let range = session + .preview_only_local_recovery_motion_range(&previous, &next, OverlapSearchConfig::default()) + .unwrap(); + + assert_eq!(*range.start(), 2); + assert_eq!(*range.end(), 6); +} + +#[test] +fn tiny_committed_keyframe_recovery_fails_closed_during_large_transient_burst() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 68; + session.last_motion_rows_hint = Some(6); + session.transient_motion_rows_hint = Some(401); + session.transient_burst_search_enabled = true; + + assert!(session.should_fail_closed_tiny_committed_keyframe_recovery_in_burst( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 70, + motion_rows: 12, + mean_abs_diff_x100: 654, + } + )); +} + +#[test] +fn tiny_committed_keyframe_recovery_does_not_block_meaningful_growth_during_burst() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 68; + session.last_motion_rows_hint = Some(6); + session.transient_motion_rows_hint = Some(401); + session.transient_burst_search_enabled = true; + + assert!(!session.should_fail_closed_tiny_committed_keyframe_recovery_in_burst( + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 81, + motion_rows: 23, + mean_abs_diff_x100: 696, + } + )); +} + +#[test] +fn underconsumed_observed_recovery_fails_closed_when_nearby_committed_candidate_reaches_recent_continuity() + { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(20); + session.transient_motion_rows_hint = Some(75); + session.transient_burst_search_enabled = true; + + let candidates_before_prune = vec![ + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::ObservedSample, + viewport_top_y: 289, + motion_rows: 8, + mean_abs_diff_x100: 0, + }, + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 289, + motion_rows: 8, + mean_abs_diff_x100: 0, + }, + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 291, + motion_rows: 30, + mean_abs_diff_x100: 0, + }, + ]; + let candidates_after_prune = vec![ + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::ObservedSample, + viewport_top_y: 289, + motion_rows: 8, + mean_abs_diff_x100: 0, + }, + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 289, + motion_rows: 8, + mean_abs_diff_x100: 0, + }, + ]; + + assert!(session.should_fail_closed_underconsumed_observed_recovery_in_burst( + &candidates_before_prune, + &candidates_after_prune, + )); +} + +#[test] +fn underconsumed_observed_recovery_does_not_block_small_recorded_burst_commit() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(4); + session.transient_motion_rows_hint = Some(466); + session.transient_burst_search_enabled = true; + + let candidates_before_prune = vec![ + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::ObservedSample, + viewport_top_y: 14, + motion_rows: 2, + mean_abs_diff_x100: 6, + }, + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 14, + motion_rows: 2, + mean_abs_diff_x100: 6, + }, + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 14, + motion_rows: 6, + mean_abs_diff_x100: 16, + }, + ]; + let candidates_after_prune = candidates_before_prune[..2].to_vec(); + + assert!(!session.should_fail_closed_underconsumed_observed_recovery_in_burst( + &candidates_before_prune, + &candidates_after_prune, + )); +} + +#[test] +fn low_confidence_committed_only_recovery_without_local_anchor_fails_closed_during_burst() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 134; + session.last_motion_rows_hint = Some(43); + session.transient_motion_rows_hint = Some(1_142); + session.transient_burst_search_enabled = true; + + let candidates = vec![ + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 190, + motion_rows: 56, + mean_abs_diff_x100: 621, + }, + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 157, + motion_rows: 73, + mean_abs_diff_x100: 557, + }, + ]; + + assert!(session.should_fail_closed_far_committed_only_recovery_without_local_anchor( + candidates[1], + &candidates, + )); +} + +#[test] +fn small_continuity_preview_local_registration_blocks_larger_committed_only_recovery() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 62; + session.last_motion_rows_hint = Some(2); + session.transient_motion_rows_hint = Some(225); + session.transient_burst_search_enabled = true; + session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { + frame: make_sparse_textlike_window(256, 120, 31), + viewport_top_y: 62, + }); + session.last_preview_only_local_registration_result = Some("matched"); + session.last_preview_only_local_registration_motion_rows = Some(3); + + let candidates = vec![ + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 74, + motion_rows: 12, + mean_abs_diff_x100: 0, + }, + DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 78, + motion_rows: 14, + mean_abs_diff_x100: 0, + }, + ]; + + assert!(session.should_fail_closed_far_committed_only_recovery_without_local_anchor( + candidates[0], + &candidates, + )); +} + +#[test] +fn suppressed_large_preview_local_registration_blocks_underconsumed_committed_only_recovery() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 202; + session.last_motion_rows_hint = Some(8); + session.transient_motion_rows_hint = Some(575); + session.transient_burst_search_enabled = true; + session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { + frame: make_sparse_textlike_window(256, 120, 31), + viewport_top_y: 202, + }); + session.last_preview_only_local_registration_result = Some("matched"); + session.last_preview_only_local_registration_motion_rows = Some(272); + + let candidates = [DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 220, + motion_rows: 32, + mean_abs_diff_x100: 765, + }]; + + assert!( + session + .should_fail_closed_underconsumed_committed_only_recovery_after_suppressed_preview_local_match( + candidates[0], + session.growth_rows_for_candidate_viewport_top_y(candidates[0].viewport_top_y), + ) + ); +} + +#[test] +fn corroborated_sample_registrations_block_committed_only_recovery_without_viewport_anchor() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 237; + session.last_motion_rows_hint = Some(20); + session.transient_motion_rows_hint = Some(145); + session.transient_burst_search_enabled = true; + session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { + frame: make_sparse_textlike_window(256, 120, 237), + viewport_top_y: 237, + }); + session.last_observed_sample_registration_result = Some("matched"); + session.last_observed_sample_registration_motion_rows = Some(135); + session.last_preview_only_local_registration_result = Some("matched"); + session.last_preview_only_local_registration_motion_rows = Some(116); + + let preferred = DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 353, + motion_rows: 116, + mean_abs_diff_x100: 0, + }; + + assert!(session + .should_fail_closed_committed_only_recovery_after_corroborated_sample_registration_without_viewport_anchor( + preferred, + session.growth_rows_for_candidate_viewport_top_y(preferred.viewport_top_y), + )); +} + +#[test] +fn corroborated_sample_registrations_block_older_keyframe_recovery_by_growth_band() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 237; + session.last_motion_rows_hint = Some(20); + session.transient_motion_rows_hint = Some(249); + session.transient_burst_search_enabled = true; + session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { + frame: make_sparse_textlike_window(256, 120, 237), + viewport_top_y: 237, + }); + session.last_observed_sample_registration_result = Some("matched"); + session.last_observed_sample_registration_motion_rows = Some(258); + session.last_preview_only_local_registration_result = Some("matched"); + session.last_preview_only_local_registration_motion_rows = Some(180); + + let preferred = DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 464, + motion_rows: 271, + mean_abs_diff_x100: 700, + }; + + assert!(session + .should_fail_closed_committed_only_recovery_after_corroborated_sample_registration_without_viewport_anchor( + preferred, + session.growth_rows_for_candidate_viewport_top_y(preferred.viewport_top_y), + )); +} + +#[test] +fn observed_burst_outpacing_recent_preview_local_commit_blocks_committed_only_recovery() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 237; + session.last_motion_rows_hint = Some(20); + session.transient_motion_rows_hint = Some(145); + session.transient_burst_search_enabled = true; + session.last_observed_sample_registration_result = Some("matched"); + session.last_observed_sample_registration_motion_rows = Some(135); + session.last_preview_only_local_registration_result = Some("no_match"); + + session.growth_history.push(super::GrowthCommit { + frame: make_sparse_textlike_window(256, 120, 237), + growth_rows: 20, + viewport_top_y: 237, + decision_source: DownwardViewportCandidateSource::PreviewOnlyLocalSample.decision_source(), + detected_motion_rows: Some(20), + effective_motion_rows_hint: Some(145), + }); + + let preferred = DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 353, + motion_rows: 116, + mean_abs_diff_x100: 0, + }; + + assert!(session + .should_fail_closed_committed_only_recovery_when_observed_burst_outpaces_recent_preview_local_commit( + preferred, + session.growth_rows_for_candidate_viewport_top_y(preferred.viewport_top_y), + )); +} + +#[test] +fn suppressed_large_preview_local_registration_helper_skips_hint_band_committed_recovery() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.current_viewport_top_y = 202; + session.last_motion_rows_hint = Some(8); + session.transient_motion_rows_hint = Some(575); + session.transient_burst_search_enabled = true; + session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { + frame: make_sparse_textlike_window(256, 120, 31), + viewport_top_y: 202, + }); + session.last_preview_only_local_registration_result = Some("matched"); + session.last_preview_only_local_registration_motion_rows = Some(272); + + let candidates = [DownwardViewportCandidate { + source: DownwardViewportCandidateSource::CommittedKeyframe, + viewport_top_y: 500, + motion_rows: 310, + mean_abs_diff_x100: 0, + }]; + + assert!( + !session + .should_fail_closed_underconsumed_committed_only_recovery_after_suppressed_preview_local_match( + candidates[0], + session.growth_rows_for_candidate_viewport_top_y(candidates[0].viewport_top_y), + ) + ); +} + +#[test] +fn weak_tiny_committed_keyframe_match_retries_full_range_during_burst() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(14); + session.transient_motion_rows_hint = Some(380); + session.transient_burst_search_enabled = true; + + assert!(session.should_retry_committed_keyframe_registration_across_full_range( + DownwardRegistration::Matched(DirectionMatch { mean_abs_diff_x100: 733, motion_rows: 7 }), + )); + assert_eq!( + session.prefer_full_range_committed_keyframe_registration( + DownwardRegistration::Matched(DirectionMatch { + mean_abs_diff_x100: 733, + motion_rows: 7, + }), + DownwardRegistration::Matched(DirectionMatch { + mean_abs_diff_x100: 0, + motion_rows: 50, + }), + ), + DownwardRegistration::Matched(DirectionMatch { mean_abs_diff_x100: 0, motion_rows: 50 }), + ); +} + +#[test] +fn modest_committed_keyframe_match_does_not_retry_full_range_during_burst() { + let mut session = ScrollSession::new(make_sparse_textlike_window(256, 120, 0), 320).unwrap(); + + session.last_motion_rows_hint = Some(9); + session.transient_motion_rows_hint = Some(1_284); + session.transient_burst_search_enabled = true; + + assert!(!session.should_retry_committed_keyframe_registration_across_full_range( + DownwardRegistration::Matched(DirectionMatch { mean_abs_diff_x100: 301, motion_rows: 27 }), + )); +} + +#[test] +fn session_preview_matches_export_after_downward_growth() { + let document = [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [50, 0, 0, 255], + [60, 0, 0, 255], + ]; + let mut session = ScrollSession::new(make_window(&document, 3, 0, 4), 3).unwrap(); + let _ = session.observe_downward_sample(make_window(&document, 3, 1, 4)).unwrap(); + let _ = session.observe_downward_sample(make_window(&document, 3, 2, 4)).unwrap(); + + assert_eq!(session.preview_image().height(), session.export_image().height()); + assert_eq!(session.preview_image().get_pixel(0, 0), session.export_image().get_pixel(0, 0)); + assert_eq!( + session.preview_image().get_pixel(0, session.preview_image().height() - 1), + session.export_image().get_pixel(0, session.export_image().height() - 1) + ); +} + +#[test] +fn session_undo_restores_previous_stitched_image() { + let base = make_test_image( + 3, + &[[10, 0, 0, 255], [20, 0, 0, 255], [30, 0, 0, 255], [40, 0, 0, 255], [50, 0, 0, 255]], + ); + let moved = make_test_image( + 3, + &[[20, 0, 0, 255], [30, 0, 0, 255], [40, 0, 0, 255], [50, 0, 0, 255], [60, 0, 0, 255]], + ); + let mut session = ScrollSession::new(base.clone(), 320).unwrap(); + + assert_eq!( + session.observe_downward_sample(moved).unwrap(), + ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 } + ); + assert!(session.undo_last_append()); + assert_eq!(session.export_image(), &base); +} From 84dbe2a0d22371f961125530522987bd8d18b081 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Sat, 4 Apr 2026 13:16:50 +0800 Subject: [PATCH 2/5] {"schema":"delivery/1","type":"fix","scope":"overlay","summary":"repair non-macos modularization imports for CI","intent":"restore Linux lint compatibility after the module split checkpoint without changing overlay behavior","impact":"gates the macOS-only startup plan import, adds the missing test enum import, and narrows macOS-only test wildcard imports so warnings stay clean on non-macOS CI","breaking":false,"risk":"low","authority":"review","delivery_mode":"status-only","refs":[{"system":"linear","id":"XY-226","role":"authority"}]} --- packages/rsnap-overlay/src/overlay.rs | 4 +++- .../rsnap-overlay/src/overlay/tests/rendering_behaviors.rs | 1 + .../rsnap-overlay/src/overlay/tests/self_capture_runtime.rs | 1 + .../rsnap-overlay/src/overlay/tests/stream_refresh_runtime.rs | 1 + .../src/overlay/tests/worker_observation_runtime.rs | 1 + 5 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index 20a78843..e61503b6 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -133,9 +133,11 @@ use winit::{ window::{WindowId, WindowLevel}, }; +#[cfg(target_os = "macos")] +use self::rendering::StartupLiveRgbPlan; use self::rendering::{ GpuContext, HudOverlayWindow, HudPillGeometry, HudRedrawSummary, OverlayWindow, - ScrollPreviewView, ScrollPreviewWindow, StartupLiveRgbPlan, WindowRenderer, + ScrollPreviewView, ScrollPreviewWindow, WindowRenderer, }; #[cfg(test)] use self::rendering::{ diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs index 33923695..a73dad6b 100644 --- a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs @@ -1,6 +1,7 @@ #![allow(clippy::wildcard_imports)] use super::*; +use crate::OverlayControl; #[test] fn pending_freeze_capture_dispatches_even_with_seeded_preview() { diff --git a/packages/rsnap-overlay/src/overlay/tests/self_capture_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/self_capture_runtime.rs index 8192618a..1f26767a 100644 --- a/packages/rsnap-overlay/src/overlay/tests/self_capture_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/tests/self_capture_runtime.rs @@ -1,5 +1,6 @@ #![allow(clippy::wildcard_imports)] +#[cfg(target_os = "macos")] use super::*; #[cfg(target_os = "macos")] diff --git a/packages/rsnap-overlay/src/overlay/tests/stream_refresh_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/stream_refresh_runtime.rs index 5719fe66..62c7f153 100644 --- a/packages/rsnap-overlay/src/overlay/tests/stream_refresh_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/tests/stream_refresh_runtime.rs @@ -1,5 +1,6 @@ #![allow(clippy::wildcard_imports)] +#[cfg(target_os = "macos")] use super::*; #[cfg(target_os = "macos")] diff --git a/packages/rsnap-overlay/src/overlay/tests/worker_observation_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/worker_observation_runtime.rs index f0f954a7..6397c524 100644 --- a/packages/rsnap-overlay/src/overlay/tests/worker_observation_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/tests/worker_observation_runtime.rs @@ -1,5 +1,6 @@ #![allow(clippy::wildcard_imports)] +#[cfg(target_os = "macos")] use super::*; #[cfg(target_os = "macos")] From 01a9d5dfeaf1f8477f11e2b6206e64a64a137c05 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Sat, 4 Apr 2026 13:54:33 +0800 Subject: [PATCH 3/5] {"schema":"delivery/1","type":"fix","scope":"rsnap-overlay","summary":"repair modularization imports for lint and tests","intent":"restore CI after vstyle auto-fixes rewrote module imports too aggressively during the overlay modularization lane","impact":"rebuilds explicit module imports, re-qualifies helper function usage, and reconnects test support helpers so lint and library tests pass again without changing overlay behavior","breaking":false,"risk":"low","authority":"review","delivery_mode":"status-only","refs":[{"system":"linear","id":"XY-226","role":"authority"}]} --- packages/rsnap-overlay/src/overlay.rs | 13 +- .../src/overlay/aux_window_runtime.rs | 11 +- .../src/overlay/capture_window_runtime.rs | 5 +- .../src/overlay/config_runtime.rs | 21 +- .../src/overlay/cursor_context_runtime.rs | 14 +- .../src/overlay/cursor_runtime.rs | 8 +- .../rsnap-overlay/src/overlay/hud_runtime.rs | 10 +- .../rsnap-overlay/src/overlay/rendering.rs | 340 ++++++++------- .../src/overlay/rendering/affordances.rs | 57 ++- .../src/overlay/rendering/hud_rendering.rs | 22 +- .../src/overlay/rendering/hud_surface.rs | 33 +- .../rendering/scroll_preview_window.rs | 35 +- .../src/overlay/scroll_preview_runtime.rs | 11 +- packages/rsnap-overlay/src/overlay/tests.rs | 97 +++-- .../src/overlay/tests/live_runtime.rs | 33 +- .../src/overlay/tests/rendering_behaviors.rs | 139 +++--- .../src/overlay/tests/scroll_input_runtime.rs | 75 ++-- .../src/overlay/tests/self_capture_runtime.rs | 48 ++- .../overlay/tests/stream_refresh_runtime.rs | 26 +- .../tests/worker_observation_runtime.rs | 66 +-- .../src/overlay/tests/worker_tick_runtime.rs | 171 ++++---- .../src/overlay/toolbar_runtime.rs | 14 +- .../src/overlay/window_position_runtime.rs | 9 +- .../src/overlay/worker_runtime.rs | 13 +- packages/rsnap-overlay/src/scroll_capture.rs | 115 +++-- .../src/scroll_capture/bench_support.rs | 13 +- .../src/scroll_capture/downward_resolution.rs | 107 +++-- .../src/scroll_capture/support.rs | 400 +++++++++--------- .../rsnap-overlay/src/scroll_capture/tests.rs | 76 ++-- 29 files changed, 1111 insertions(+), 871 deletions(-) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index e61503b6..1bedb4ff 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -76,7 +76,6 @@ use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use serde::{Deserialize, Serialize}; use wgpu::Adapter; use wgpu::AddressMode; -use wgpu::BindGroup; use wgpu::BindGroupLayout; use wgpu::BindingResource; use wgpu::BindingType; @@ -125,6 +124,8 @@ use wgpu::Trace; use wgpu::{self}; use winit::dpi::{LogicalPosition, LogicalSize, PhysicalPosition}; use winit::event::KeyEvent; +use winit::event::Modifiers; +use winit::window::Window; use winit::{ dpi::PhysicalSize, event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}, @@ -162,8 +163,6 @@ use self::trace_recording::{ ScrollCaptureTraceFrameRecord, ScrollCaptureTraceRecorder, ScrollCaptureTraceSessionSnapshot, }; #[cfg(target_os = "macos")] -use crate::backend; -#[cfg(target_os = "macos")] use crate::live_frame_stream_macos::{CursorSampleRequest, MacLiveFrameStream}; use crate::scroll_capture::{self, ScrollDirection, ScrollObserveOutcome, ScrollSession}; use crate::state::LiveCursorSample; @@ -2384,7 +2383,7 @@ impl OverlaySession { self.toolbar_state.drag_anchor = None; } - fn handle_modifiers_changed(&mut self, modifiers: &winit::event::Modifiers) -> OverlayControl { + fn handle_modifiers_changed(&mut self, modifiers: &Modifiers) -> OverlayControl { self.keyboard_modifiers = modifiers.state(); OverlayControl::Continue @@ -5211,7 +5210,7 @@ fn macos_activate_app() { } #[cfg(target_os = "macos")] -fn macos_make_window_key(window: &winit::window::Window) { +fn macos_make_window_key(window: &Window) { let Ok(handle) = window.window_handle() else { return; }; @@ -5279,7 +5278,7 @@ fn macos_post_scroll_wheel_event( } #[cfg(target_os = "macos")] -fn macos_configure_overlay_window_mouse_moved_events(window: &winit::window::Window) { +fn macos_configure_overlay_window_mouse_moved_events(window: &Window) { let Ok(handle) = window.window_handle() else { return; }; @@ -5308,7 +5307,7 @@ fn macos_configure_overlay_window_mouse_moved_events(window: &winit::window::Win #[cfg(target_os = "macos")] fn macos_configure_hud_window( - window: &winit::window::Window, + window: &Window, blur_enabled: bool, blur_amount: f32, corner_radius_points: Option, diff --git a/packages/rsnap-overlay/src/overlay/aux_window_runtime.rs b/packages/rsnap-overlay/src/overlay/aux_window_runtime.rs index 421ca7de..6eb710ed 100644 --- a/packages/rsnap-overlay/src/overlay/aux_window_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/aux_window_runtime.rs @@ -1,6 +1,11 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; +#[allow(unused_imports)] +use crate::overlay::{ + Duration, Event, GlobalPoint, HUD_LOUPE_MOVE_INTERVAL_MIN, INTERACTIVE_REPAINT_FPS_CAP, + Instant, LOUPE_WINDOW_WARMUP_REDRAWS, LogicalPosition, MonitorRect, MonitorRectPoints, + OVERLAY_EVENT_LOOP_STALL_THRESHOLD, Ordering, OverlayControl, OverlayEventLoopPhase, + OverlayMode, OverlaySession, SLOW_OP_WARN_INTERVAL, SLOW_OP_WARN_OUTER_POSITION, WindowEvent, + scroll_capture, +}; impl OverlaySession { pub(super) fn live_loupe_uses_hud_window(&self) -> bool { diff --git a/packages/rsnap-overlay/src/overlay/capture_window_runtime.rs b/packages/rsnap-overlay/src/overlay/capture_window_runtime.rs index ca7030c5..3fca2f60 100644 --- a/packages/rsnap-overlay/src/overlay/capture_window_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/capture_window_runtime.rs @@ -1,6 +1,5 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; +#[allow(unused_imports)] +use crate::overlay::{GlobalPoint, MonitorRect, OverlayMode, OverlaySession, image_helpers}; impl OverlaySession { pub(super) fn update_cursor_state(&mut self, monitor: MonitorRect, cursor: GlobalPoint) { diff --git a/packages/rsnap-overlay/src/overlay/config_runtime.rs b/packages/rsnap-overlay/src/overlay/config_runtime.rs index 11469bcd..ba0c5f5b 100644 --- a/packages/rsnap-overlay/src/overlay/config_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/config_runtime.rs @@ -1,6 +1,15 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; +use winit::window::Window; + +#[cfg(target_os = "macos")] +use crate::backend; +#[allow(unused_imports)] +use crate::overlay::{ + self, Arc, HUD_PILL_CORNER_RADIUS_POINTS, Instant, LOUPE_TILE_CORNER_RADIUS_POINTS, + OverlayConfig, OverlayMode, OverlaySession, OverlayWorker, scroll_capture, +}; +#[cfg(target_os = "macos")] +#[allow(unused_imports)] +use crate::overlay::{MacLiveFrameStream, MacOSHudWindowConfigState, SLOW_OP_WARN_HUD_CONFIG}; impl OverlaySession { /// Applies updated runtime configuration to an existing session. @@ -167,7 +176,7 @@ impl OverlaySession { pub(super) fn configure_hud_window_common( &mut self, - window: &winit::window::Window, + window: &Window, corner_radius: Option, ) { window.set_transparent(true); @@ -189,7 +198,7 @@ impl OverlaySession { #[cfg(target_os = "macos")] fn configure_macos_hud_window_cached( &mut self, - window: &winit::window::Window, + window: &Window, blur_enabled: bool, blur_amount: f32, corner_radius: Option, @@ -213,7 +222,7 @@ impl OverlaySession { let started_at = Instant::now(); - macos_configure_hud_window( + overlay::macos_configure_hud_window( window, blur_enabled, blur_amount, diff --git a/packages/rsnap-overlay/src/overlay/cursor_context_runtime.rs b/packages/rsnap-overlay/src/overlay/cursor_context_runtime.rs index fa5c65eb..7c0faae3 100644 --- a/packages/rsnap-overlay/src/overlay/cursor_context_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/cursor_context_runtime.rs @@ -1,6 +1,14 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; +#[cfg(target_os = "macos")] +#[allow(unused_imports)] +use crate::overlay::{ + CursorSampleRequest, STARTUP_LIVE_SAMPLE_WAIT_POLL_INTERVAL, STARTUP_LIVE_SAMPLE_WAIT_TIMEOUT, + StartupLiveRgbPlan, thread, +}; +#[allow(unused_imports)] +use crate::overlay::{ + DeviceCursorPointSource, FreezeCaptureTarget, GlobalPoint, Instant, + LIVE_EVENT_CURSOR_CACHE_TTL, MonitorRect, OverlayMode, OverlaySession, +}; impl OverlaySession { pub(super) fn initialize_cursor_state_for_cursor( diff --git a/packages/rsnap-overlay/src/overlay/cursor_runtime.rs b/packages/rsnap-overlay/src/overlay/cursor_runtime.rs index e16acd36..4b5030ff 100644 --- a/packages/rsnap-overlay/src/overlay/cursor_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/cursor_runtime.rs @@ -1,6 +1,8 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; +#[allow(unused_imports)] +use crate::overlay::{ + CURSOR_POLL_INTERVAL_MIN, DeviceCursorPointSource, Duration, Instant, + LIVE_HOVER_HIT_TEST_INTERVAL, OverlayMode, OverlaySession, +}; impl OverlaySession { pub(super) fn maybe_tick_frozen_cursor_tracking(&mut self) { diff --git a/packages/rsnap-overlay/src/overlay/hud_runtime.rs b/packages/rsnap-overlay/src/overlay/hud_runtime.rs index 3bf646d9..5a99df69 100644 --- a/packages/rsnap-overlay/src/overlay/hud_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/hud_runtime.rs @@ -1,6 +1,10 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; +#[allow(unused_imports)] +use crate::overlay::{ + Duration, FrozenCaptureSource, GlobalPoint, HudAnchor, HudPillGeometry, HudRedrawSummary, + Instant, LIVE_PRESENT_INTERVAL_MIN, LogicalSize, MonitorRect, OverlayControl, + OverlayEventLoopPhase, OverlayExit, OverlayMode, OverlaySession, Pos2, Rect, Result, eyre, + scroll_capture, +}; impl OverlaySession { pub(super) fn stabilized_live_hud_inner_size( diff --git a/packages/rsnap-overlay/src/overlay/rendering.rs b/packages/rsnap-overlay/src/overlay/rendering.rs index 0727a9aa..1e68bcd6 100644 --- a/packages/rsnap-overlay/src/overlay/rendering.rs +++ b/packages/rsnap-overlay/src/overlay/rendering.rs @@ -1,16 +1,32 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; - mod affordances; mod hud_rendering; mod hud_surface; mod scroll_preview_window; +pub(super) use self::{hud_surface::HudPillGeometry, scroll_preview_window::ScrollPreviewWindow}; + +use egui::Modifiers; +use winit::window::Window; + use self::hud_rendering::LiveLoupeTexture; use self::hud_surface::{HudBg, HudBlurUniformRaw}; -pub(super) use hud_surface::HudPillGeometry; -pub(super) use scroll_preview_window::ScrollPreviewWindow; +#[allow(unused_imports)] +use crate::overlay::{ + self, AcquiredSurfaceFrame, Adapter, AddressMode, Arc, BindGroupLayout, BindingResource, + BindingType, BlendState, Buffer, BufferBindingType, BufferSize, BufferUsages, ClippedPrimitive, + Color32, ColorWrites, CompositeAlphaMode, Cow, CurrentSurfaceTexture, Device, Duration, Event, + ExperimentalFeatures, Features, FilterMode, FontDefinitions, FontFamily, FrontFace, + FrozenToolbarPointerState, FrozenToolbarState, FullOutput, HudAnchor, HudTheme, Id, Instant, + LayerId, LoadOp, MemoryHints, MipmapFilterMode, MonitorRect, MultisampleState, Mutex, Order, + OverlayMode, OverlaySession, OverlayState, PhysicalSize, PipelineCompilationOptions, + PointerButton, PolygonMode, Pos2, PowerPreference, PresentMode, PrimitiveTopology, Queue, Rect, + RectPoints, RenderPipeline, Renderer, Result, SLOW_OP_WARN_RENDER, Sampler, SamplerBindingType, + ScreenDescriptor, ShaderSource, ShaderStages, SlowOperationLogger, StoreOp, Surface, + SurfaceCapabilities, SurfaceFrameSkipReason, SurfaceTexture, Texture, TextureAspect, + TextureSampleType, TextureUsages, TextureView, TextureViewDescriptor, TextureViewDimension, + ThemeMode, ToolbarPlacement, Trace, Variant, Vec2, ViewportId, Visuals, WindowId, + WindowRendererPath, WrapErr, eyre, hud_helpers, mem, +}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) struct FrozenToolbarButtonStyle { @@ -24,30 +40,6 @@ pub(super) struct ScrollPreviewView { pub(super) theme: HudTheme, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct SelectionFlowGeometryCacheKey { - rect_min_x_bits: u32, - rect_min_y_bits: u32, - rect_max_x_bits: u32, - rect_max_y_bits: u32, - corner_radius_bits: u32, - seam_offset_bits: u32, - sample_count: usize, -} -impl SelectionFlowGeometryCacheKey { - const fn new(rect: Rect, corner_radius: f32, seam_offset: f32, sample_count: usize) -> Self { - Self { - rect_min_x_bits: rect.min.x.to_bits(), - rect_min_y_bits: rect.min.y.to_bits(), - rect_max_x_bits: rect.max.x.to_bits(), - rect_max_y_bits: rect.max.y.to_bits(), - corner_radius_bits: corner_radius.to_bits(), - seam_offset_bits: seam_offset.to_bits(), - sample_count, - } - } -} - #[derive(Debug, Default)] pub(super) struct SelectionFlowGeometryCache { key: Option, @@ -90,14 +82,6 @@ pub(super) struct SelectionDashedBorderMetrics { pub(super) gap_length: f32, } -#[derive(Clone, Copy, Debug, PartialEq)] -struct SelectionSizeBadgePadding { - left: f32, - right: f32, - top: f32, - bottom: f32, -} - #[derive(Clone, Copy, Debug, PartialEq)] pub(super) struct SelectionSizeBadgeLayout { pub(super) text_size: Vec2, @@ -112,7 +96,7 @@ pub(super) struct SelectionSizeBadgeTarget { } pub(super) struct HudOverlayWindow { - pub(super) window: Arc, + pub(super) window: Arc, pub(super) renderer: WindowRenderer, } @@ -134,123 +118,9 @@ pub(super) struct StartupLiveRgbPlan { pub(super) seed_monitor: Option, } -#[derive(Debug, Default)] -struct WindowRendererPhaseTimings { - prepare_input: Duration, - sync_hud_bg: Duration, - run_egui: Duration, - update_hud_blur_uniform: Duration, - sync_egui_textures: Duration, - tessellate: Duration, - acquire_frame: Duration, - render_frame: Duration, - total: Duration, -} -impl WindowRendererPhaseTimings { - fn trace( - &self, - path: WindowRendererPath, - window_id: WindowId, - monitor_id: u32, - mode: OverlayMode, - toolbar_active: bool, - paint_jobs: usize, - ) { - tracing::trace!( - op = "overlay.window_renderer_phase_timing", - path = path.as_str(), - window_id = ?window_id, - monitor_id, - mode = ?mode, - toolbar_active, - paint_jobs, - total_us = self.total.as_micros(), - prepare_input_us = self.prepare_input.as_micros(), - sync_hud_bg_us = self.sync_hud_bg.as_micros(), - run_egui_us = self.run_egui.as_micros(), - update_hud_blur_uniform_us = self.update_hud_blur_uniform.as_micros(), - sync_egui_textures_us = self.sync_egui_textures.as_micros(), - tessellate_us = self.tessellate.as_micros(), - acquire_frame_us = self.acquire_frame.as_micros(), - render_frame_us = self.render_frame.as_micros(), - "Overlay window renderer phase timing." - ); - } - - fn warn_if_substeps_slow( - &self, - slow_op_logger: &mut SlowOperationLogger, - path: WindowRendererPath, - window_id: WindowId, - monitor_id: u32, - mode: OverlayMode, - paint_jobs: usize, - ) { - let context = || { - format!( - "path={} window_id={window_id:?} monitor_id={monitor_id} mode={mode:?} paint_jobs={paint_jobs}", - path.as_str() - ) - }; - - self.warn_phase_if_slow( - slow_op_logger, - "overlay.window_renderer.prepare_input", - self.prepare_input, - &context, - ); - self.warn_phase_if_slow( - slow_op_logger, - "overlay.window_renderer.sync_hud_bg", - self.sync_hud_bg, - &context, - ); - self.warn_phase_if_slow( - slow_op_logger, - "overlay.window_renderer.run_egui", - self.run_egui, - &context, - ); - self.warn_phase_if_slow( - slow_op_logger, - "overlay.window_renderer.update_hud_blur_uniform", - self.update_hud_blur_uniform, - &context, - ); - self.warn_phase_if_slow( - slow_op_logger, - "overlay.window_renderer.sync_egui_textures", - self.sync_egui_textures, - &context, - ); - self.warn_phase_if_slow( - slow_op_logger, - "overlay.window_renderer.tessellate", - self.tessellate, - &context, - ); - } - - fn warn_phase_if_slow( - &self, - slow_op_logger: &mut SlowOperationLogger, - op: &'static str, - elapsed: Duration, - describe: &F, - ) where - F: Fn() -> String, - { - if elapsed.is_zero() { - return; - } - - slow_op_logger.warn_if_redraw_substep_slow(op, elapsed, self.total, describe); - } -} - pub(super) struct OverlayWindow { pub(super) monitor: MonitorRect, - pub(super) window: Arc, + pub(super) window: Arc, pub(super) renderer: WindowRenderer, pub(super) refresh_rate_millihertz: Option, } @@ -288,7 +158,7 @@ impl GpuContext { } pub(super) struct WindowRenderer { - window: Arc, + window: Arc, surface: Surface<'static>, surface_config: wgpu::SurfaceConfiguration, needs_reconfigure: bool, @@ -553,7 +423,7 @@ impl WindowRenderer { } fn make_surface_config( - window: &winit::window::Window, + window: &Window, format: wgpu::TextureFormat, alpha_mode: CompositeAlphaMode, ) -> wgpu::SurfaceConfiguration { @@ -748,7 +618,7 @@ impl WindowRenderer { pos: pointer.cursor_local, button: PointerButton::Primary, pressed: true, - modifiers: egui::Modifiers::default(), + modifiers: Modifiers::default(), }); } if pointer.left_button_went_up { @@ -756,7 +626,7 @@ impl WindowRenderer { pos: pointer.cursor_local, button: PointerButton::Primary, pressed: false, - modifiers: egui::Modifiers::default(), + modifiers: Modifiers::default(), }); } } @@ -806,8 +676,8 @@ impl WindowRenderer { ) -> (FullOutput, Option) { let hud_data = if can_draw_hud { state.cursor.and_then(|cursor| { - let local_cursor = - hud_local_cursor_override.or_else(|| global_to_local(cursor, monitor))?; + let local_cursor = hud_local_cursor_override + .or_else(|| overlay::global_to_local(cursor, monitor))?; Some((cursor, local_cursor)) }) @@ -1111,7 +981,7 @@ impl WindowRenderer { pub(super) fn new( gpu: &GpuContext, - window: Arc, + window: Arc, egui_repaint_deadline: Arc>>, ) -> Result { let surface = gpu @@ -1351,7 +1221,7 @@ impl WindowRenderer { "Skipped overlay window frame acquisition." ); - if should_request_overlay_redraw_after_surface_skip( + if overlay::should_request_overlay_redraw_after_surface_skip( reason, Instant::now(), &mut self.occluded_redraw_retry_until, @@ -1536,3 +1406,149 @@ impl WindowRenderer { ) } } + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct SelectionFlowGeometryCacheKey { + rect_min_x_bits: u32, + rect_min_y_bits: u32, + rect_max_x_bits: u32, + rect_max_y_bits: u32, + corner_radius_bits: u32, + seam_offset_bits: u32, + sample_count: usize, +} +impl SelectionFlowGeometryCacheKey { + const fn new(rect: Rect, corner_radius: f32, seam_offset: f32, sample_count: usize) -> Self { + Self { + rect_min_x_bits: rect.min.x.to_bits(), + rect_min_y_bits: rect.min.y.to_bits(), + rect_max_x_bits: rect.max.x.to_bits(), + rect_max_y_bits: rect.max.y.to_bits(), + corner_radius_bits: corner_radius.to_bits(), + seam_offset_bits: seam_offset.to_bits(), + sample_count, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct SelectionSizeBadgePadding { + left: f32, + right: f32, + top: f32, + bottom: f32, +} + +#[derive(Debug, Default)] +struct WindowRendererPhaseTimings { + prepare_input: Duration, + sync_hud_bg: Duration, + run_egui: Duration, + update_hud_blur_uniform: Duration, + sync_egui_textures: Duration, + tessellate: Duration, + acquire_frame: Duration, + render_frame: Duration, + total: Duration, +} +impl WindowRendererPhaseTimings { + fn trace( + &self, + path: WindowRendererPath, + window_id: WindowId, + monitor_id: u32, + mode: OverlayMode, + toolbar_active: bool, + paint_jobs: usize, + ) { + tracing::trace!( + op = "overlay.window_renderer_phase_timing", + path = path.as_str(), + window_id = ?window_id, + monitor_id, + mode = ?mode, + toolbar_active, + paint_jobs, + total_us = self.total.as_micros(), + prepare_input_us = self.prepare_input.as_micros(), + sync_hud_bg_us = self.sync_hud_bg.as_micros(), + run_egui_us = self.run_egui.as_micros(), + update_hud_blur_uniform_us = self.update_hud_blur_uniform.as_micros(), + sync_egui_textures_us = self.sync_egui_textures.as_micros(), + tessellate_us = self.tessellate.as_micros(), + acquire_frame_us = self.acquire_frame.as_micros(), + render_frame_us = self.render_frame.as_micros(), + "Overlay window renderer phase timing." + ); + } + + fn warn_if_substeps_slow( + &self, + slow_op_logger: &mut SlowOperationLogger, + path: WindowRendererPath, + window_id: WindowId, + monitor_id: u32, + mode: OverlayMode, + paint_jobs: usize, + ) { + let context = || { + format!( + "path={} window_id={window_id:?} monitor_id={monitor_id} mode={mode:?} paint_jobs={paint_jobs}", + path.as_str() + ) + }; + + self.warn_phase_if_slow( + slow_op_logger, + "overlay.window_renderer.prepare_input", + self.prepare_input, + &context, + ); + self.warn_phase_if_slow( + slow_op_logger, + "overlay.window_renderer.sync_hud_bg", + self.sync_hud_bg, + &context, + ); + self.warn_phase_if_slow( + slow_op_logger, + "overlay.window_renderer.run_egui", + self.run_egui, + &context, + ); + self.warn_phase_if_slow( + slow_op_logger, + "overlay.window_renderer.update_hud_blur_uniform", + self.update_hud_blur_uniform, + &context, + ); + self.warn_phase_if_slow( + slow_op_logger, + "overlay.window_renderer.sync_egui_textures", + self.sync_egui_textures, + &context, + ); + self.warn_phase_if_slow( + slow_op_logger, + "overlay.window_renderer.tessellate", + self.tessellate, + &context, + ); + } + + fn warn_phase_if_slow( + &self, + slow_op_logger: &mut SlowOperationLogger, + op: &'static str, + elapsed: Duration, + describe: &F, + ) where + F: Fn() -> String, + { + if elapsed.is_zero() { + return; + } + + slow_op_logger.warn_if_redraw_substep_slow(op, elapsed, self.total, describe); + } +} diff --git a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs index 1cd8447b..382a8580 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs @@ -1,11 +1,40 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; +use egui::Context; + +#[allow(unused_imports)] +use crate::overlay::rendering::{ + FrozenToolbarButtonStyle, SelectionDashedBorderCache, SelectionDashedBorderCacheKey, + SelectionDashedBorderMetrics, SelectionFlowGeometryCache, SelectionFlowGeometryCacheKey, + SelectionSizeBadgeLayout, SelectionSizeBadgePadding, SelectionSizeBadgeTarget, WindowRenderer, +}; +#[allow(unused_imports)] +use crate::overlay::{ + self, Align, Align2, Area, Color32, CornerRadius, FROZEN_SELECTION_SCRIM_ALPHA_DARK, + FROZEN_SELECTION_SCRIM_ALPHA_LIGHT, FROZEN_TOOLBAR_BUTTON_SIZE_POINTS, + FROZEN_TOOLBAR_ITEM_SPACING_POINTS, FontFamily, FontId, FrozenToolbarPointerState, + FrozenToolbarState, FrozenToolbarTool, HUD_PILL_CORNER_RADIUS_POINTS, + HUD_PILL_INNER_MARGIN_X_POINTS, HUD_PILL_INNER_MARGIN_Y_POINTS, HUD_PILL_STROKE_WIDTH_POINTS, + HudPillGeometry, HudTheme, Id, LIVE_DRAG_SELECTION_SCRIM_ALPHA_DARK, + LIVE_DRAG_SELECTION_SCRIM_ALPHA_LIGHT, LIVE_DRAG_START_THRESHOLD_PX, LayerId, Layout, Mesh, + MonitorRect, Order, OverlayMode, OverlayState, Painter, Pos2, Rect, RectPoints, + SELECTION_DASHED_BORDER_ALPHA, SELECTION_DASHED_BORDER_DASH_LENGTH_PX, + SELECTION_DASHED_BORDER_GAP_LENGTH_PX, SELECTION_DASHED_BORDER_WIDTH_PX, + SELECTION_FLOW_CORE_FLOW_WIDTH, SELECTION_FLOW_CORNER_RADIUS_PX, SELECTION_FLOW_FLOW_BOOST, + SELECTION_FLOW_FROZEN_ALPHA_SCALE, SELECTION_FLOW_FROZEN_INTENSITY, + SELECTION_FLOW_LIGHT_PALETTE, SELECTION_FLOW_MAX_SEGMENTS, SELECTION_FLOW_MIN_SEGMENTS, + SELECTION_FLOW_PALETTE, SELECTION_FLOW_SAMPLE_STEP_PX, SELECTION_FLOW_SPEED, + SELECTION_SIZE_BADGE_FAR_SHADOW_OFFSET_PX, SELECTION_SIZE_BADGE_FONT_SIZE_POINTS, + SELECTION_SIZE_BADGE_GAP_PX, SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX, + SELECTION_SIZE_BADGE_NEAR_SHADOW_OFFSET_PX, SELECTION_SIZE_BADGE_OUTLINE_OFFSET_PX, + SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX, SELECTION_SIZE_BADGE_TEXT_OUTSET_POINTS, + SelectionFlowStyle, Sense, Shape, Stroke, StrokeKind, TOOLBAR_CAPTURE_GAP_PX, + TOOLBAR_EXPANDED_HEIGHT_PX, TOOLBAR_SCREEN_MARGIN_PX, ToolbarPlacement, Ui, UiBuilder, Vec2, + regular, +}; impl WindowRenderer { #[allow(clippy::too_many_arguments)] pub(in crate::overlay) fn render_live_capture_affordances( - ctx: &egui::Context, + ctx: &Context, painter: &Painter, state: &OverlayState, monitor: MonitorRect, @@ -103,7 +132,7 @@ impl WindowRenderer { #[allow(clippy::too_many_arguments)] pub(in crate::overlay) fn render_frozen_capture_affordance( - ctx: &egui::Context, + ctx: &Context, state: &OverlayState, monitor: MonitorRect, screen_rect: Rect, @@ -318,7 +347,7 @@ impl WindowRenderer { ); let toolbar_pos = toolbar_state.floating_position.unwrap_or(default_pos); - if !frozen_toolbar_matches_default_slot(toolbar_pos, default_pos) { + if !overlay::frozen_toolbar_matches_default_slot(toolbar_pos, default_pos) { return None; } @@ -349,7 +378,7 @@ impl WindowRenderer { } pub(in crate::overlay) fn selection_size_badge_layout( - ctx: &egui::Context, + ctx: &Context, text: &str, theme: HudTheme, pixels_per_point: f32, @@ -537,7 +566,7 @@ impl WindowRenderer { } pub(in crate::overlay) fn render_selection_size_badge( - ctx: &egui::Context, + ctx: &Context, painter: &Painter, monitor: MonitorRect, screen_rect: Rect, @@ -944,7 +973,7 @@ impl WindowRenderer { pub(in crate::overlay) fn render_selection_flow_ring( painter: &Painter, rect: Rect, - ctx: &egui::Context, + ctx: &Context, theme: HudTheme, style: SelectionFlowStyle, selection_flow_stroke_width_px: f32, @@ -1416,7 +1445,7 @@ impl WindowRenderer { #[allow(clippy::too_many_arguments)] pub(in crate::overlay) fn render_frozen_toolbar_ui( - ctx: &egui::Context, + ctx: &Context, state: &OverlayState, monitor: MonitorRect, theme: HudTheme, @@ -1466,7 +1495,7 @@ impl WindowRenderer { #[cfg(any(not(target_os = "macos"), test))] { - if !advance_frozen_toolbar_readiness_sample_state(toolbar_state, screen_rect) { + if !overlay::advance_frozen_toolbar_readiness_sample_state(toolbar_state, screen_rect) { ctx.request_repaint(); return; @@ -1629,7 +1658,7 @@ impl WindowRenderer { #[allow(clippy::too_many_arguments)] pub(in crate::overlay) fn resolve_frozen_toolbar_birth( - ctx: &egui::Context, + ctx: &Context, state: &OverlayState, monitor: MonitorRect, toolbar_state: &mut FrozenToolbarState, @@ -1655,7 +1684,7 @@ impl WindowRenderer { "Frozen toolbar birth attempt." ); - let needs_new_sample = frozen_toolbar_needs_new_sample( + let needs_new_sample = overlay::frozen_toolbar_needs_new_sample( toolbar_state.layout_last_screen_size_points, screen_size_points, ); @@ -1785,7 +1814,7 @@ impl WindowRenderer { #[allow(clippy::too_many_arguments)] pub(in crate::overlay) fn draw_frozen_toolbar( - ctx: &egui::Context, + ctx: &Context, toolbar_state: &mut FrozenToolbarState, monitor: MonitorRect, screen_rect: Rect, diff --git a/packages/rsnap-overlay/src/overlay/rendering/hud_rendering.rs b/packages/rsnap-overlay/src/overlay/rendering/hud_rendering.rs index 4afe5df9..520597b2 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/hud_rendering.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/hud_rendering.rs @@ -1,6 +1,15 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; +use egui::Context; + +#[allow(unused_imports)] +use crate::overlay::rendering::WindowRenderer; +#[allow(unused_imports)] +use crate::overlay::{ + Align, Area, Color32, ColorImage, CornerRadius, Frame, GlobalPoint, HUD_LOUPE_STRIP_GAP_POINTS, + HUD_PILL_CORNER_RADIUS_POINTS, HudAnchor, HudPillGeometry, HudTheme, Id, Layout, Margin, + MonitorRect, Order, OverlayMode, OverlayState, Pos2, Rect, RichText, Sense, Stroke, StrokeKind, + TextureHandle, TextureId, TextureOptions, Ui, Vec2, hud_helpers, +}; +use crate::state::LoupeSample; pub(super) struct LiveLoupeTexture { texture: TextureHandle, @@ -26,7 +35,7 @@ impl WindowRenderer { #[allow(clippy::too_many_arguments)] pub(in crate::overlay::rendering) fn render_hud( &mut self, - ctx: &egui::Context, + ctx: &Context, state: &OverlayState, monitor: MonitorRect, cursor: GlobalPoint, @@ -388,10 +397,7 @@ impl WindowRenderer { } } - fn sync_live_loupe_texture( - &mut self, - loupe: Option<&crate::state::LoupeSample>, - ) -> Option { + fn sync_live_loupe_texture(&mut self, loupe: Option<&LoupeSample>) -> Option { let Some(loupe) = loupe else { self.live_loupe_texture = None; diff --git a/packages/rsnap-overlay/src/overlay/rendering/hud_surface.rs b/packages/rsnap-overlay/src/overlay/rendering/hud_surface.rs index a6bf8861..94911c9e 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/hud_surface.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/hud_surface.rs @@ -1,6 +1,17 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; +use egui::RawInput; +use wgpu::{BindGroup, TextureFormat}; + +#[allow(unused_imports)] +use crate::overlay::rendering::{GpuContext, WindowRenderer, WindowRendererPhaseTimings}; +#[allow(unused_imports)] +use crate::overlay::{ + Area, BindingResource, Color32, CornerRadius, Frame, FullOutput, HudDrawConfig, HudTheme, Id, + Instant, LOUPE_TILE_CORNER_RADIUS_POINTS, Margin, MonitorRect, Order, Origin3d, OverlayMode, + OverlayState, PhysicalSize, Pos2, Rect, Result, Rgb, RgbaImage, Sampler, StoreOp, Stroke, + StrokeKind, Texture, TextureAspect, TextureDimension, TextureUsages, TextureView, + TextureViewDescriptor, ThemeMode, Vec2, WindowRendererPath, hud_helpers, image_helpers, mem, + ptr, slice, +}; impl WindowRenderer { pub(in crate::overlay::rendering) fn trace_frozen_frame_metrics( @@ -244,7 +255,7 @@ impl WindowRenderer { #[allow(clippy::too_many_arguments)] pub(in crate::overlay::rendering) fn run_loupe_tile_egui( &mut self, - raw_input: egui::RawInput, + raw_input: RawInput, state: &OverlayState, theme: HudTheme, hud_blur_active: bool, @@ -446,7 +457,7 @@ impl WindowRenderer { mip_level_count, sample_count: 1, dimension: TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, + format: TextureFormat::Rgba8UnormSrgb, usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST | TextureUsages::RENDER_ATTACHMENT, @@ -540,12 +551,6 @@ pub(super) struct HudBg { max_lod: f32, } -#[derive(Clone, Copy, Debug, PartialEq)] -pub(in crate::overlay) struct HudPillGeometry { - pub(in crate::overlay) rect: Rect, - pub(in crate::overlay) radius_points: f32, -} - #[repr(C)] #[derive(Clone, Copy, Debug)] pub(super) struct HudBlurUniformRaw { @@ -560,3 +565,9 @@ impl HudBlurUniformRaw { unsafe { slice::from_raw_parts(ptr::from_ref(self).cast::(), mem::size_of::()) } } } + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(in crate::overlay) struct HudPillGeometry { + pub(in crate::overlay) rect: Rect, + pub(in crate::overlay) radius_points: f32, +} diff --git a/packages/rsnap-overlay/src/overlay/rendering/scroll_preview_window.rs b/packages/rsnap-overlay/src/overlay/rendering/scroll_preview_window.rs index 155d95f2..040c74f2 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/scroll_preview_window.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/scroll_preview_window.rs @@ -1,18 +1,22 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; - -struct ScrollPreviewStrip { - texture: TextureHandle, - pixel_size: [usize; 2], - rgba: Vec, - size_points: Vec2, -} +use wgpu::SurfaceConfiguration; + +#[allow(unused_imports)] +use crate::overlay::rendering::{GpuContext, ScrollPreviewView, WindowRenderer}; +#[allow(unused_imports)] +use crate::overlay::{ + self, AcquiredSurfaceFrame, ActiveEventLoop, Align, Arc, CentralPanel, Color32, ColorImage, + CornerRadius, CurrentSurfaceTexture, FontDefinitions, Frame, FullOutput, HudTheme, Layout, + LoadOp, LogicalSize, Margin, PhysicalSize, Renderer, Result, RgbaImage, + SCROLL_PREVIEW_WINDOW_HEIGHT_POINTS, SCROLL_PREVIEW_WINDOW_WIDTH_POINTS, ScreenDescriptor, + StoreOp, Stroke, Surface, SurfaceFrameSkipReason, TextureHandle, TextureOptions, + TextureViewDescriptor, Variant, Vec2, ViewportId, Visuals, WindowEvent, WindowLevel, WrapErr, + eyre, image_helpers, +}; pub(in crate::overlay) struct ScrollPreviewWindow { pub(in crate::overlay) window: Arc, surface: Surface<'static>, - surface_config: wgpu::SurfaceConfiguration, + surface_config: SurfaceConfiguration, needs_reconfigure: bool, egui_ctx: egui::Context, egui_state: egui_winit::State, @@ -79,7 +83,7 @@ impl ScrollPreviewWindow { let _ = window.set_cursor_hittest(false); #[cfg(target_os = "macos")] - macos_configure_hud_window(window.as_ref(), false, 0.0, Some(18.0)); + overlay::macos_configure_hud_window(window.as_ref(), false, 0.0, Some(18.0)); Ok(Self { window, @@ -371,3 +375,10 @@ impl ScrollPreviewWindow { self.needs_reconfigure = true; } } + +struct ScrollPreviewStrip { + texture: TextureHandle, + pixel_size: [usize; 2], + rgba: Vec, + size_points: Vec2, +} diff --git a/packages/rsnap-overlay/src/overlay/scroll_preview_runtime.rs b/packages/rsnap-overlay/src/overlay/scroll_preview_runtime.rs index c4773214..d1cfcecc 100644 --- a/packages/rsnap-overlay/src/overlay/scroll_preview_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/scroll_preview_runtime.rs @@ -1,6 +1,11 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; +#[allow(unused_imports)] +use crate::overlay::{ + ElementState, LogicalPosition, LogicalSize, MonitorRect, MouseButton, OverlayControl, + OverlayExit, OverlaySession, Pos2, Rect, RgbaImage, SCROLL_CAPTURE_PREVIEW_WIDTH_PX, + SCROLL_PREVIEW_WINDOW_HEIGHT_POINTS, SCROLL_PREVIEW_WINDOW_MARGIN_POINTS, + SCROLL_PREVIEW_WINDOW_WIDTH_POINTS, ScrollPreviewView, Vec2, WindowEvent, WindowId, + hud_helpers, scroll_capture, +}; impl OverlaySession { pub(super) fn sync_scroll_preview_segments(&mut self) { diff --git a/packages/rsnap-overlay/src/overlay/tests.rs b/packages/rsnap-overlay/src/overlay/tests.rs index b50073de..7dd592d4 100644 --- a/packages/rsnap-overlay/src/overlay/tests.rs +++ b/packages/rsnap-overlay/src/overlay/tests.rs @@ -1,13 +1,30 @@ +mod live_runtime; +mod rendering_behaviors; +mod scroll_input_runtime; +mod self_capture_runtime; +mod stream_refresh_runtime; +mod worker_observation_runtime; +mod worker_tick_runtime; + #[cfg(target_os = "macos")] use std::collections::VecDeque; #[cfg(target_os = "macos")] use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; #[cfg(target_os = "macos")] use std::thread; use std::time::Duration; use std::time::Instant; -use image::{Rgba, RgbaImage}; +use color_eyre::eyre; +use color_eyre::eyre::Result; +use egui::FontDefinitions; +use egui::FontFamily; +use egui::RawInput; +use egui_phosphor::Variant; +use image::Rgba; #[cfg(target_os = "macos")] use winit::dpi::PhysicalPosition; use winit::event::{ElementState, MouseButton, MouseScrollDelta}; @@ -59,37 +76,28 @@ use crate::state::{WindowListSnapshot, WindowRect}; use crate::worker::OverlayWorker; use crate::worker::{WorkerErrorSource, WorkerResponse}; -mod live_runtime; -mod rendering_behaviors; -mod scroll_input_runtime; -mod self_capture_runtime; -mod stream_refresh_runtime; -mod worker_observation_runtime; -mod worker_tick_runtime; - #[cfg(target_os = "macos")] struct SequenceScrollCaptureBackend { - frames: VecDeque>, + frames: VecDeque>, } - #[cfg(target_os = "macos")] impl SequenceScrollCaptureBackend { - fn new(frames: impl IntoIterator>) -> Self { + fn new(frames: impl IntoIterator>) -> Self { Self { frames: frames.into_iter().collect() } } } #[cfg(target_os = "macos")] impl CaptureBackend for SequenceScrollCaptureBackend { - fn capture_monitor(&mut self, _monitor: MonitorRect) -> color_eyre::eyre::Result { - Err(color_eyre::eyre::eyre!("unused in this test")) + fn capture_monitor(&mut self, _monitor: MonitorRect) -> Result { + Err(eyre::eyre!("unused in this test")) } fn capture_monitor_region_for_scroll_capture( &mut self, _monitor: MonitorRect, _rect_px: RectPoints, - ) -> color_eyre::eyre::Result> { + ) -> Result> { Ok(self.frames.pop_front().unwrap_or(None)) } @@ -97,7 +105,7 @@ impl CaptureBackend for SequenceScrollCaptureBackend { &mut self, _monitor: MonitorRect, _point: GlobalPoint, - ) -> color_eyre::eyre::Result> { + ) -> Result> { Ok(None) } @@ -107,12 +115,12 @@ impl CaptureBackend for SequenceScrollCaptureBackend { _point: GlobalPoint, _width_px: u32, _height_px: u32, - ) -> color_eyre::eyre::Result> { + ) -> Result> { Ok(None) } - fn refresh_window_cache(&mut self) -> color_eyre::eyre::Result> { - Err(color_eyre::eyre::eyre!("unused in this test")) + fn refresh_window_cache(&mut self) -> Result> { + Err(eyre::eyre!("unused in this test")) } } @@ -248,11 +256,17 @@ fn observe_scroll_capture_frame( session: &mut OverlaySession, frame: image::RgbaImage, ) -> Option { - session.observe_scroll_capture_frame(frame).transpose().unwrap() + match session.observe_scroll_capture_frame(frame).transpose() { + Ok(outcome) => outcome, + Err(err) => panic!("observe_scroll_capture_frame failed: {err:#}"), + } } fn scroll_capture_export_height(session: &OverlaySession) -> u32 { - session.scroll_capture.session.as_ref().unwrap().export_image().height() + match session.scroll_capture.session.as_ref() { + Some(scroll_session) => scroll_session.export_image().height(), + None => panic!("scroll_capture_export_height requires an active scroll session"), + } } fn test_monitor() -> MonitorRect { @@ -269,32 +283,28 @@ fn test_monitor_with_scale(width: u32, height: u32, scale_factor_x1000: u32) -> MonitorRect { id: 1, origin: GlobalPoint::new(0, 0), width, height, scale_factor_x1000 } } -fn test_frozen_image() -> RgbaImage { - RgbaImage::from_pixel(8, 8, Rgba([12, 34, 56, 255])) +fn test_frozen_image() -> image::RgbaImage { + image::RgbaImage::from_pixel(8, 8, Rgba([12, 34, 56, 255])) } fn test_egui_context() -> egui::Context { let ctx = egui::Context::default(); - let mut fonts = egui::FontDefinitions::default(); + let mut fonts = FontDefinitions::default(); let phosphor_fill = String::from("phosphor-fill"); - let proportional_fallback = fonts - .families - .get(&egui::FontFamily::Proportional) - .and_then(|names| names.first()) - .cloned(); + let proportional_fallback = + fonts.families.get(&FontFamily::Proportional).and_then(|names| names.first()).cloned(); - egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular); + egui_phosphor::add_to_fonts(&mut fonts, Variant::Regular); - fonts.font_data.insert(phosphor_fill.clone(), egui_phosphor::Variant::Fill.font_data().into()); + fonts.font_data.insert(phosphor_fill.clone(), Variant::Fill.font_data().into()); fonts .families - .entry(egui::FontFamily::Name(phosphor_fill.clone().into())) + .entry(FontFamily::Name(phosphor_fill.clone().into())) .or_default() .extend([phosphor_fill]); if let Some(fallback) = proportional_fallback { - let family = - fonts.families.entry(egui::FontFamily::Name("phosphor-fill".into())).or_default(); + let family = fonts.families.entry(FontFamily::Name("phosphor-fill".into())).or_default(); if !family.contains(&fallback) { family.push(fallback); @@ -303,7 +313,7 @@ fn test_egui_context() -> egui::Context { ctx.set_fonts(fonts); - let _ = ctx.run_ui(egui::RawInput::default(), |_ui| {}); + let _ = ctx.run_ui(RawInput::default(), |_ui| {}); ctx } @@ -354,7 +364,7 @@ fn begin_png_action_copies_preview_render_image_during_active_scroll_capture() { session.scroll_capture.active = true; session.scroll_capture.session = Some(scroll_session); session.scroll_capture.preview_display_image = - Some(RgbaImage::from_pixel(320, 64, Rgba([77, 0, 0, 255]))); + Some(image::RgbaImage::from_pixel(320, 64, Rgba([77, 0, 0, 255]))); session.begin_png_action(PngAction::Copy); @@ -549,7 +559,7 @@ fn duplicate_live_frames_schedule_forced_refresh_when_downward_backlog_is_fresh( let frame = ScrollCaptureLiveFrame { frame_seq: 7, captured_at: observed_at, - image: RgbaImage::from_pixel(16, 16, Rgba([7, 8, 9, 255])), + image: image::RgbaImage::from_pixel(16, 16, Rgba([7, 8, 9, 255])), }; let mut session = OverlaySession::new(); @@ -609,7 +619,7 @@ fn scroll_capture_guard_error_keeps_frozen_capture_available() { seed_ready_scroll_capture_selection(&mut session); session.set_scroll_capture_start_guard(Arc::new(|| { - Err(color_eyre::eyre::eyre!("Open System Settings and retry.")) + Err(eyre::eyre!("Open System Settings and retry.")) })); let control = session.start_scroll_capture(); @@ -649,9 +659,8 @@ fn scroll_capture_starting_hook_error_keeps_frozen_capture_available() { seed_ready_scroll_capture_selection(&mut session); session.set_scroll_capture_start_guard(Arc::new(|| Ok(true))); - session.set_scroll_capture_starting_hook(Arc::new(|| { - Err(color_eyre::eyre::eyre!("Observer was not ready.")) - })); + session + .set_scroll_capture_starting_hook(Arc::new(|| Err(eyre::eyre!("Observer was not ready.")))); let control = session.start_scroll_capture(); @@ -669,14 +678,14 @@ fn scroll_capture_starting_hook_error_keeps_frozen_capture_available() { #[cfg(target_os = "macos")] #[test] fn scroll_capture_preflight_runs_before_permission_guard() { - let guard_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let guard_calls = Arc::new(AtomicUsize::new(0)); let mut session = OverlaySession::new(); session.set_scroll_capture_start_guard(Arc::new({ let guard_calls = Arc::clone(&guard_calls); move || { - guard_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + guard_calls.fetch_add(1, Ordering::SeqCst); Ok(true) } @@ -695,7 +704,7 @@ fn scroll_capture_preflight_runs_before_permission_guard() { #[cfg(target_os = "macos")] #[test] fn scroll_capture_starting_hook_runs_before_started_hook() { - let hook_order = Arc::new(std::sync::Mutex::new(Vec::<&'static str>::new())); + let hook_order = Arc::new(Mutex::new(Vec::<&'static str>::new())); let mut session = OverlaySession::new(); seed_ready_scroll_capture_selection(&mut session); diff --git a/packages/rsnap-overlay/src/overlay/tests/live_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/live_runtime.rs index 04fd037b..32931a99 100644 --- a/packages/rsnap-overlay/src/overlay/tests/live_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/tests/live_runtime.rs @@ -1,6 +1,17 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; +use image::RgbaImage; + +#[allow(unused_imports)] +use crate::overlay::tests::{ + self, Duration, GlobalPoint, HudRedrawSummary, LoupeSample, MonitorRect, MonitorRectPoints, + OverlayMode, OverlaySession, OverlayState, Pos2, Rect, RectPoints, Rgb, Vec2, WindowRenderer, + hud_helpers, overlay, +}; +#[cfg(target_os = "macos")] +#[allow(unused_imports)] +use crate::overlay::tests::{ + AltActivationMode, HUD_PILL_CORNER_RADIUS_POINTS, HudPillGeometry, LiveCursorSample, + LiveSampleApplyResult, ModifiersState, StartupLiveRgbPlan, WindowId, +}; #[cfg(target_os = "macos")] #[test] @@ -13,7 +24,7 @@ fn apply_live_cursor_sample_updates_rgb_and_loupe_state() { scale_factor_x1000: 1_000, }; let cursor = GlobalPoint::new(120, 180); - let patch = image::RgbaImage::from_pixel(3, 3, Rgba([10, 20, 30, 255])); + let patch = RgbaImage::from_pixel(3, 3, crate::overlay::tests::Rgba([10, 20, 30, 255])); let mut session = OverlaySession::new(); session.cursor_monitor = Some(monitor); @@ -48,7 +59,7 @@ fn apply_live_cursor_sample_detail_keeps_overlay_redraw_narrow_for_rgb_and_loupe scale_factor_x1000: 1_000, }; let cursor = GlobalPoint::new(120, 180); - let patch = image::RgbaImage::from_pixel(3, 3, Rgba([10, 20, 30, 255])); + let patch = RgbaImage::from_pixel(3, 3, crate::overlay::tests::Rgba([10, 20, 30, 255])); let mut session = OverlaySession::new(); session.cursor_monitor = Some(monitor); @@ -382,7 +393,7 @@ fn live_drag_focus_rect_uses_large_drag_on_active_monitor() { scale_factor_x1000: 1_000, }; let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(1_000.0, 800.0)); - let mut state = crate::state::OverlayState::new(); + let mut state = OverlayState::new(); state.drag_rect = Some(MonitorRectPoints { monitor_id: monitor.id, @@ -674,7 +685,11 @@ fn apply_live_cursor_sample_clears_existing_loupe_when_alt_is_released() { cursor, LiveCursorSample { rgb: Some(Rgb::new(10, 20, 30)), - patch: Some(image::RgbaImage::from_pixel(3, 3, Rgba([10, 20, 30, 255]))), + patch: Some(RgbaImage::from_pixel( + 3, + 3, + crate::overlay::tests::Rgba([10, 20, 30, 255]), + )), }, ); @@ -760,7 +775,7 @@ fn live_hud_rgb_text_uses_fixed_width_placeholders() { #[test] fn stable_live_loupe_side_prefers_configured_patch_side() { - let mut state = crate::state::OverlayState::new(); + let mut state = OverlayState::new(); state.loupe_patch_side_px = 21; state.loupe = Some(LoupeSample { @@ -773,7 +788,7 @@ fn stable_live_loupe_side_prefers_configured_patch_side() { #[test] fn stable_live_loupe_side_ignores_larger_runtime_patch() { - let mut state = crate::state::OverlayState::new(); + let mut state = OverlayState::new(); state.loupe_patch_side_px = 21; state.loupe = Some(LoupeSample { diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs index a73dad6b..e828a628 100644 --- a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs @@ -1,15 +1,31 @@ -#![allow(clippy::wildcard_imports)] +use egui::Id; +use egui::LayerId; +use egui::Order; +use egui::Ui; +use image::RgbaImage; -use super::*; use crate::OverlayControl; +#[allow(unused_imports)] +use crate::overlay::tests::{ + self, ElementState, FrozenCaptureSource, FrozenSelectionDragState, FrozenToolbarState, + FrozenToolbarTool, GlobalPoint, HUD_LOUPE_STRIP_GAP_POINTS, HudTheme, MonitorRect, + MonitorRectPoints, MouseButton, OverlayMode, OverlaySession, OverlayState, PngAction, Pos2, + RawInput, Rect, RectPoints, Rgba, SELECTION_DASHED_BORDER_DASH_LENGTH_PX, + SELECTION_DASHED_BORDER_GAP_LENGTH_PX, SELECTION_DASHED_BORDER_WIDTH_PX, + SELECTION_SIZE_BADGE_GAP_PX, SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX, + SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX, ScrollSession, SelectionDashedBorderCache, + SelectionDashedBorderMetrics, SelectionFlowGeometryCache, SelectionSizeBadgeTarget, + TOOLBAR_CAPTURE_GAP_PX, TOOLBAR_SCREEN_MARGIN_PX, ToolbarPlacement, Vec2, WindowRenderer, + WorkerErrorSource, WorkerResponse, overlay, +}; #[test] fn pending_freeze_capture_dispatches_even_with_seeded_preview() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let mut session = OverlaySession::new(); session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); + session.state.finish_freeze(monitor, tests::test_frozen_image()); session.pending_freeze_capture = Some(monitor); @@ -19,11 +35,11 @@ fn pending_freeze_capture_dispatches_even_with_seeded_preview() { #[cfg(not(target_os = "macos"))] #[test] fn pending_freeze_capture_waits_for_empty_frozen_image_off_macos() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let mut session = OverlaySession::new(); session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); + session.state.finish_freeze(monitor, tests::test_frozen_image()); session.pending_freeze_capture = Some(monitor); @@ -32,14 +48,14 @@ fn pending_freeze_capture_waits_for_empty_frozen_image_off_macos() { #[test] fn frozen_final_capture_ready_requires_no_pending_or_inflight_capture() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let mut session = OverlaySession::new(); session.state.begin_freeze(monitor); assert!(!session.frozen_final_capture_ready()); - session.state.finish_freeze(monitor, test_frozen_image()); + session.state.finish_freeze(monitor, tests::test_frozen_image()); session.authoritative_frozen_capture_ready = true; @@ -57,12 +73,12 @@ fn frozen_final_capture_ready_requires_no_pending_or_inflight_capture() { #[test] fn frozen_preview_does_not_become_final_ready_when_capture_tracking_clears_without_success() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let capture_rect = RectPoints::new(100, 120, 220, 180); let mut session = OverlaySession::new(); session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); + session.state.finish_freeze(monitor, tests::test_frozen_image()); session.state.frozen_capture_rect = Some(capture_rect); session.frozen_capture_source = FrozenCaptureSource::DragRegion; @@ -86,7 +102,7 @@ fn frozen_preview_does_not_become_final_ready_when_capture_tracking_clears_witho #[test] fn unrelated_worker_errors_do_not_clear_pending_freeze_capture_state() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let mut session = OverlaySession::new(); session.state.begin_freeze(monitor); @@ -114,12 +130,12 @@ fn unrelated_worker_errors_do_not_clear_pending_freeze_capture_state() { #[test] fn frozen_selection_drag_starts_only_for_drag_region_inside_capture_rect() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let capture_rect = RectPoints::new(100, 120, 200, 240); let mut session = OverlaySession::new(); session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); + session.state.finish_freeze(monitor, tests::test_frozen_image()); session.state.frozen_capture_rect = Some(capture_rect); @@ -143,12 +159,12 @@ fn frozen_selection_drag_starts_only_for_drag_region_inside_capture_rect() { #[test] fn frozen_selection_drag_updates_capture_rect_and_toolbar_position() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let capture_rect = RectPoints::new(100, 120, 200, 240); let mut session = OverlaySession::new(); session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); + session.state.finish_freeze(monitor, tests::test_frozen_image()); session.state.frozen_capture_rect = Some(capture_rect); session.frozen_capture_source = FrozenCaptureSource::DragRegion; @@ -168,12 +184,12 @@ fn frozen_selection_drag_updates_capture_rect_and_toolbar_position() { #[test] fn frozen_selection_drag_clamps_capture_rect_to_monitor_bounds() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let capture_rect = RectPoints::new(100, 120, 200, 240); let mut session = OverlaySession::new(); session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); + session.state.finish_freeze(monitor, tests::test_frozen_image()); session.state.frozen_capture_rect = Some(capture_rect); session.frozen_capture_source = FrozenCaptureSource::DragRegion; @@ -216,7 +232,7 @@ fn cropped_frozen_capture_image_uses_moved_capture_rect() { #[test] fn auto_center_frozen_capture_rect_recenters_detected_content() { - let monitor = test_monitor_with_scale(80, 60, 2_000); + let monitor = tests::test_monitor_with_scale(80, 60, 2_000); let capture_rect = RectPoints::new(20, 16, 40, 24); let mut image = RgbaImage::from_pixel(160, 120, Rgba([14, 16, 20, 255])); let mut session = OverlaySession::new(); @@ -247,7 +263,7 @@ fn auto_center_frozen_capture_rect_recenters_detected_content() { #[test] fn frozen_toolbar_default_position_centers_on_capture_rect_midpoint() { - let monitor = test_monitor_with_scale(400, 300, 2_000); + let monitor = tests::test_monitor_with_scale(400, 300, 2_000); let capture_rect = RectPoints::new(150, 100, 100, 60); let session = OverlaySession::new(); let toolbar_size = WindowRenderer::frozen_toolbar_size(&session.toolbar_state); @@ -261,7 +277,7 @@ fn frozen_toolbar_default_position_centers_on_capture_rect_midpoint() { #[test] fn auto_center_frozen_capture_rect_noops_for_uniform_crop() { - let monitor = test_monitor_with_scale(80, 60, 1_000); + let monitor = tests::test_monitor_with_scale(80, 60, 1_000); let capture_rect = RectPoints::new(20, 16, 40, 24); let mut session = OverlaySession::new(); @@ -304,12 +320,12 @@ fn global_left_release_stops_frozen_selection_drag() { #[test] fn scroll_capture_and_export_wait_for_authoritative_frozen_capture() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let capture_rect = RectPoints::new(100, 120, 220, 180); let mut session = OverlaySession::new(); session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); + session.state.finish_freeze(monitor, tests::test_frozen_image()); session.authoritative_frozen_capture_ready = true; session.state.frozen_capture_rect = Some(capture_rect); @@ -690,7 +706,7 @@ fn selection_size_badge_rect_keeps_tiny_bottom_capture_visible() { #[test] fn frozen_selection_size_badge_falls_inside_when_default_bottom_toolbar_slot_overlaps() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); let capture_rect_points = RectPoints::new(200, 180, 200, 300); @@ -728,7 +744,7 @@ fn frozen_selection_size_badge_falls_inside_when_default_bottom_toolbar_slot_ove #[test] fn frozen_selection_size_badge_keeps_below_placement_after_toolbar_leaves_default_slot() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); let capture_rect_points = RectPoints::new(200, 180, 200, 300); @@ -810,7 +826,7 @@ fn frozen_top_toolbar_reserved_rect_uses_inside_fallback_slot() { #[test] fn overlay_session_computes_frozen_toolbar_reserved_rect_without_inline_toolbar_state() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); let mut session = OverlaySession::new(); @@ -880,7 +896,7 @@ fn frozen_toolbar_reserved_rect_uses_overlay_viewport_size() { #[test] fn frozen_toolbar_reserved_rect_skips_hidden_toolbar_slot() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); let mut session = OverlaySession::new(); @@ -894,7 +910,7 @@ fn frozen_toolbar_reserved_rect_skips_hidden_toolbar_slot() { #[test] fn frozen_toolbar_reserved_rect_waits_for_toolbar_birth_readiness() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); let mut session = OverlaySession::new(); @@ -931,7 +947,7 @@ fn frozen_toolbar_reserved_rect_waits_for_toolbar_birth_readiness() { #[test] fn frozen_toolbar_ready_for_draw_ignores_preseeded_position_until_viewport_stabilizes() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let capture_rect = RectPoints::new(200, 180, 200, 300); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); @@ -955,7 +971,7 @@ fn frozen_toolbar_ready_for_draw_ignores_preseeded_position_until_viewport_stabi #[test] fn frozen_toolbar_ready_for_draw_recovers_after_preseeded_position_is_sampled() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let capture_rect = RectPoints::new(200, 180, 200, 300); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); @@ -974,8 +990,8 @@ fn frozen_toolbar_ready_for_draw_recovers_after_preseeded_position_is_sampled() #[test] fn render_frozen_toolbar_ui_waits_for_readiness_before_first_visible_frame() { - let ctx = test_egui_context(); - let monitor = test_monitor(); + let ctx = tests::test_egui_context(); + let monitor = tests::test_monitor(); let capture_rect = RectPoints::new(200, 180, 200, 300); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); @@ -994,7 +1010,7 @@ fn render_frozen_toolbar_ui_waits_for_readiness_before_first_visible_frame() { let mut hud_pill = None; let _ = ctx.run_ui( egui::RawInput { screen_rect: Some(screen_rect), ..Default::default() }, - |ui| { + |ui: &mut Ui| { WindowRenderer::render_frozen_toolbar_ui( ui.ctx(), state, @@ -1022,8 +1038,9 @@ fn render_frozen_toolbar_ui_waits_for_readiness_before_first_visible_frame() { let state = &session.state; let toolbar_state = &mut session.toolbar_state; let mut hud_pill = None; - let _ = - ctx.run_ui(egui::RawInput { screen_rect: Some(screen_rect), ..Default::default() }, |ui| { + let _ = ctx.run_ui( + egui::RawInput { screen_rect: Some(screen_rect), ..Default::default() }, + |ui: &mut Ui| { WindowRenderer::render_frozen_toolbar_ui( ui.ctx(), state, @@ -1039,14 +1056,15 @@ fn render_frozen_toolbar_ui_waits_for_readiness_before_first_visible_frame() { None, &mut hud_pill, ); - }); + }, + ); assert!(hud_pill.is_some(), "third frame should draw the stabilized toolbar"); } #[test] fn frozen_toolbar_reserved_rect_restores_near_default_slot() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); let capture_rect = Rect::from_min_size(Pos2::new(200.0, 180.0), Vec2::new(200.0, 300.0)); @@ -1081,7 +1099,7 @@ fn frozen_toolbar_reserved_rect_restores_near_default_slot() { #[test] fn frozen_toolbar_overlay_viewport_sample_recovers_from_toolbar_window_pollution() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let overlay_screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); let toolbar_window_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(92.0, 26.0)); @@ -1217,7 +1235,7 @@ fn selection_size_badge_reserved_rect_accepts_overlap_when_no_non_overlapping_sl #[test] fn selection_size_badge_text_uses_monitor_pixel_dimensions() { - let monitor = test_monitor_with_scale(1_000, 800, 2_000); + let monitor = tests::test_monitor_with_scale(1_000, 800, 2_000); assert_eq!( WindowRenderer::selection_size_badge_text(monitor, RectPoints::new(10, 20, 120, 80)), @@ -1227,7 +1245,7 @@ fn selection_size_badge_text_uses_monitor_pixel_dimensions() { #[test] fn selection_size_badge_layout_keeps_visual_bounds_within_right_edge_rect() { - let ctx = test_egui_context(); + let ctx = tests::test_egui_context(); let layout = WindowRenderer::selection_size_badge_layout(&ctx, "240x160", HudTheme::Light, 1.0); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); let capture_rect = Rect::from_min_size(Pos2::new(760.0, 160.0), Vec2::new(40.0, 120.0)); @@ -1244,7 +1262,7 @@ fn selection_size_badge_layout_keeps_visual_bounds_within_right_edge_rect() { #[test] fn selection_size_badge_layout_keeps_visual_bounds_within_bottom_fallback_rect() { - let ctx = test_egui_context(); + let ctx = tests::test_egui_context(); let layout = WindowRenderer::selection_size_badge_layout(&ctx, "240x160", HudTheme::Light, 1.0); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); let capture_rect = Rect::from_min_size(Pos2::new(120.0, 588.0), Vec2::new(140.0, 12.0)); @@ -1261,7 +1279,7 @@ fn selection_size_badge_layout_keeps_visual_bounds_within_bottom_fallback_rect() #[test] fn live_capture_size_badge_target_prefers_drag_then_hover_then_fullscreen() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); let mut state = OverlayState::new(); @@ -1308,7 +1326,7 @@ fn live_capture_size_badge_target_prefers_drag_then_hover_then_fullscreen() { #[test] fn live_capture_size_badge_target_skips_fullscreen_fallback_while_primary_down() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); let mut state = OverlayState::new(); @@ -1358,8 +1376,8 @@ fn frozen_capture_size_badge_target_keeps_tiny_frozen_rect() { #[test] fn render_frozen_capture_affordance_keeps_tiny_frozen_badge_path() { - let ctx = test_egui_context(); - let monitor = test_monitor(); + let ctx = tests::test_egui_context(); + let monitor = tests::test_monitor(); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); let mut state = OverlayState::new(); @@ -1387,11 +1405,10 @@ fn render_frozen_capture_affordance_keeps_tiny_frozen_badge_path() { #[test] fn render_live_capture_affordances_keep_hover_scrim_when_flow_disabled() { - let ctx = test_egui_context(); - let layer = - egui::LayerId::new(egui::Order::Foreground, egui::Id::new("live-hover-flow-disabled")); + let ctx = tests::test_egui_context(); + let layer = LayerId::new(Order::Foreground, Id::new("live-hover-flow-disabled")); let painter = ctx.layer_painter(layer); - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); let selection_dashed_border_cache = SelectionDashedBorderCache::default(); @@ -1420,7 +1437,7 @@ fn render_live_capture_affordances_keep_hover_scrim_when_flow_disabled() { #[test] fn live_capture_size_badge_target_keeps_tiny_drag_rect() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); let mut state = OverlayState::new(); @@ -1501,11 +1518,11 @@ fn scroll_toolbar_compacts_to_two_buttons() { #[cfg(target_os = "macos")] #[test] fn drag_region_toolbar_size_stays_stable_while_final_capture_readiness_changes() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let mut session = OverlaySession::new(); session.state.begin_freeze(monitor); - session.state.finish_freeze(monitor, test_frozen_image()); + session.state.finish_freeze(monitor, tests::test_frozen_image()); session.state.frozen_capture_rect = Some(RectPoints::new(120, 160, 320, 240)); session.frozen_capture_source = FrozenCaptureSource::DragRegion; @@ -1535,7 +1552,7 @@ fn drag_region_toolbar_size_stays_stable_while_final_capture_readiness_changes() #[cfg(target_os = "macos")] #[test] fn drag_region_toolbar_recenters_when_auto_center_appears_after_preview_commit() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let capture_rect = RectPoints::new(120, 160, 320, 240); let mut session = OverlaySession::new(); @@ -1551,7 +1568,7 @@ fn drag_region_toolbar_recenters_when_auto_center_appears_after_preview_commit() assert!(!session.toolbar_state.auto_center_available); assert_eq!(seeded_pos.x + seeded_size.x * 0.5, capture_midpoint_x); - session.commit_frozen_preview(monitor, test_frozen_image(), None); + session.commit_frozen_preview(monitor, tests::test_frozen_image(), None); session.sync_frozen_toolbar_state(); let ready_size = WindowRenderer::frozen_toolbar_size(&session.toolbar_state); @@ -1570,7 +1587,7 @@ fn drag_region_toolbar_recenters_when_auto_center_appears_after_preview_commit() #[cfg(target_os = "macos")] #[test] fn late_toolbar_width_change_preserves_manual_toolbar_move() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let capture_rect = RectPoints::new(120, 160, 320, 240); let mut session = OverlaySession::new(); @@ -1584,7 +1601,7 @@ fn late_toolbar_width_change_preserves_manual_toolbar_move() { session.toolbar_state.floating_position = Some(moved_pos); - session.commit_frozen_preview(monitor, test_frozen_image(), None); + session.commit_frozen_preview(monitor, tests::test_frozen_image(), None); session.sync_frozen_toolbar_state(); assert!(!session.maybe_recenter_frozen_toolbar_default_slot(monitor)); @@ -1675,8 +1692,8 @@ fn scroll_preview_grows_with_render_height_until_monitor_limit() { #[test] fn current_scroll_preview_render_image_prefers_committed_export_during_scroll_capture() { let mut session = OverlaySession::new(); - let base = make_scroll_capture_test_image(3, &[[10, 0, 0, 255]; 8]); - let grown = make_scroll_capture_test_image(3, &[[20, 0, 0, 255]; 12]); + let base = tests::make_scroll_capture_test_image(3, &[[10, 0, 0, 255]; 8]); + let grown = tests::make_scroll_capture_test_image(3, &[[20, 0, 0, 255]; 12]); let mismatched_preview = RgbaImage::from_pixel(320, 40, Rgba([99, 0, 0, 255])); let mut scroll_session = ScrollSession::new(base, 320).expect("scroll session"); let _ = scroll_session.observe_downward_sample(grown).expect("observe"); @@ -1702,8 +1719,8 @@ fn current_scroll_preview_render_image_uses_preview_display_when_scroll_capture_ #[test] fn scroll_capture_preview_dimensions_follow_render_authority_during_scroll_capture() { let mut session = OverlaySession::new(); - let base = make_scroll_capture_test_image(3, &[[10, 0, 0, 255]; 8]); - let grown = make_scroll_capture_test_image(3, &[[20, 0, 0, 255]; 12]); + let base = tests::make_scroll_capture_test_image(3, &[[10, 0, 0, 255]; 8]); + let grown = tests::make_scroll_capture_test_image(3, &[[20, 0, 0, 255]; 12]); let mismatched_preview = RgbaImage::from_pixel(320, 40, Rgba([99, 0, 0, 255])); let mut scroll_session = ScrollSession::new(base, 320).expect("scroll session"); let _ = scroll_session.observe_downward_sample(grown).expect("observe"); @@ -1722,8 +1739,8 @@ fn scroll_capture_preview_dimensions_follow_render_authority_during_scroll_captu #[test] fn refresh_scroll_preview_display_image_uses_export_sized_render_buffer_during_active_capture() { let mut session = OverlaySession::new(); - let base = make_scroll_capture_test_image(3, &[[10, 0, 0, 255]; 8]); - let grown = make_scroll_capture_test_image(3, &[[20, 0, 0, 255]; 12]); + let base = tests::make_scroll_capture_test_image(3, &[[10, 0, 0, 255]; 8]); + let grown = tests::make_scroll_capture_test_image(3, &[[20, 0, 0, 255]; 12]); let mut scroll_session = ScrollSession::new(base, 320).expect("scroll session"); let _ = scroll_session.observe_downward_sample(grown).expect("observe"); let expected_committed = scroll_session.export_image().clone(); diff --git a/packages/rsnap-overlay/src/overlay/tests/scroll_input_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/scroll_input_runtime.rs index fa934341..e964c307 100644 --- a/packages/rsnap-overlay/src/overlay/tests/scroll_input_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/tests/scroll_input_runtime.rs @@ -1,6 +1,11 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; +#[allow(unused_imports)] +use crate::overlay::tests::{ + self, Duration, GlobalPoint, Instant, MonitorRect, MouseScrollDelta, OverlaySession, + RectPoints, ScrollDirection, ScrollObserveOutcome, ScrollSession, overlay, +}; +#[cfg(target_os = "macos")] +#[allow(unused_imports)] +use crate::overlay::tests::{Arc, OverlayControl, SCROLL_CAPTURE_MOUSE_PASSTHROUGH_IDLE_GRACE}; #[cfg(target_os = "macos")] #[test] @@ -225,7 +230,7 @@ fn scroll_overlay_mouse_passthrough_window_arms_and_expires() { fn scroll_capture_start_enables_persistent_passthrough() { let mut session = OverlaySession::new(); - seed_ready_scroll_capture_selection(&mut session); + tests::seed_ready_scroll_capture_selection(&mut session); let control = session.start_scroll_capture(); @@ -241,7 +246,7 @@ fn scroll_capture_start_enables_persistent_passthrough() { fn scroll_capture_pause_and_resume_toggle_persistent_passthrough() { let mut session = OverlaySession::new(); - seed_ready_scroll_capture_selection(&mut session); + tests::seed_ready_scroll_capture_selection(&mut session); let _ = session.start_scroll_capture(); @@ -412,62 +417,84 @@ fn upward_input_does_not_dirty_later_downward_growth() { let mut session = OverlaySession::new(); session.scroll_capture.active = true; - session.scroll_capture.session = - Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); + session.scroll_capture.session = Some( + ScrollSession::new(tests::make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap(), + ); - set_scroll_capture_input(&mut session, ScrollDirection::Down); + tests::set_scroll_capture_input(&mut session, ScrollDirection::Down); assert_eq!( - observe_scroll_capture_frame(&mut session, make_scroll_capture_window(&document, 3, 1, 5),), + tests::observe_scroll_capture_frame( + &mut session, + tests::make_scroll_capture_window(&document, 3, 1, 5), + ), Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) ); assert_eq!( - observe_scroll_capture_frame(&mut session, make_scroll_capture_window(&document, 3, 2, 5),), + tests::observe_scroll_capture_frame( + &mut session, + tests::make_scroll_capture_window(&document, 3, 2, 5), + ), Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) ); - let height_after_second_append = scroll_capture_export_height(&session); + let height_after_second_append = tests::scroll_capture_export_height(&session); - set_scroll_capture_input(&mut session, ScrollDirection::Up); + tests::set_scroll_capture_input(&mut session, ScrollDirection::Up); assert!(matches!( - observe_scroll_capture_frame(&mut session, make_scroll_capture_window(&document, 3, 0, 5),), + tests::observe_scroll_capture_frame( + &mut session, + tests::make_scroll_capture_window(&document, 3, 0, 5), + ), Some( ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } | ScrollObserveOutcome::PreviewUpdated ) )); - assert_eq!(scroll_capture_export_height(&session), height_after_second_append); + assert_eq!(tests::scroll_capture_export_height(&session), height_after_second_append); - set_scroll_capture_input(&mut session, ScrollDirection::Down); + tests::set_scroll_capture_input(&mut session, ScrollDirection::Down); assert_eq!( - observe_scroll_capture_frame(&mut session, make_scroll_capture_window(&document, 3, 2, 5),), + tests::observe_scroll_capture_frame( + &mut session, + tests::make_scroll_capture_window(&document, 3, 2, 5), + ), Some(ScrollObserveOutcome::NoChange) ); - assert_eq!(scroll_capture_export_height(&session), height_after_second_append); + assert_eq!(tests::scroll_capture_export_height(&session), height_after_second_append); - set_scroll_capture_input(&mut session, ScrollDirection::Up); + tests::set_scroll_capture_input(&mut session, ScrollDirection::Up); assert!(matches!( - observe_scroll_capture_frame(&mut session, make_scroll_capture_window(&document, 3, 1, 5),), + tests::observe_scroll_capture_frame( + &mut session, + tests::make_scroll_capture_window(&document, 3, 1, 5), + ), Some( ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up } | ScrollObserveOutcome::PreviewUpdated | ScrollObserveOutcome::NoChange ) )); - assert_eq!(scroll_capture_export_height(&session), height_after_second_append); + assert_eq!(tests::scroll_capture_export_height(&session), height_after_second_append); - set_scroll_capture_input(&mut session, ScrollDirection::Down); + tests::set_scroll_capture_input(&mut session, ScrollDirection::Down); assert_eq!( - observe_scroll_capture_frame(&mut session, make_scroll_capture_window(&document, 3, 2, 5),), + tests::observe_scroll_capture_frame( + &mut session, + tests::make_scroll_capture_window(&document, 3, 2, 5), + ), Some(ScrollObserveOutcome::NoChange) ); - assert_eq!(scroll_capture_export_height(&session), height_after_second_append); + assert_eq!(tests::scroll_capture_export_height(&session), height_after_second_append); assert_eq!( - observe_scroll_capture_frame(&mut session, make_scroll_capture_window(&document, 3, 3, 5),), + tests::observe_scroll_capture_frame( + &mut session, + tests::make_scroll_capture_window(&document, 3, 3, 5), + ), Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) ); } diff --git a/packages/rsnap-overlay/src/overlay/tests/self_capture_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/self_capture_runtime.rs index 1f26767a..02f61193 100644 --- a/packages/rsnap-overlay/src/overlay/tests/self_capture_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/tests/self_capture_runtime.rs @@ -1,12 +1,20 @@ -#![allow(clippy::wildcard_imports)] - #[cfg(target_os = "macos")] -use super::*; +#[allow(unused_imports)] +use crate::overlay::tests::{ + self, Arc, InflightScrollCaptureObservation, OverlayControl, ScrollCaptureLiveFrame, + WindowListSnapshot, WindowRect, +}; +#[cfg(target_os = "macos")] +#[allow(unused_imports)] +use crate::overlay::tests::{ + GlobalPoint, Instant, OverlaySession, ScrollDirection, WorkerErrorSource, WorkerResponse, + overlay, +}; #[cfg(target_os = "macos")] #[test] fn apply_self_capture_exception_window_ids_to_active_streams_updates_live_stream_filters() { - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + let (mut session, original_worker_debug_id) = tests::configured_session_with_macos_worker(); session.window_list_snapshot = Some(Arc::new(WindowListSnapshot { captured_at: Instant::now(), @@ -49,14 +57,14 @@ fn apply_self_capture_exception_window_ids_to_active_streams_updates_live_stream #[test] fn apply_self_capture_exception_window_ids_to_active_streams_keeps_scroll_live_stream_disabled_in_worker_mode() { - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + let (mut session, original_worker_debug_id) = tests::configured_session_with_macos_worker(); - enable_test_worker_scroll_capture_path(&mut session); + tests::enable_test_worker_scroll_capture_path(&mut session); session.test_push_scroll_capture_live_frame(ScrollCaptureLiveFrame { frame_seq: 9, captured_at: Instant::now(), - image: test_frozen_image(), + image: tests::test_frozen_image(), }); session.scroll_capture.last_stream_event_at = Some(Instant::now()); @@ -80,8 +88,8 @@ fn apply_self_capture_exception_window_ids_to_active_streams_keeps_scroll_live_s #[test] fn apply_self_capture_exception_window_ids_to_active_streams_defers_worker_refresh_while_freeze_is_inflight() { - let monitor = test_monitor(); - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + let monitor = tests::test_monitor(); + let (mut session, original_worker_debug_id) = tests::configured_session_with_macos_worker(); session.inflight_freeze_capture = Some(monitor); @@ -99,7 +107,7 @@ fn apply_self_capture_exception_window_ids_to_active_streams_defers_worker_refre #[test] fn apply_self_capture_exception_window_ids_to_active_streams_defers_worker_refresh_while_hit_test_is_inflight() { - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + let (mut session, original_worker_debug_id) = tests::configured_session_with_macos_worker(); session.pending_click_hit_test_request_id = Some(7); @@ -113,7 +121,7 @@ fn apply_self_capture_exception_window_ids_to_active_streams_defers_worker_refre #[test] fn apply_self_capture_exception_window_ids_to_active_streams_defers_worker_refresh_while_window_list_refresh_is_inflight() { - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + let (mut session, original_worker_debug_id) = tests::configured_session_with_macos_worker(); session.window_list_refresh_inflight = true; @@ -127,7 +135,7 @@ fn apply_self_capture_exception_window_ids_to_active_streams_defers_worker_refre #[test] fn apply_self_capture_exception_window_ids_to_active_streams_defers_worker_refresh_while_png_encode_is_inflight() { - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + let (mut session, original_worker_debug_id) = tests::configured_session_with_macos_worker(); session.png_encode_inflight = true; @@ -140,15 +148,15 @@ fn apply_self_capture_exception_window_ids_to_active_streams_defers_worker_refre #[cfg(target_os = "macos")] #[test] fn captured_freeze_response_applies_deferred_worker_refresh() { - let monitor = test_monitor(); - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + let monitor = tests::test_monitor(); + let (mut session, original_worker_debug_id) = tests::configured_session_with_macos_worker(); session.inflight_freeze_capture = Some(monitor); session.pending_self_capture_exception_window_ids_worker_refresh = true; let control = session.maybe_tick_worker_response_limiter(WorkerResponse::CapturedFreeze { monitor, - image: test_frozen_image(), + image: tests::test_frozen_image(), window_image: None, captured_window_id: None, }); @@ -161,8 +169,8 @@ fn captured_freeze_response_applies_deferred_worker_refresh() { #[cfg(target_os = "macos")] #[test] fn hit_test_response_applies_deferred_worker_refresh() { - let monitor = test_monitor(); - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + let monitor = tests::test_monitor(); + let (mut session, original_worker_debug_id) = tests::configured_session_with_macos_worker(); session.pending_click_hit_test_request_id = Some(11); session.pending_self_capture_exception_window_ids_worker_refresh = true; @@ -182,7 +190,7 @@ fn hit_test_response_applies_deferred_worker_refresh() { #[cfg(target_os = "macos")] #[test] fn window_list_refresh_response_applies_deferred_worker_refresh() { - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + let (mut session, original_worker_debug_id) = tests::configured_session_with_macos_worker(); session.window_list_refresh_inflight = true; session.pending_self_capture_exception_window_ids_worker_refresh = true; @@ -208,7 +216,7 @@ fn window_list_refresh_response_applies_deferred_worker_refresh() { #[cfg(target_os = "macos")] #[test] fn stale_window_list_refresh_response_is_dropped_after_self_capture_filter_change() { - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + let (mut session, original_worker_debug_id) = tests::configured_session_with_macos_worker(); session.window_list_snapshot = Some(Arc::new(WindowListSnapshot { captured_at: Instant::now(), @@ -245,7 +253,7 @@ fn stale_window_list_refresh_response_is_dropped_after_self_capture_filter_chang #[cfg(target_os = "macos")] #[test] fn png_error_response_applies_deferred_worker_refresh() { - let (mut session, original_worker_debug_id) = configured_session_with_macos_worker(); + let (mut session, original_worker_debug_id) = tests::configured_session_with_macos_worker(); session.png_encode_inflight = true; session.pending_self_capture_exception_window_ids_worker_refresh = true; diff --git a/packages/rsnap-overlay/src/overlay/tests/stream_refresh_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/stream_refresh_runtime.rs index 62c7f153..6bd74e27 100644 --- a/packages/rsnap-overlay/src/overlay/tests/stream_refresh_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/tests/stream_refresh_runtime.rs @@ -1,7 +1,16 @@ -#![allow(clippy::wildcard_imports)] - #[cfg(target_os = "macos")] -use super::*; +#[allow(unused_imports)] +use crate::overlay::tests::{ + self, Arc, MacLiveFrameStream, OverlayControl, + SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW, SCROLL_CAPTURE_INPUT_FRESHNESS, + ScrollCaptureLiveFrame, +}; +#[cfg(target_os = "macos")] +#[allow(unused_imports)] +use crate::overlay::tests::{ + Duration, GlobalPoint, Instant, MonitorRect, OverlaySession, RectPoints, ScrollDirection, + ScrollSession, overlay, +}; #[cfg(target_os = "macos")] #[test] @@ -49,7 +58,7 @@ fn handle_scroll_input_ready_drains_input_and_polls_stream_fallback() { #[cfg(target_os = "macos")] #[test] fn drain_external_scroll_input_worker_path_does_not_arm_live_stream_stale_grace() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let rect = RectPoints::new(100, 120, 512, 640); let through = Instant::now(); let recorded_at = through - Duration::from_millis(1); @@ -61,7 +70,7 @@ fn drain_external_scroll_input_worker_path_does_not_arm_live_stream_stale_grace( session.scroll_capture.capture_rect_pixels = Some(rect); session.scroll_capture.live_stream = Some(MacLiveFrameStream::new()); - enable_test_worker_scroll_capture_path(&mut session); + tests::enable_test_worker_scroll_capture_path(&mut session); session.set_external_scroll_input_drain_reader(Arc::new({ let events = Arc::clone(&events); @@ -244,8 +253,9 @@ fn consuming_live_frame_backlog_arms_time_gap_burst_after_draining_fresh_input() session.scroll_capture.active = true; session.scroll_capture.monitor = Some(monitor); session.scroll_capture.capture_rect_pixels = Some(rect); - session.scroll_capture.session = - Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); + session.scroll_capture.session = Some( + ScrollSession::new(tests::make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap(), + ); session.scroll_capture.last_consumed_stream_frame_captured_at = Some( captured_at - SCROLL_CAPTURE_ACTIVE_GESTURE_STALE_REFRESH_DEAD_WINDOW @@ -266,7 +276,7 @@ fn consuming_live_frame_backlog_arms_time_gap_burst_after_draining_fresh_input() session.test_push_scroll_capture_live_frame(ScrollCaptureLiveFrame { frame_seq: 9, captured_at, - image: make_scroll_capture_window(&document, 3, 0, 5), + image: tests::make_scroll_capture_window(&document, 3, 0, 5), }); session.test_consume_scroll_capture_backlog(1); diff --git a/packages/rsnap-overlay/src/overlay/tests/worker_observation_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/worker_observation_runtime.rs index 6397c524..5db6d039 100644 --- a/packages/rsnap-overlay/src/overlay/tests/worker_observation_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/tests/worker_observation_runtime.rs @@ -1,7 +1,12 @@ -#![allow(clippy::wildcard_imports)] - #[cfg(target_os = "macos")] -use super::*; +#[allow(unused_imports)] +use crate::overlay::tests::{ + self, Duration, GlobalPoint, Instant, MonitorRect, OverlaySession, RectPoints, ScrollDirection, + ScrollObserveOutcome, ScrollSession, overlay, +}; +#[cfg(target_os = "macos")] +#[allow(unused_imports)] +use crate::overlay::tests::{InflightScrollCaptureObservation, SCROLL_CAPTURE_INPUT_FRESHNESS}; #[cfg(target_os = "macos")] #[test] @@ -29,22 +34,23 @@ fn stale_latched_worker_input_fails_closed_without_appending_growth() { session.scroll_capture.active = true; session.scroll_capture.monitor = Some(monitor); session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.session = - Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); + session.scroll_capture.session = Some( + ScrollSession::new(tests::make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap(), + ); session.scroll_capture.input_direction = Some(ScrollDirection::Down); session.scroll_capture.input_direction_at = Some(Instant::now()); session.scroll_capture.input_gesture_active = true; assert_eq!( session - .observe_scroll_capture_frame(make_scroll_capture_window(&document, 3, 1, 5)) + .observe_scroll_capture_frame(tests::make_scroll_capture_window(&document, 3, 1, 5)) .transpose() .unwrap(), Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) ); assert_eq!( session - .observe_scroll_capture_frame(make_scroll_capture_window(&document, 3, 2, 5)) + .observe_scroll_capture_frame(tests::make_scroll_capture_window(&document, 3, 2, 5)) .transpose() .unwrap(), Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) @@ -69,7 +75,7 @@ fn stale_latched_worker_input_fails_closed_without_appending_growth() { monitor, capture_rect, 41, - make_scroll_capture_window(&document, 3, 1, 5), + tests::make_scroll_capture_window(&document, 3, 1, 5), ); assert_eq!(session.scroll_capture.inflight_request_id, None); @@ -96,8 +102,8 @@ fn newer_same_direction_input_keeps_latched_worker_observation_context() { scale_factor_x1000: 1_000, }; let capture_rect = RectPoints::new(100, 120, 200, 240); - let base = make_sparse_worker_capture_window(512, 640, 0); - let next = make_sparse_worker_capture_window(512, 640, 90); + let base = tests::make_sparse_worker_capture_window(512, 640, 0); + let next = tests::make_sparse_worker_capture_window(512, 640, 90); let mut session = OverlaySession::new(); session.scroll_capture.active = true; @@ -136,10 +142,10 @@ fn newer_same_direction_input_keeps_latched_worker_observation_context() { #[cfg(target_os = "macos")] #[test] fn stale_same_direction_worker_frame_keeps_latched_worker_observation_context() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let capture_rect = RectPoints::new(100, 120, 512, 640); - let base = make_sparse_worker_capture_window(512, 640, 0); - let next = make_sparse_worker_capture_window(512, 640, 90); + let base = tests::make_sparse_worker_capture_window(512, 640, 0); + let next = tests::make_sparse_worker_capture_window(512, 640, 90); let mut session = OverlaySession::new(); session.scroll_capture.active = true; @@ -169,10 +175,10 @@ fn stale_same_direction_worker_frame_keeps_latched_worker_observation_context() #[cfg(target_os = "macos")] #[test] fn worker_frame_without_fresh_or_latched_input_fails_closed_without_appending_growth() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let capture_rect = RectPoints::new(100, 120, 512, 640); - let base = make_sparse_worker_capture_window(512, 640, 0); - let next = make_sparse_worker_capture_window(512, 640, 90); + let base = tests::make_sparse_worker_capture_window(512, 640, 0); + let next = tests::make_sparse_worker_capture_window(512, 640, 90); let mut session = OverlaySession::new(); session.scroll_capture.active = true; @@ -231,22 +237,23 @@ fn newer_opposite_direction_supersedes_latched_worker_observation_context() { session.scroll_capture.active = true; session.scroll_capture.monitor = Some(monitor); session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.session = - Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); + session.scroll_capture.session = Some( + ScrollSession::new(tests::make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap(), + ); session.scroll_capture.input_direction = Some(ScrollDirection::Down); session.scroll_capture.input_direction_at = Some(Instant::now()); session.scroll_capture.input_gesture_active = true; assert_eq!( session - .observe_scroll_capture_frame(make_scroll_capture_window(&document, 3, 1, 5)) + .observe_scroll_capture_frame(tests::make_scroll_capture_window(&document, 3, 1, 5)) .transpose() .unwrap(), Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) ); assert_eq!( session - .observe_scroll_capture_frame(make_scroll_capture_window(&document, 3, 2, 5)) + .observe_scroll_capture_frame(tests::make_scroll_capture_window(&document, 3, 2, 5)) .transpose() .unwrap(), Some(ScrollObserveOutcome::Committed { direction: ScrollDirection::Down, growth_rows: 1 }) @@ -270,7 +277,7 @@ fn newer_opposite_direction_supersedes_latched_worker_observation_context() { monitor, capture_rect, 41, - make_scroll_capture_window(&document, 3, 3, 5), + tests::make_scroll_capture_window(&document, 3, 3, 5), ); assert_eq!(session.scroll_capture.inflight_request_id, None); @@ -298,8 +305,9 @@ fn successive_same_direction_worker_frames_do_not_stall_after_newer_input() { session.scroll_capture.active = true; session.scroll_capture.monitor = Some(monitor); session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.session = - Some(ScrollSession::new(make_sparse_worker_capture_window(512, 640, 0), 320).unwrap()); + session.scroll_capture.session = Some( + ScrollSession::new(tests::make_sparse_worker_capture_window(512, 640, 0), 320).unwrap(), + ); for (step, start_row) in [90_u32, 180, 270].into_iter().enumerate() { session.scroll_capture.input_direction = Some(ScrollDirection::Down); @@ -318,7 +326,7 @@ fn successive_same_direction_worker_frames_do_not_stall_after_newer_input() { monitor, capture_rect, 41 + step as u64, - make_sparse_worker_capture_window(512, 640, start_row), + tests::make_sparse_worker_capture_window(512, 640, start_row), ); assert_eq!(session.scroll_capture.inflight_request_id, None); @@ -351,7 +359,8 @@ fn successive_browser_like_worker_frames_do_not_stall_after_newer_input() { session.scroll_capture.monitor = Some(monitor); session.scroll_capture.capture_rect_pixels = Some(capture_rect); session.scroll_capture.session = Some( - ScrollSession::new(make_browser_like_worker_capture_window(512, 640, 0), 320).unwrap(), + ScrollSession::new(tests::make_browser_like_worker_capture_window(512, 640, 0), 320) + .unwrap(), ); for (step, start_row) in [84_u32, 168, 252].into_iter().enumerate() { @@ -371,7 +380,7 @@ fn successive_browser_like_worker_frames_do_not_stall_after_newer_input() { monitor, capture_rect, 81 + step as u64, - make_browser_like_worker_capture_window(512, 640, start_row), + tests::make_browser_like_worker_capture_window(512, 640, start_row), ); assert_eq!(session.scroll_capture.inflight_request_id, None); @@ -413,8 +422,9 @@ fn missing_worker_scroll_frame_clears_inflight_without_mutating_session() { session.scroll_capture.active = true; session.scroll_capture.monitor = Some(monitor); session.scroll_capture.capture_rect_pixels = Some(capture_rect); - session.scroll_capture.session = - Some(ScrollSession::new(make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap()); + session.scroll_capture.session = Some( + ScrollSession::new(tests::make_scroll_capture_window(&document, 3, 0, 5), 320).unwrap(), + ); session.scroll_capture.input_direction = Some(ScrollDirection::Down); session.scroll_capture.input_direction_at = Some(Instant::now()); session.scroll_capture.input_gesture_active = true; diff --git a/packages/rsnap-overlay/src/overlay/tests/worker_tick_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/worker_tick_runtime.rs index 5944e379..014ac6d8 100644 --- a/packages/rsnap-overlay/src/overlay/tests/worker_tick_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/tests/worker_tick_runtime.rs @@ -1,6 +1,11 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; +#[allow(unused_imports)] +use crate::overlay::tests::{ + self, Duration, GlobalPoint, Instant, MonitorRect, OverlaySession, RectPoints, + SCROLL_CAPTURE_SAMPLE_INTERVAL, ScrollDirection, ScrollObserveOutcome, ScrollSession, overlay, +}; +#[cfg(target_os = "macos")] +#[allow(unused_imports)] +use crate::overlay::tests::{Arc, MacLiveFrameStream, OverlayWorker, SequenceScrollCaptureBackend}; #[cfg(target_os = "macos")] #[test] @@ -96,8 +101,8 @@ fn maybe_tick_scroll_capture_does_not_synthesize_preview_growth_from_input_witho let tick_at = Instant::now(); let event_at = tick_at - Duration::from_millis(1); let events = Arc::new([(1, event_at, 150.0, 160.0, -4.0, true, false)]); - let base_frame = make_scroll_capture_window(&document, 3, 0, 5); - let latest_frame = make_scroll_capture_window(&document, 3, 1, 5); + let base_frame = tests::make_scroll_capture_window(&document, 3, 0, 5); + let latest_frame = tests::make_scroll_capture_window(&document, 3, 1, 5); let scroll_session = ScrollSession::new(base_frame.clone(), 320).unwrap(); let committed_preview = scroll_session.preview_image().clone(); let mut session = OverlaySession::new(); @@ -125,7 +130,7 @@ fn maybe_tick_scroll_capture_does_not_synthesize_preview_growth_from_input_witho session.maybe_tick_scroll_capture(); assert_eq!(session.scroll_capture.preview_display_image.as_ref(), Some(&committed_preview)); - assert_eq!(scroll_capture_export_height(&session), base_frame.height()); + assert_eq!(tests::scroll_capture_export_height(&session), base_frame.height()); } #[cfg(target_os = "macos")] @@ -150,8 +155,8 @@ fn maybe_tick_scroll_capture_does_not_double_count_preview_growth_from_same_late let tick_at = Instant::now(); let event_at = tick_at - Duration::from_millis(1); let events = Arc::new([(1, event_at, 150.0, 160.0, -4.0, true, false)]); - let base_frame = make_scroll_capture_window(&document, 3, 0, 5); - let moved_frame = make_scroll_capture_window(&document, 3, 1, 5); + let base_frame = tests::make_scroll_capture_window(&document, 3, 0, 5); + let moved_frame = tests::make_scroll_capture_window(&document, 3, 1, 5); let mut session = OverlaySession::new(); let mut scroll_session = ScrollSession::new(base_frame, 320).unwrap(); @@ -185,7 +190,7 @@ fn maybe_tick_scroll_capture_does_not_double_count_preview_growth_from_same_late session.maybe_tick_scroll_capture(); assert_eq!(session.scroll_capture.preview_display_image.as_ref(), Some(&committed_preview)); - assert_eq!(scroll_capture_export_height(&session), committed_preview.height()); + assert_eq!(tests::scroll_capture_export_height(&session), committed_preview.height()); } #[cfg(target_os = "macos")] @@ -199,9 +204,9 @@ fn maybe_tick_scroll_capture_worker_path_recovers_after_blocked_overshot_frame() scale_factor_x1000: 1_000, }; let rect = RectPoints::new(100, 120, 512, 640); - let base = make_browser_like_worker_capture_window(512, 640, 0); - let blocked = make_browser_like_worker_capture_window(512, 640, 760); - let followup = make_browser_like_worker_capture_window(512, 640, 844); + let base = tests::make_browser_like_worker_capture_window(512, 640, 0); + let blocked = tests::make_browser_like_worker_capture_window(512, 640, 760); + let followup = tests::make_browser_like_worker_capture_window(512, 640, 844); let mut session = OverlaySession::new(); session.worker = Some(OverlayWorker::new( @@ -213,8 +218,8 @@ fn maybe_tick_scroll_capture_worker_path_recovers_after_blocked_overshot_frame() session.scroll_capture.capture_rect_pixels = Some(rect); session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); - enable_test_worker_scroll_capture_path(&mut session); - set_scroll_capture_input(&mut session, ScrollDirection::Down); + tests::enable_test_worker_scroll_capture_path(&mut session); + tests::set_scroll_capture_input(&mut session, ScrollDirection::Down); session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); @@ -222,12 +227,12 @@ fn maybe_tick_scroll_capture_worker_path_recovers_after_blocked_overshot_frame() assert!(session.scroll_capture.inflight_request_id.is_some()); - drain_scroll_capture_worker_until_idle(&mut session); + tests::drain_scroll_capture_worker_until_idle(&mut session); - assert_eq!(scroll_capture_export_height(&session), 640); + assert_eq!(tests::scroll_capture_export_height(&session), 640); assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 0); - set_scroll_capture_input(&mut session, ScrollDirection::Down); + tests::set_scroll_capture_input(&mut session, ScrollDirection::Down); session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); @@ -235,9 +240,9 @@ fn maybe_tick_scroll_capture_worker_path_recovers_after_blocked_overshot_frame() assert!(session.scroll_capture.inflight_request_id.is_some()); - drain_scroll_capture_worker_until_idle(&mut session); + tests::drain_scroll_capture_worker_until_idle(&mut session); - assert_eq!(scroll_capture_export_height(&session), 724); + assert_eq!(tests::scroll_capture_export_height(&session), 724); assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 84); } @@ -245,11 +250,11 @@ fn maybe_tick_scroll_capture_worker_path_recovers_after_blocked_overshot_frame() #[test] fn maybe_tick_scroll_capture_worker_path_retries_immediately_after_blocked_overshot_frame_during_fresh_downward_input() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let rect = RectPoints::new(100, 120, 512, 640); - let base = make_browser_like_worker_capture_window(512, 640, 0); - let blocked = make_browser_like_worker_capture_window(512, 640, 760); - let followup = make_browser_like_worker_capture_window(512, 640, 844); + let base = tests::make_browser_like_worker_capture_window(512, 640, 0); + let blocked = tests::make_browser_like_worker_capture_window(512, 640, 760); + let followup = tests::make_browser_like_worker_capture_window(512, 640, 844); let mut session = OverlaySession::new(); session.worker = Some(OverlayWorker::new( @@ -261,8 +266,8 @@ fn maybe_tick_scroll_capture_worker_path_retries_immediately_after_blocked_overs session.scroll_capture.capture_rect_pixels = Some(rect); session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); - enable_test_worker_scroll_capture_path(&mut session); - set_scroll_capture_input(&mut session, ScrollDirection::Down); + tests::enable_test_worker_scroll_capture_path(&mut session); + tests::set_scroll_capture_input(&mut session, ScrollDirection::Down); session.scroll_capture.last_external_scroll_input_seq = 1; session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); @@ -271,9 +276,9 @@ fn maybe_tick_scroll_capture_worker_path_retries_immediately_after_blocked_overs assert!(session.scroll_capture.inflight_request_id.is_some()); - drain_scroll_capture_worker_until_idle(&mut session); + tests::drain_scroll_capture_worker_until_idle(&mut session); - assert_eq!(scroll_capture_export_height(&session), 640); + assert_eq!(tests::scroll_capture_export_height(&session), 640); session.scroll_capture.last_external_scroll_input_seq = 2; session.scroll_capture.input_direction = Some(ScrollDirection::Down); @@ -287,28 +292,28 @@ fn maybe_tick_scroll_capture_worker_path_retries_immediately_after_blocked_overs "fresh downward input after a blocked worker frame should retry immediately" ); - drain_scroll_capture_worker_until_idle(&mut session); + tests::drain_scroll_capture_worker_until_idle(&mut session); assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 84); - assert_eq!(scroll_capture_export_height(&session), 724); + assert_eq!(tests::scroll_capture_export_height(&session), 724); } #[cfg(target_os = "macos")] #[test] fn maybe_tick_scroll_capture_worker_path_recovers_across_interleaved_no_frame_and_blocked_browser_steps() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let rect = RectPoints::new(100, 120, 512, 640); let mut session = OverlaySession::new(); session.worker = Some(OverlayWorker::new( Box::new(SequenceScrollCaptureBackend::new([ None, - Some(make_browser_like_worker_capture_window(512, 640, 84)), - Some(make_browser_like_worker_capture_window(512, 640, 700)), - Some(make_browser_like_worker_capture_window(512, 640, 784)), + Some(tests::make_browser_like_worker_capture_window(512, 640, 84)), + Some(tests::make_browser_like_worker_capture_window(512, 640, 700)), + Some(tests::make_browser_like_worker_capture_window(512, 640, 784)), None, - Some(make_browser_like_worker_capture_window(512, 640, 868)), + Some(tests::make_browser_like_worker_capture_window(512, 640, 868)), ])), None, )); @@ -316,10 +321,11 @@ fn maybe_tick_scroll_capture_worker_path_recovers_across_interleaved_no_frame_an session.scroll_capture.monitor = Some(monitor); session.scroll_capture.capture_rect_pixels = Some(rect); session.scroll_capture.session = Some( - ScrollSession::new(make_browser_like_worker_capture_window(512, 640, 0), 320).unwrap(), + ScrollSession::new(tests::make_browser_like_worker_capture_window(512, 640, 0), 320) + .unwrap(), ); - enable_test_worker_scroll_capture_path(&mut session); + tests::enable_test_worker_scroll_capture_path(&mut session); for expected_top_y in [84_i32, 168, 252] { let mut attempts = 0_u8; @@ -334,7 +340,7 @@ fn maybe_tick_scroll_capture_worker_path_recovers_across_interleaved_no_frame_an "worker path failed to recover to expected_top_y={expected_top_y}" ); - set_scroll_capture_input(&mut session, ScrollDirection::Down); + tests::set_scroll_capture_input(&mut session, ScrollDirection::Down); session.scroll_capture.last_external_scroll_input_seq = session.scroll_capture.last_external_scroll_input_seq.saturating_add(1); @@ -350,24 +356,24 @@ fn maybe_tick_scroll_capture_worker_path_recovers_across_interleaved_no_frame_an session.scroll_capture.input_direction_at = Some(Instant::now()); session.scroll_capture.input_gesture_active = true; - drain_scroll_capture_worker_until_idle(&mut session); + tests::drain_scroll_capture_worker_until_idle(&mut session); } assert_eq!( session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), expected_top_y ); - assert_eq!(scroll_capture_export_height(&session), 640 + expected_top_y as u32); + assert_eq!(tests::scroll_capture_export_height(&session), 640 + expected_top_y as u32); } } #[cfg(target_os = "macos")] #[test] fn maybe_tick_scroll_capture_worker_path_keeps_same_direction_superseded_response() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let rect = RectPoints::new(100, 120, 512, 640); - let base = make_sparse_worker_capture_window(512, 640, 0); - let moved = make_sparse_worker_capture_window(512, 640, 180); + let base = tests::make_sparse_worker_capture_window(512, 640, 0); + let moved = tests::make_sparse_worker_capture_window(512, 640, 180); let mut session = OverlaySession::new(); session.worker = @@ -377,8 +383,8 @@ fn maybe_tick_scroll_capture_worker_path_keeps_same_direction_superseded_respons session.scroll_capture.capture_rect_pixels = Some(rect); session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); - enable_test_worker_scroll_capture_path(&mut session); - set_scroll_capture_input(&mut session, ScrollDirection::Down); + tests::enable_test_worker_scroll_capture_path(&mut session); + tests::set_scroll_capture_input(&mut session, ScrollDirection::Down); session.scroll_capture.last_external_scroll_input_seq = 1; session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); @@ -390,9 +396,9 @@ fn maybe_tick_scroll_capture_worker_path_keeps_same_direction_superseded_respons session.scroll_capture.last_external_scroll_input_seq = 2; session.scroll_capture.input_direction = Some(ScrollDirection::Down); - drain_scroll_capture_worker_until_idle(&mut session); + tests::drain_scroll_capture_worker_until_idle(&mut session); - assert_eq!(scroll_capture_export_height(&session), 820); + assert_eq!(tests::scroll_capture_export_height(&session), 820); assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 180); } @@ -400,15 +406,15 @@ fn maybe_tick_scroll_capture_worker_path_keeps_same_direction_superseded_respons #[test] fn maybe_tick_scroll_capture_worker_path_commits_successive_browser_like_frames_after_newer_same_direction_input() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let rect = RectPoints::new(100, 120, 512, 640); let mut session = OverlaySession::new(); session.worker = Some(OverlayWorker::new( Box::new(SequenceScrollCaptureBackend::new([ - Some(make_browser_like_worker_capture_window(512, 640, 84)), - Some(make_browser_like_worker_capture_window(512, 640, 168)), - Some(make_browser_like_worker_capture_window(512, 640, 252)), + Some(tests::make_browser_like_worker_capture_window(512, 640, 84)), + Some(tests::make_browser_like_worker_capture_window(512, 640, 168)), + Some(tests::make_browser_like_worker_capture_window(512, 640, 252)), ])), None, )); @@ -416,13 +422,14 @@ fn maybe_tick_scroll_capture_worker_path_commits_successive_browser_like_frames_ session.scroll_capture.monitor = Some(monitor); session.scroll_capture.capture_rect_pixels = Some(rect); session.scroll_capture.session = Some( - ScrollSession::new(make_browser_like_worker_capture_window(512, 640, 0), 320).unwrap(), + ScrollSession::new(tests::make_browser_like_worker_capture_window(512, 640, 0), 320) + .unwrap(), ); - enable_test_worker_scroll_capture_path(&mut session); + tests::enable_test_worker_scroll_capture_path(&mut session); for (step, expected_top_y) in [84_i32, 168, 252].into_iter().enumerate() { - set_scroll_capture_input(&mut session, ScrollDirection::Down); + tests::set_scroll_capture_input(&mut session, ScrollDirection::Down); session.scroll_capture.last_external_scroll_input_seq = (step as u64) + 1; session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); @@ -434,7 +441,7 @@ fn maybe_tick_scroll_capture_worker_path_commits_successive_browser_like_frames_ session.scroll_capture.last_external_scroll_input_seq = (step as u64) + 2; session.scroll_capture.input_direction = Some(ScrollDirection::Down); - drain_scroll_capture_worker_until_idle(&mut session); + tests::drain_scroll_capture_worker_until_idle(&mut session); assert_eq!(session.scroll_capture.inflight_request_id, None); assert_eq!( @@ -451,10 +458,10 @@ fn maybe_tick_scroll_capture_worker_path_commits_successive_browser_like_frames_ #[cfg(target_os = "macos")] #[test] fn maybe_tick_scroll_capture_worker_path_drops_opposite_direction_superseded_response() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let rect = RectPoints::new(100, 120, 512, 640); - let base = make_sparse_worker_capture_window(512, 640, 0); - let moved = make_sparse_worker_capture_window(512, 640, 180); + let base = tests::make_sparse_worker_capture_window(512, 640, 0); + let moved = tests::make_sparse_worker_capture_window(512, 640, 180); let mut session = OverlaySession::new(); session.worker = @@ -464,8 +471,8 @@ fn maybe_tick_scroll_capture_worker_path_drops_opposite_direction_superseded_res session.scroll_capture.capture_rect_pixels = Some(rect); session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); - enable_test_worker_scroll_capture_path(&mut session); - set_scroll_capture_input(&mut session, ScrollDirection::Down); + tests::enable_test_worker_scroll_capture_path(&mut session); + tests::set_scroll_capture_input(&mut session, ScrollDirection::Down); session.scroll_capture.last_external_scroll_input_seq = 1; session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); @@ -477,9 +484,9 @@ fn maybe_tick_scroll_capture_worker_path_drops_opposite_direction_superseded_res session.scroll_capture.last_external_scroll_input_seq = 2; session.scroll_capture.input_direction = Some(ScrollDirection::Up); - drain_scroll_capture_worker_until_idle(&mut session); + tests::drain_scroll_capture_worker_until_idle(&mut session); - assert_eq!(scroll_capture_export_height(&session), 640); + assert_eq!(tests::scroll_capture_export_height(&session), 640); assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 0); } @@ -487,10 +494,10 @@ fn maybe_tick_scroll_capture_worker_path_drops_opposite_direction_superseded_res #[test] fn maybe_tick_scroll_capture_worker_path_retries_immediately_after_no_new_frame_during_fresh_downward_input() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let rect = RectPoints::new(100, 120, 512, 640); - let base = make_browser_like_worker_capture_window(512, 640, 0); - let moved = make_browser_like_worker_capture_window(512, 640, 84); + let base = tests::make_browser_like_worker_capture_window(512, 640, 0); + let moved = tests::make_browser_like_worker_capture_window(512, 640, 84); let mut session = OverlaySession::new(); session.worker = Some(OverlayWorker::new( @@ -502,8 +509,8 @@ fn maybe_tick_scroll_capture_worker_path_retries_immediately_after_no_new_frame_ session.scroll_capture.capture_rect_pixels = Some(rect); session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); - enable_test_worker_scroll_capture_path(&mut session); - set_scroll_capture_input(&mut session, ScrollDirection::Down); + tests::enable_test_worker_scroll_capture_path(&mut session); + tests::set_scroll_capture_input(&mut session, ScrollDirection::Down); session.scroll_capture.last_external_scroll_input_seq = 1; session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); @@ -512,10 +519,10 @@ fn maybe_tick_scroll_capture_worker_path_retries_immediately_after_no_new_frame_ assert!(session.scroll_capture.inflight_request_id.is_some()); - drain_scroll_capture_worker_until_idle(&mut session); + tests::drain_scroll_capture_worker_until_idle(&mut session); assert_eq!(session.scroll_capture.inflight_request_id, None); - assert_eq!(scroll_capture_export_height(&session), 640); + assert_eq!(tests::scroll_capture_export_height(&session), 640); session.scroll_capture.last_external_scroll_input_seq = 2; session.scroll_capture.input_direction = Some(ScrollDirection::Down); @@ -529,10 +536,10 @@ fn maybe_tick_scroll_capture_worker_path_retries_immediately_after_no_new_frame_ "fresh downward input after a worker no-frame response should retry immediately" ); - drain_scroll_capture_worker_until_idle(&mut session); + tests::drain_scroll_capture_worker_until_idle(&mut session); assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 84); - assert_eq!(scroll_capture_export_height(&session), 724); + assert_eq!(tests::scroll_capture_export_height(&session), 724); } #[test] @@ -546,11 +553,11 @@ fn scroll_capture_sample_interval_matches_platform_worker_sampling_strategy() { #[cfg(target_os = "macos")] #[test] fn maybe_tick_scroll_capture_worker_path_backs_off_after_duplicate_committed_frame() { - let monitor = test_monitor(); + let monitor = tests::test_monitor(); let rect = RectPoints::new(100, 120, 512, 640); - let base = make_browser_like_worker_capture_window(512, 640, 0); - let step_one = make_browser_like_worker_capture_window(512, 640, 84); - let step_two = make_browser_like_worker_capture_window(512, 640, 168); + let base = tests::make_browser_like_worker_capture_window(512, 640, 0); + let step_one = tests::make_browser_like_worker_capture_window(512, 640, 84); + let step_two = tests::make_browser_like_worker_capture_window(512, 640, 168); let mut session = OverlaySession::new(); session.worker = Some(OverlayWorker::new( @@ -566,8 +573,8 @@ fn maybe_tick_scroll_capture_worker_path_backs_off_after_duplicate_committed_fra session.scroll_capture.capture_rect_pixels = Some(rect); session.scroll_capture.session = Some(ScrollSession::new(base, 320).unwrap()); - enable_test_worker_scroll_capture_path(&mut session); - set_scroll_capture_input(&mut session, ScrollDirection::Down); + tests::enable_test_worker_scroll_capture_path(&mut session); + tests::set_scroll_capture_input(&mut session, ScrollDirection::Down); session.scroll_capture.last_external_scroll_input_seq = 1; session.scroll_capture.next_sample_at = Some(Instant::now() - Duration::from_millis(1)); @@ -576,10 +583,10 @@ fn maybe_tick_scroll_capture_worker_path_backs_off_after_duplicate_committed_fra assert!(session.scroll_capture.inflight_request_id.is_some()); - drain_scroll_capture_worker_until_idle(&mut session); + tests::drain_scroll_capture_worker_until_idle(&mut session); assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 84); - assert_eq!(scroll_capture_export_height(&session), 724); + assert_eq!(tests::scroll_capture_export_height(&session), 724); session.scroll_capture.last_external_scroll_input_seq = 2; session.scroll_capture.input_direction = Some(ScrollDirection::Down); @@ -591,11 +598,11 @@ fn maybe_tick_scroll_capture_worker_path_backs_off_after_duplicate_committed_fra assert!(session.scroll_capture.inflight_request_id.is_some()); - drain_scroll_capture_worker_until_idle(&mut session); + tests::drain_scroll_capture_worker_until_idle(&mut session); assert_eq!(session.scroll_capture.inflight_request_id, None); assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 84); - assert_eq!(scroll_capture_export_height(&session), 724); + assert_eq!(tests::scroll_capture_export_height(&session), 724); session.maybe_tick_scroll_capture(); @@ -614,8 +621,8 @@ fn maybe_tick_scroll_capture_worker_path_backs_off_after_duplicate_committed_fra assert!(session.scroll_capture.inflight_request_id.is_some()); - drain_scroll_capture_worker_until_idle(&mut session); + tests::drain_scroll_capture_worker_until_idle(&mut session); assert_eq!(session.scroll_capture.session.as_ref().unwrap().current_viewport_top_y(), 168); - assert_eq!(scroll_capture_export_height(&session), 808); + assert_eq!(tests::scroll_capture_export_height(&session), 808); } diff --git a/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs b/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs index 95274406..0aae2f11 100644 --- a/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs @@ -1,6 +1,14 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; +#[cfg(target_os = "macos")] +#[allow(unused_imports)] +use crate::overlay::TOOLBAR_WINDOW_WARMUP_REDRAWS; +#[allow(unused_imports)] +use crate::overlay::{ + Arc, FrozenCaptureSource, FrozenToolbarPointerState, GlobalPoint, + HUD_PILL_CORNER_RADIUS_POINTS, HudAnchor, HudOverlayWindow, Instant, LogicalSize, MonitorRect, + OverlayControl, OverlayEventLoopPhase, OverlayExit, OverlayMode, OverlaySession, + PhysicalPosition, PhysicalSize, Pos2, Result, TOOLBAR_DRAG_START_THRESHOLD_PX, Vec2, WindowId, + scroll_capture, +}; impl OverlaySession { pub(super) fn handle_toolbar_cursor_moved( diff --git a/packages/rsnap-overlay/src/overlay/window_position_runtime.rs b/packages/rsnap-overlay/src/overlay/window_position_runtime.rs index 0a104985..506ecd2c 100644 --- a/packages/rsnap-overlay/src/overlay/window_position_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/window_position_runtime.rs @@ -1,6 +1,9 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; +#[allow(unused_imports)] +use crate::overlay::{ + GlobalPoint, HUD_LOUPE_STRIP_GAP_POINTS, Instant, LogicalPosition, MonitorRect, OverlayMode, + OverlaySession, Pos2, Rect, SLOW_OP_WARN_OUTER_POSITION, TOOLBAR_SCREEN_MARGIN_PX, Vec2, + WindowRenderer, +}; impl OverlaySession { pub(super) fn update_hud_window_position(&mut self, monitor: MonitorRect, cursor: GlobalPoint) { diff --git a/packages/rsnap-overlay/src/overlay/worker_runtime.rs b/packages/rsnap-overlay/src/overlay/worker_runtime.rs index d0443a92..0a1c6d1a 100644 --- a/packages/rsnap-overlay/src/overlay/worker_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/worker_runtime.rs @@ -1,6 +1,13 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; +#[allow(unused_imports)] +use crate::overlay::{ + Arc, CURSOR_POLL_INTERVAL_MIN, CapturedMonitorRegionResult, Duration, GlobalPoint, Instant, + LiveCursorSample, LiveSampleApplyResult, MonitorRect, MonitorRectPoints, OverlayControl, + OverlayMode, OverlaySession, WindowFreezeCaptureTarget, WindowHit, WindowListSnapshot, + WorkerErrorSource, WorkerRequestSendError, WorkerResponse, mem, +}; +#[cfg(target_os = "macos")] +#[allow(unused_imports)] +use crate::overlay::{CursorSampleRequest, PendingRecognizeTextRequest}; impl OverlaySession { pub(super) fn drain_worker_responses(&mut self) -> OverlayControl { diff --git a/packages/rsnap-overlay/src/scroll_capture.rs b/packages/rsnap-overlay/src/scroll_capture.rs index 19ab3e55..ee05c462 100644 --- a/packages/rsnap-overlay/src/scroll_capture.rs +++ b/packages/rsnap-overlay/src/scroll_capture.rs @@ -1,7 +1,12 @@ pub mod bench_support; + mod downward_resolution; mod support; +pub(crate) use self::support::{ + compose_provisional_preview_image, scroll_capture_fingerprint, scroll_capture_fingerprint_delta, +}; + use std::ops::RangeInclusive; use color_eyre::eyre::{self, Result}; @@ -9,22 +14,6 @@ use image::RgbaImage; #[cfg(test)] use self::support::detect_vertical_overlap; -use self::support::{ - append_vertical_image, best_local_downward_viewport_candidate, - classify_downward_registration_candidates, classify_vision_downward_sample_motion_against, - collect_overlap_direction_matches, collect_overlap_direction_matches_in_ranges, - crop_bottom_rows, downward_registration_has_meaningful_overlap, - estimate_pairwise_downward_shift_rows, evaluate_overlap_direction, evenly_spaced_sample, - format_downward_viewport_candidates, informative_column_span, max_directional_motion_rows, - preferred_upward_input_override_match, preferred_upward_override_match, preview_update_outcome, - resize_strip_to_preview_width, resume_direct_match_is_trustworthy, - rewind_active_upward_motion_should_fail_closed, rewind_active_upward_override_match, - select_downward_viewport_candidate, stack_vertical_images, - upward_confirmation_match_for_downward_input, -}; -pub(crate) use self::support::{ - compose_provisional_preview_image, scroll_capture_fingerprint, scroll_capture_fingerprint_delta, -}; pub(crate) const PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS: u32 = 24; pub(crate) const PREVIEW_ONLY_LOCAL_RECOVERY_MAX_TOLERANCE_ROWS: u32 = 12; @@ -74,7 +63,7 @@ impl ScrollFrameFingerprint { pub(crate) fn from_image(image: &RgbaImage) -> Self { let width = image.width().max(1); let height = image.height().max(1); - let informative_span = informative_column_span(image, 0, height); + let informative_span = self::support::informative_column_span(image, 0, height); let informative_left = informative_span.map_or(0, |span| span.start_x.min(width.saturating_sub(1))); let informative_right = informative_span @@ -91,10 +80,15 @@ impl ScrollFrameFingerprint { Vec::with_capacity((FINGERPRINT_GRID_COLUMNS * FINGERPRINT_GRID_ROWS) as usize); for row in 0..FINGERPRINT_GRID_ROWS { - let y = evenly_spaced_sample(top, bottom, row, FINGERPRINT_GRID_ROWS); + let y = self::support::evenly_spaced_sample(top, bottom, row, FINGERPRINT_GRID_ROWS); for column in 0..FINGERPRINT_GRID_COLUMNS { - let x = evenly_spaced_sample(left, right, column, FINGERPRINT_GRID_COLUMNS); + let x = self::support::evenly_spaced_sample( + left, + right, + column, + FINGERPRINT_GRID_COLUMNS, + ); let pixel = image.get_pixel(x, y).0; samples.push(pixel); @@ -260,7 +254,8 @@ pub(crate) struct ScrollSession { impl ScrollSession { pub(crate) fn new(base_frame: RgbaImage, preview_width_px: u32) -> Result { let fingerprint = scroll_capture_fingerprint(&base_frame); - let anchor_preview = resize_strip_to_preview_width(&base_frame, preview_width_px.max(1)); + let anchor_preview = + self::support::resize_strip_to_preview_width(&base_frame, preview_width_px.max(1)); Ok(Self { anchor_frame: base_frame.clone(), @@ -390,9 +385,10 @@ impl ScrollSession { return Ok(ScrollObserveOutcome::NoChange); } - let Some(matched) = - classify_vision_downward_sample_motion_against(&previous_worker_frame, &frame) - else { + let Some(matched) = self::support::classify_vision_downward_sample_motion_against( + &previous_worker_frame, + &frame, + ) else { self.update_worker_pairwise_reference_frame(frame, fingerprint); self.log_decision( "scroll_capture.worker_pairwise_no_change", @@ -406,7 +402,7 @@ impl ScrollSession { return Ok(ScrollObserveOutcome::NoChange); }; let corroborated_shift_rows = - estimate_pairwise_downward_shift_rows(&previous_worker_frame, &frame); + self::support::estimate_pairwise_downward_shift_rows(&previous_worker_frame, &frame); if matched.motion_rows >= WORKER_PAIRWISE_CORROBORATION_MIN_ROWS && corroborated_shift_rows.is_none_or(|estimated| { @@ -747,11 +743,12 @@ impl ScrollSession { self.effective_motion_rows_hint(), ); - if let Some(up_match) = upward_confirmation_match_for_downward_input( - committed_up_match, - committed_down_match, - self.current_viewport_top_y > 0, - ) { + if let Some(up_match) = + self::support::upward_confirmation_match_for_downward_input( + committed_up_match, + committed_down_match, + self.current_viewport_top_y > 0, + ) { return Ok(self.fail_closed_downward_non_monotonic_frame( preview_changed, self.last_sample_frame.clone(), @@ -821,10 +818,11 @@ impl ScrollSession { &diagnostics, )); } - if let Some((up_match, from_committed)) = preferred_upward_input_override_match( - diagnostics.sample_override_match, - diagnostics.committed_override_match, - ) { + if let Some((up_match, from_committed)) = + self::support::preferred_upward_input_override_match( + diagnostics.sample_override_match, + diagnostics.committed_override_match, + ) { let (op, block_reason) = if from_committed { ( "scroll_capture.rewind_armed_from_committed_match", @@ -870,7 +868,7 @@ impl ScrollSession { sample_motion: Option, diagnostics: &UpwardInputDiagnostics, ) -> Option { - if rewind_active_upward_motion_should_fail_closed( + if self::support::rewind_active_upward_motion_should_fail_closed( diagnostics.sample_override_match, diagnostics.committed_override_match, diagnostics.committed_down_match_eval.final_match, @@ -895,7 +893,7 @@ impl ScrollSession { )); } - rewind_active_upward_override_match( + self::support::rewind_active_upward_override_match( diagnostics.sample_override_match, diagnostics.committed_override_match, self.resume_frontier_top_y.is_some(), @@ -946,10 +944,11 @@ impl ScrollSession { return ScrollObserveOutcome::UnsupportedDirection { direction: ScrollDirection::Up }; } - if let Some((up_match, from_committed)) = preferred_upward_input_override_match( - diagnostics.sample_override_match, - diagnostics.committed_override_match, - ) { + if let Some((up_match, from_committed)) = + self::support::preferred_upward_input_override_match( + diagnostics.sample_override_match, + diagnostics.committed_override_match, + ) { let (op, block_reason) = if from_committed { ( "scroll_capture.rewind_armed_from_committed_match", @@ -1017,11 +1016,11 @@ impl ScrollSession { ); UpwardInputDiagnostics { - sample_override_match: preferred_upward_override_match( + sample_override_match: self::support::preferred_upward_override_match( sample_up_match_eval.final_match, sample_down_match_eval.final_match, ), - committed_override_match: preferred_upward_override_match( + committed_override_match: self::support::preferred_upward_override_match( committed_up_match_eval.final_match, committed_down_match_eval.final_match, ), @@ -1050,7 +1049,7 @@ impl ScrollSession { } let config = OverlapSearchConfig::default(); - let max_motion_rows = max_directional_motion_rows(previous, next, config); + let max_motion_rows = self::support::max_directional_motion_rows(previous, next, config); let fallback_range = Some(OverlapSearchRange { start: 1, end: max_motion_rows }); let fallback_eval = self.diagnose_reference_overlap_direction_with_preferred_range( previous, @@ -1360,7 +1359,7 @@ impl ScrollSession { Some(block_reason), ); - preview_update_outcome(preview_changed) + self::support::preview_update_outcome(preview_changed) } fn observe_upward_rewind(&mut self, motion_rows: u32) { @@ -1460,7 +1459,7 @@ impl ScrollSession { Some(preferred.competing_block_reason(competing)), ); - return Ok(preview_update_outcome(preview_changed)); + return Ok(self::support::preview_update_outcome(preview_changed)); }, }; @@ -1614,7 +1613,7 @@ impl ScrollSession { Some(block_reason), ); - Ok(preview_update_outcome(preview_changed)) + Ok(self::support::preview_update_outcome(preview_changed)) } fn block_downward_growth_candidate( @@ -1641,7 +1640,7 @@ impl ScrollSession { Some(block_reason), ); - Ok(preview_update_outcome(preview_changed)) + Ok(self::support::preview_update_outcome(preview_changed)) } fn should_fail_closed_tiny_observed_recovery_in_burst( @@ -1796,7 +1795,7 @@ impl ScrollSession { .observed_viewport_top_y .saturating_add(i32::try_from(motion_rows).unwrap_or_default()); let Some(resume_frontier_top_y) = self.resume_frontier_top_y else { - return Ok(preview_update_outcome(preview_changed)); + return Ok(self::support::preview_update_outcome(preview_changed)); }; let frame_reacquires_last_committed_viewport = self.frame_reacquires_last_committed_viewport(&frame); @@ -1858,7 +1857,7 @@ impl ScrollSession { Some("resume_active_candidate_reached_frontier_without_residual_growth"), ); - return Ok(preview_update_outcome(preview_changed)); + return Ok(self::support::preview_update_outcome(preview_changed)); } self.resolve_resume_frontier_direct_match( @@ -1895,7 +1894,7 @@ impl ScrollSession { Some("resume_active_reacquired_last_committed_frame"), ); - Some(preview_update_outcome(preview_changed)) + Some(self::support::preview_update_outcome(preview_changed)) } fn block_resume_frontier_before_growth( @@ -1918,7 +1917,7 @@ impl ScrollSession { Some("resume_active_frame_matches_last_committed_frame"), ); - return Some(preview_update_outcome(preview_changed)); + return Some(self::support::preview_update_outcome(preview_changed)); } if self.resume_frontier_requires_reacquire { return None; @@ -1935,7 +1934,7 @@ impl ScrollSession { Some("resume_active_candidate_observed_viewport_still_below_frontier"), ); - return Some(preview_update_outcome(preview_changed)); + return Some(self::support::preview_update_outcome(preview_changed)); } None @@ -1972,8 +1971,8 @@ impl ScrollSession { ScrollDirection::Down, direct_match_hint_rows, ); - let trusted_committed_down_match = - raw_committed_down_match.filter(|matched| resume_direct_match_is_trustworthy(*matched)); + let trusted_committed_down_match = raw_committed_down_match + .filter(|matched| self::support::resume_direct_match_is_trustworthy(*matched)); let committed_up_match = self.evaluate_reference_overlap_direction_preferred_only( &self.last_committed_frame, &frame, @@ -2070,7 +2069,7 @@ impl ScrollSession { Some(block_reason), ); - preview_update_outcome(preview_changed) + self::support::preview_update_outcome(preview_changed) } fn block_resume_frontier_without_direct_match( @@ -2223,7 +2222,7 @@ impl ScrollSession { Some("bootstrap_growth_exceeded_initial_growth_cap"), ); - return Ok(preview_update_outcome(preview_changed)); + return Ok(self::support::preview_update_outcome(preview_changed)); } if growth_rows == 0 { self.consecutive_transient_burst_missing_downward_candidate_frames = 0; @@ -2243,7 +2242,7 @@ impl ScrollSession { block_reason, ); - return Ok(preview_update_outcome(preview_changed)); + return Ok(self::support::preview_update_outcome(preview_changed)); } let max_growth_rows = self.max_downward_growth_rows_for_frame(&frame); @@ -2260,7 +2259,7 @@ impl ScrollSession { Some("candidate_viewport_growth_exceeded_monotonic_cap"), ); - return Ok(preview_update_outcome(preview_changed)); + return Ok(self::support::preview_update_outcome(preview_changed)); } self.log_decision( @@ -2690,7 +2689,7 @@ impl ScrollSession { next: &RgbaImage, config: OverlapSearchConfig, ) -> Option> { - let max_motion_rows = max_directional_motion_rows(previous, next, config); + let max_motion_rows = self::support::max_directional_motion_rows(previous, next, config); if max_motion_rows == 0 { return None; diff --git a/packages/rsnap-overlay/src/scroll_capture/bench_support.rs b/packages/rsnap-overlay/src/scroll_capture/bench_support.rs index c89ebf83..5d9c106e 100644 --- a/packages/rsnap-overlay/src/scroll_capture/bench_support.rs +++ b/packages/rsnap-overlay/src/scroll_capture/bench_support.rs @@ -2,9 +2,9 @@ use image::{Rgba, RgbaImage, imageops}; +use crate::scroll_capture::support::{self}; use crate::scroll_capture::{ - OverlapSearchConfig, ScrollDirection, ScrollObserveOutcome, ScrollSession, - evaluate_overlap_direction, max_directional_motion_rows, scroll_capture_fingerprint, + self, OverlapSearchConfig, ScrollDirection, ScrollObserveOutcome, ScrollSession, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -15,7 +15,6 @@ pub enum ScrollCaptureBenchScenario { /// Wider capture data with a larger viewport and scroll delta. Wide, } - impl ScrollCaptureBenchScenario { /// All supported benchmark scenarios in stable iteration order. pub const ALL: [Self; 2] = [Self::Baseline, Self::Wide]; @@ -89,7 +88,6 @@ pub struct ScrollCaptureBenchHarness { fixture: ScrollCaptureBenchFixture, overlap_config: OverlapSearchConfig, } - impl ScrollCaptureBenchHarness { #[must_use] /// Builds the benchmark harness for the selected fixture scenario. @@ -103,7 +101,7 @@ impl ScrollCaptureBenchHarness { #[must_use] /// Runs the fingerprint path and returns stable summary metrics. pub fn run_fingerprint(&self) -> ScrollCaptureFingerprintMetrics { - let bytes = scroll_capture_fingerprint(&self.fixture.fingerprint_frame); + let bytes = scroll_capture::scroll_capture_fingerprint(&self.fixture.fingerprint_frame); ScrollCaptureFingerprintMetrics { byte_len: bytes.len(), checksum: checksum_bytes(&bytes) } } @@ -111,12 +109,12 @@ impl ScrollCaptureBenchHarness { #[must_use] /// Runs the overlap matcher and returns the resulting comparison metrics. pub fn run_overlap_match(&self) -> ScrollCaptureOverlapMetrics { - let max_motion_rows = max_directional_motion_rows( + let max_motion_rows = support::max_directional_motion_rows( &self.fixture.base_frame, &self.fixture.next_frame, self.overlap_config, ); - let matched = evaluate_overlap_direction( + let matched = support::evaluate_overlap_direction( &self.fixture.base_frame, &self.fixture.next_frame, ScrollDirection::Down, @@ -181,7 +179,6 @@ struct ScrollCaptureBenchFixture { window_rows: u32, preview_width_px: u32, } - impl ScrollCaptureBenchFixture { fn new(spec: ScrollCaptureBenchFixtureSpec) -> Self { let document = build_document(spec.width, spec.document_rows); diff --git a/packages/rsnap-overlay/src/scroll_capture/downward_resolution.rs b/packages/rsnap-overlay/src/scroll_capture/downward_resolution.rs index 9bdec554..1458231f 100644 --- a/packages/rsnap-overlay/src/scroll_capture/downward_resolution.rs +++ b/packages/rsnap-overlay/src/scroll_capture/downward_resolution.rs @@ -1,6 +1,23 @@ -#![allow(clippy::wildcard_imports)] - -use super::*; +use crate::scroll_capture::support; +#[allow(unused_imports)] +use crate::scroll_capture::{ + BlockedPreviewOnlyLocalCandidate, CommittedDownwardViewportCandidateMode, + DIRECTION_WARNING_MARGIN_X100, DOWNWARD_COMMITTED_KEYFRAME_LOCAL_OVERRUN_MAX_ROWS, + DOWNWARD_KEYFRAME_MIN_OVERLAP_DIVISOR, DOWNWARD_KEYFRAME_SEARCH_LIMIT, + DOWNWARD_KEYFRAME_SEARCH_MAX_TOLERANCE_ROWS, DOWNWARD_KEYFRAME_SEARCH_MOTION_TOLERANCE_ROWS, + DOWNWARD_SEARCH_MOTION_TOLERANCE_ROWS, DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS, DirectionMatch, + DirectionMatchEval, DownwardRegistration, DownwardSampleMatch, DownwardSampleMatchSource, + DownwardViewportCandidate, DownwardViewportCandidateSource, DownwardViewportResolution, + FALLBACK_DOWNWARD_GROWTH_MAX_ROWS, FALLBACK_DOWNWARD_GROWTH_MIN_ROWS, GrowthCommit, + INITIAL_DOWNWARD_MAX_MOTION_ROWS, LOCAL_DOWNWARD_SEARCH_MAX_TOLERANCE_ROWS, + LOCAL_DOWNWARD_SEARCH_MOTION_TOLERANCE_ROWS, MotionObservation, OverlapSearchConfig, + OverlapSearchRange, PREVIEW_ONLY_LOCAL_NEAR_CONTINUITY_ROWS, + PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS, PREVIEW_ONLY_LOCAL_RECOVERY_MAX_TOLERANCE_ROWS, + REPEATED_PREVIEW_ONLY_LOCAL_RECOVERY_MAX_MOTION_ROWS, RangeInclusive, Result, RgbaImage, + ScrollDirection, ScrollObserveOutcome, ScrollSession, + TINY_PREVIEW_ONLY_LOCAL_BURST_RECOVERY_MAX_MOTION_ROWS, + UNDERCONSUMED_OBSERVED_BURST_RECOVERY_GAP_ROWS, eyre, +}; impl ScrollSession { pub(super) fn evaluate_reference_overlap_direction( @@ -14,7 +31,7 @@ impl ScrollSession { let preferred_range = self.preferred_motion_range_from_hint(previous, next, motion_rows_hint, config)?; - evaluate_overlap_direction(previous, next, direction, preferred_range, config) + support::evaluate_overlap_direction(previous, next, direction, preferred_range, config) } pub(super) fn evaluate_reference_downward_registration( @@ -51,8 +68,8 @@ impl ScrollSession { ) -> (DownwardRegistration, Option<&'static str>) { let config = OverlapSearchConfig::default(); let max_overlap = previous.height().min(next.height()); - let max_motion_rows = max_directional_motion_rows(previous, next, config); - let mut candidates = collect_overlap_direction_matches_in_ranges( + let max_motion_rows = support::max_directional_motion_rows(previous, next, config); + let mut candidates = support::collect_overlap_direction_matches_in_ranges( previous, next, ScrollDirection::Down, @@ -65,7 +82,7 @@ impl ScrollSession { && allow_full_range_fallback && (motion_rows_hint.is_none() || self.transient_burst_search_enabled) { - candidates = collect_overlap_direction_matches( + candidates = support::collect_overlap_direction_matches( previous, next, ScrollDirection::Down, @@ -76,14 +93,14 @@ impl ScrollSession { } candidates.retain(|matched| { - downward_registration_has_meaningful_overlap(*matched, max_overlap, config) + support::downward_registration_has_meaningful_overlap(*matched, max_overlap, config) }); if candidates.is_empty() { no_match_reason.get_or_insert("insufficient_overlap"); } - let classification = classify_downward_registration_candidates(&candidates); + let classification = support::classify_downward_registration_candidates(&candidates); let upward_veto = self.evaluate_reference_overlap_direction( previous, next, @@ -113,9 +130,9 @@ impl ScrollSession { ) -> DownwardRegistration { let config = OverlapSearchConfig::default(); let max_overlap = previous.height().min(next.height()); - let max_motion_rows = max_directional_motion_rows(previous, next, config); + let max_motion_rows = support::max_directional_motion_rows(previous, next, config); let mut candidates = preferred_range.as_ref().map_or_else(Vec::new, |range| { - collect_overlap_direction_matches( + support::collect_overlap_direction_matches( previous, next, ScrollDirection::Down, @@ -129,7 +146,7 @@ impl ScrollSession { && allow_full_range_fallback && (motion_rows_hint.is_none() || self.transient_burst_search_enabled) { - candidates = collect_overlap_direction_matches( + candidates = support::collect_overlap_direction_matches( previous, next, ScrollDirection::Down, @@ -140,14 +157,14 @@ impl ScrollSession { } candidates.retain(|matched| { - downward_registration_has_meaningful_overlap(*matched, max_overlap, config) + support::downward_registration_has_meaningful_overlap(*matched, max_overlap, config) }); if candidates.is_empty() { no_match_reason.get_or_insert("insufficient_overlap"); } - let classification = classify_downward_registration_candidates(&candidates); + let classification = support::classify_downward_registration_candidates(&candidates); let upward_veto = self.evaluate_reference_overlap_direction( previous, next, @@ -251,7 +268,7 @@ impl ScrollSession { config: OverlapSearchConfig, ) -> Option> { let transient_motion_rows_hint = self.normalized_transient_motion_rows_hint()?; - let max_motion_rows = max_directional_motion_rows(previous, next, config); + let max_motion_rows = support::max_directional_motion_rows(previous, next, config); if transient_motion_rows_hint == 0 || transient_motion_rows_hint > max_motion_rows { return None; @@ -277,7 +294,7 @@ impl ScrollSession { motion_rows_hint: Option, config: OverlapSearchConfig, ) -> Option> { - let max_motion_rows = max_directional_motion_rows(previous, next, config); + let max_motion_rows = support::max_directional_motion_rows(previous, next, config); if let Some(last_growth_rows) = motion_rows_hint { let tolerance = (last_growth_rows / 2) @@ -325,16 +342,21 @@ impl ScrollSession { allow_downward_full_range_fallback: bool, ) -> DirectionMatchEval { let config = OverlapSearchConfig::default(); - let max_motion_rows = max_directional_motion_rows(previous, next, config); + let max_motion_rows = support::max_directional_motion_rows(previous, next, config); let preferred_only_match = preferred_range.and_then(|range| { - evaluate_overlap_direction(previous, next, direction, range.as_range(), config) + support::evaluate_overlap_direction(previous, next, direction, range.as_range(), config) }); let mut final_match = preferred_only_match; let mut used_full_range_fallback = false; if final_match.is_none() && allow_downward_full_range_fallback { - final_match = - evaluate_overlap_direction(previous, next, direction, 1..=max_motion_rows, config); + final_match = support::evaluate_overlap_direction( + previous, + next, + direction, + 1..=max_motion_rows, + config, + ); used_full_range_fallback = final_match.is_some(); } @@ -358,7 +380,7 @@ impl ScrollSession { let preferred_range = self.preferred_motion_range_from_hint(previous, next, motion_rows_hint, config)?; - evaluate_overlap_direction(previous, next, direction, preferred_range, config) + support::evaluate_overlap_direction(previous, next, direction, preferred_range, config) } pub(super) fn preferred_motion_range_from_hint( @@ -368,7 +390,7 @@ impl ScrollSession { motion_rows_hint: Option, config: OverlapSearchConfig, ) -> Option> { - let max_motion_rows = max_directional_motion_rows(previous, next, config); + let max_motion_rows = support::max_directional_motion_rows(previous, next, config); if let Some(last_growth_rows) = motion_rows_hint { let tolerance = DOWNWARD_SEARCH_MOTION_TOLERANCE_ROWS.min(max_motion_rows); @@ -388,7 +410,7 @@ impl ScrollSession { motion_rows_hint: Option, config: OverlapSearchConfig, ) -> Option> { - let max_motion_rows = max_directional_motion_rows(previous, next, config); + let max_motion_rows = support::max_directional_motion_rows(previous, next, config); if let Some(last_growth_rows) = motion_rows_hint { let tolerance = (last_growth_rows / 2) @@ -479,7 +501,7 @@ impl ScrollSession { let candidates_before_prune = candidates.clone(); self.last_downward_viewport_candidates_before_prune = - Some(format_downward_viewport_candidates(&candidates)); + Some(support::format_downward_viewport_candidates(&candidates)); self.prune_committed_keyframe_candidates_outside_local_continuity(&mut candidates); self.restore_repeated_small_preview_only_local_candidate_after_empty_prune( @@ -505,9 +527,9 @@ impl ScrollSession { self.last_downward_viewport_candidate_count = Some(candidates.len()); self.last_downward_viewport_candidates_after_prune = - Some(format_downward_viewport_candidates(&candidates)); + Some(support::format_downward_viewport_candidates(&candidates)); - select_downward_viewport_candidate(&mut candidates) + support::select_downward_viewport_candidate(&mut candidates) } #[allow(clippy::too_many_arguments)] @@ -853,7 +875,7 @@ impl ScrollSession { let has_committed_candidate = candidates.iter().any(|candidate| { candidate.source == DownwardViewportCandidateSource::CommittedKeyframe }); - let mut local_anchor = best_local_downward_viewport_candidate(candidates); + let mut local_anchor = support::best_local_downward_viewport_candidate(candidates); if local_anchor.is_some_and(|anchor| { has_committed_candidate @@ -875,7 +897,7 @@ impl ScrollSession { candidate.source != DownwardViewportCandidateSource::PreviewOnlyLocalSample }); - local_anchor = best_local_downward_viewport_candidate(candidates); + local_anchor = support::best_local_downward_viewport_candidate(candidates); } let Some(local_anchor) = local_anchor else { @@ -1668,7 +1690,7 @@ impl ScrollSession { Some(decision_source), ); - Some(preview_update_outcome(preview_changed)) + Some(support::preview_update_outcome(preview_changed)) } pub(super) fn fallback_downward_growth_exceeds_continuity_budget( @@ -1706,7 +1728,7 @@ impl ScrollSession { self.collect_fallback_downward_viewport_candidates(&frame, &mut candidates); - match select_downward_viewport_candidate(&mut candidates) { + match support::select_downward_viewport_candidate(&mut candidates) { DownwardViewportResolution::NoMatch => { self.refresh_preview_only_downward_local_sample( &frame, @@ -1721,7 +1743,7 @@ impl ScrollSession { Some("no_committed_keyframe_match"), ); - Ok(preview_update_outcome(preview_changed)) + Ok(support::preview_update_outcome(preview_changed)) }, DownwardViewportResolution::Selected(candidate) => { if self.fallback_downward_growth_exceeds_continuity_budget(candidate.viewport_top_y) @@ -1744,7 +1766,7 @@ impl ScrollSession { Some("fallback_committed_candidate_exceeded_local_continuity_budget"), ); - return Ok(preview_update_outcome(preview_changed)); + return Ok(support::preview_update_outcome(preview_changed)); } if let Some(outcome) = self @@ -1785,7 +1807,7 @@ impl ScrollSession { Some(preferred.competing_block_reason(competing)), ); - Ok(preview_update_outcome(preview_changed)) + Ok(support::preview_update_outcome(preview_changed)) }, } } @@ -1801,13 +1823,13 @@ impl ScrollSession { effective_motion_rows_hint: Option, previous_motion_rows_hint: Option, ) -> Result { - let fingerprint = scroll_capture_fingerprint(&frame); - let strip = crop_bottom_rows(&frame, growth_rows) + let fingerprint = support::scroll_capture_fingerprint(&frame); + let strip = support::crop_bottom_rows(&frame, growth_rows) .ok_or_else(|| eyre::eyre!("failed to extract growth strip"))?; - let preview_strip = resize_strip_to_preview_width(&strip, self.preview_width_px); + let preview_strip = support::resize_strip_to_preview_width(&strip, self.preview_width_px); - self.export_image = append_vertical_image(&self.export_image, &strip)?; - self.preview_image = append_vertical_image(&self.preview_image, &preview_strip)?; + self.export_image = support::append_vertical_image(&self.export_image, &strip)?; + self.preview_image = support::append_vertical_image(&self.preview_image, &preview_strip)?; self.bottom_segments.push(strip); self.bottom_preview_segments.push(preview_strip); @@ -1816,7 +1838,10 @@ impl ScrollSession { self.observed_viewport_top_y = viewport_top_y; self.record_last_sample(&frame, fingerprint); - self.record_last_downward_observed_sample(&frame, scroll_capture_fingerprint(&frame)); + self.record_last_downward_observed_sample( + &frame, + support::scroll_capture_fingerprint(&frame), + ); if self.should_seed_preview_only_local_after_observed_burst_commit( decision_source, @@ -1901,7 +1926,7 @@ impl ScrollSession { ordered.push(strip); } - stack_vertical_images(&ordered) + support::stack_vertical_images(&ordered) } pub(super) fn rebuild_preview_image(&self) -> Result { @@ -1913,6 +1938,6 @@ impl ScrollSession { ordered.push(strip); } - stack_vertical_images(&ordered) + support::stack_vertical_images(&ordered) } } diff --git a/packages/rsnap-overlay/src/scroll_capture/support.rs b/packages/rsnap-overlay/src/scroll_capture/support.rs index dc8a20f0..7d45b7a8 100644 --- a/packages/rsnap-overlay/src/scroll_capture/support.rs +++ b/packages/rsnap-overlay/src/scroll_capture/support.rs @@ -24,8 +24,8 @@ use objc2_foundation::{NSArray, NSDictionary}; use objc2_vision::{VNImageOption, VNImageRequestHandler, VNTranslationalImageRegistrationRequest}; #[cfg(test)] -use super::OverlapMatch; -use super::{ +use crate::scroll_capture::OverlapMatch; +use crate::scroll_capture::{ DIRECTION_WARNING_MARGIN_X100, DOWNWARD_REGISTRATION_AMBIGUOUS_GAP_ROWS, DOWNWARD_REGISTRATION_MIN_OVERLAP_DIVISOR, DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS, DirectionMatch, DownwardRegistration, DownwardViewportCandidate, @@ -111,15 +111,6 @@ pub(crate) fn compose_provisional_preview_image( append_vertical_image(base_preview, &preview_strip).unwrap_or_else(|_| base_preview.clone()) } -fn worker_pairwise_overlap_search_config() -> OverlapSearchConfig { - OverlapSearchConfig { - min_overlap_rows: 24, - max_column_samples: 96, - max_row_samples: 96, - max_mean_abs_diff_x100: 850, - } -} - #[cfg(target_os = "macos")] pub(super) fn classify_vision_downward_sample_motion_against( previous: &RgbaImage, @@ -212,40 +203,6 @@ pub(super) fn estimate_pairwise_downward_shift_rows( .map(|matched| matched.motion_rows) } -#[cfg(target_os = "macos")] -fn cg_image_from_rgba_image(image: &RgbaImage) -> Result> { - let width = image.width() as usize; - let height = image.height() as usize; - - if width == 0 || height == 0 { - return Err(eyre::eyre!("vision registration image has zero dimensions")); - } - - let bytes = CFData::from_bytes(image.as_raw()); - let provider = CGDataProvider::with_cf_data(Some(bytes.as_ref())) - .ok_or_else(|| eyre::eyre!("failed to create CGDataProvider for Vision registration"))?; - let color_space = CGColorSpace::new_device_rgb() - .ok_or_else(|| eyre::eyre!("failed to create RGB colorspace for Vision registration"))?; - let bitmap_info = CGBitmapInfo(CGImageAlphaInfo::Last.0 | CGImageByteOrderInfo::Order32Big.0); - - unsafe { - CGImage::new( - width, - height, - 8, - 32, - width.saturating_mul(4), - Some(color_space.as_ref()), - bitmap_info, - Some(provider.as_ref()), - ptr::null(), - false, - CGColorRenderingIntent::RenderingIntentDefault, - ) - } - .ok_or_else(|| eyre::eyre!("failed to create CGImage for Vision registration")) -} - pub(super) fn select_downward_viewport_candidate( candidates: &mut [DownwardViewportCandidate], ) -> DownwardViewportResolution { @@ -311,31 +268,6 @@ pub(super) fn format_downward_viewport_candidates( .join(",") } -fn prefer_local_downward_viewport_candidate( - candidates: &[DownwardViewportCandidate], -) -> Option { - let local = best_local_downward_viewport_candidate(candidates)?; - let committed = candidates - .iter() - .copied() - .filter(|candidate| candidate.source == DownwardViewportCandidateSource::CommittedKeyframe) - .min_by(|left, right| { - left.mean_abs_diff_x100 - .cmp(&right.mean_abs_diff_x100) - .then(left.motion_rows.cmp(&right.motion_rows)) - }); - let Some(committed) = committed else { - return Some(local); - }; - let committed_is_nearby = committed.viewport_top_y.abs_diff(local.viewport_top_y) - < DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS; - let committed_is_only_modestly_better = - committed.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) - >= local.mean_abs_diff_x100; - - if committed_is_nearby && committed_is_only_modestly_better { Some(local) } else { None } -} - pub(super) fn best_local_downward_viewport_candidate( candidates: &[DownwardViewportCandidate], ) -> Option { @@ -625,64 +557,6 @@ pub(super) fn max_directional_motion_rows( max_overlap.saturating_sub(effective_min_overlap).max(1) } -#[cfg(test)] -fn detect_vertical_overlap_in_range( - previous: &RgbaImage, - next: &RgbaImage, - range: RangeInclusive, - direction: ScrollDirection, - config: OverlapSearchConfig, - informative_span: Option, -) -> OverlapMatch { - if previous.width() == 0 || next.width() == 0 || previous.height() == 0 || next.height() == 0 { - return OverlapMatch { rows: 0, matched: false, mean_abs_diff_x100: u32::MAX }; - } - - let Some(informative_span) = informative_span else { - return OverlapMatch { rows: 0, matched: false, mean_abs_diff_x100: u32::MAX }; - }; - let max_overlap = previous.height().min(next.height()); - let effective_min_overlap = - if max_overlap <= config.min_overlap_rows { 1 } else { config.min_overlap_rows.max(1) }; - let max_motion_rows = max_overlap.saturating_sub(effective_min_overlap).max(1); - let search_start = (*range.start()).max(1).min(max_motion_rows); - let search_end = (*range.end()).max(search_start).min(max_motion_rows); - let orientation = match direction { - ScrollDirection::Down => OverlapOrientation::PreviousBottomToNextTop, - ScrollDirection::Up => OverlapOrientation::PreviousTopToNextBottom, - }; - let mut best = OverlapMatch { rows: 0, matched: false, mean_abs_diff_x100: u32::MAX }; - - for motion_rows in search_start..=search_end { - let overlap_rows = max_overlap.saturating_sub(motion_rows); - - if overlap_rows < effective_min_overlap { - continue; - } - - let diff = motion_mean_abs_diff_x100( - previous, - next, - motion_rows, - config, - orientation, - informative_span, - ); - - if diff > config.max_mean_abs_diff_x100 { - continue; - } - if !best.matched - || diff < best.mean_abs_diff_x100 - || (diff == best.mean_abs_diff_x100 && overlap_rows > best.rows) - { - best = OverlapMatch { rows: overlap_rows, matched: true, mean_abs_diff_x100: diff }; - } - } - - best -} - pub(super) fn resize_strip_to_preview_width(strip: &RgbaImage, preview_width_px: u32) -> RgbaImage { if strip.width() <= preview_width_px { return strip.clone(); @@ -749,6 +623,204 @@ pub(super) fn append_vertical_image(base: &RgbaImage, strip: &RgbaImage) -> Resu stack_vertical_images(&[base, strip]) } +pub(super) fn informative_column_span( + image: &RgbaImage, + start_y: u32, + rows: u32, +) -> Option { + if image.width() == 0 || image.height() == 0 || rows == 0 { + return None; + } + + let clamped_rows = rows.min(image.height().saturating_sub(start_y)).max(1); + let row_samples = clamped_rows.min(INFORMATIVE_SPAN_ROW_SAMPLES.max(2)).max(2); + let mut scores = vec![0_u32; image.width() as usize]; + let mut max_score = 0_u32; + + for row in 0..row_samples.saturating_sub(1) { + let local_y = evenly_spaced_sample(0, clamped_rows, row, row_samples); + let next_local_y = (local_y.saturating_add(1)).min(clamped_rows.saturating_sub(1)); + let y = start_y.saturating_add(local_y).min(image.height().saturating_sub(1)); + let next_y = start_y.saturating_add(next_local_y).min(image.height().saturating_sub(1)); + + for x in 0..image.width() { + let pixel = image.get_pixel(x, y).0; + let next_pixel = image.get_pixel(x, next_y).0; + let score = u32::from(pixel[0].abs_diff(next_pixel[0])) + .saturating_add(u32::from(pixel[1].abs_diff(next_pixel[1]))) + .saturating_add(u32::from(pixel[2].abs_diff(next_pixel[2]))); + let slot = &mut scores[x as usize]; + + *slot = slot.saturating_add(score); + max_score = max_score.max(*slot); + } + } + + if max_score == 0 { + return None; + } + + let threshold = (max_score / 6).max(INFORMATIVE_SPAN_SCORE_FLOOR_X100); + let mut start_x = None; + let mut end_x = None; + + for (x, score) in scores.iter().enumerate() { + if *score >= threshold { + start_x.get_or_insert(x as u32); + + end_x = Some((x as u32).saturating_add(1)); + } + } + + let start_x = start_x?; + let end_exclusive_x = end_x?; + let padding = INFORMATIVE_SPAN_HORIZONTAL_PADDING_PX.min(image.width() / 8); + let start_x = start_x.saturating_sub(padding); + let end_exclusive_x = + end_exclusive_x.saturating_add(padding).min(image.width()).max(start_x.saturating_add(1)); + + Some(InformativeSpan { start_x, end_exclusive_x }) +} + +pub(super) fn evenly_spaced_sample(start: u32, end_exclusive: u32, index: u32, count: u32) -> u32 { + let span = end_exclusive.saturating_sub(start).max(1); + + if count <= 1 { + return start.min(end_exclusive.saturating_sub(1)); + } + + let numerator = + (u64::from(index) * u64::from(span.saturating_sub(1))) / u64::from(count.saturating_sub(1)); + + start.saturating_add(numerator as u32).min(end_exclusive.saturating_sub(1)) +} + +fn worker_pairwise_overlap_search_config() -> OverlapSearchConfig { + OverlapSearchConfig { + min_overlap_rows: 24, + max_column_samples: 96, + max_row_samples: 96, + max_mean_abs_diff_x100: 850, + } +} + +#[cfg(target_os = "macos")] +fn cg_image_from_rgba_image(image: &RgbaImage) -> Result> { + let width = image.width() as usize; + let height = image.height() as usize; + + if width == 0 || height == 0 { + return Err(eyre::eyre!("vision registration image has zero dimensions")); + } + + let bytes = CFData::from_bytes(image.as_raw()); + let provider = CGDataProvider::with_cf_data(Some(bytes.as_ref())) + .ok_or_else(|| eyre::eyre!("failed to create CGDataProvider for Vision registration"))?; + let color_space = CGColorSpace::new_device_rgb() + .ok_or_else(|| eyre::eyre!("failed to create RGB colorspace for Vision registration"))?; + let bitmap_info = CGBitmapInfo(CGImageAlphaInfo::Last.0 | CGImageByteOrderInfo::Order32Big.0); + + unsafe { + CGImage::new( + width, + height, + 8, + 32, + width.saturating_mul(4), + Some(color_space.as_ref()), + bitmap_info, + Some(provider.as_ref()), + ptr::null(), + false, + CGColorRenderingIntent::RenderingIntentDefault, + ) + } + .ok_or_else(|| eyre::eyre!("failed to create CGImage for Vision registration")) +} + +fn prefer_local_downward_viewport_candidate( + candidates: &[DownwardViewportCandidate], +) -> Option { + let local = best_local_downward_viewport_candidate(candidates)?; + let committed = candidates + .iter() + .copied() + .filter(|candidate| candidate.source == DownwardViewportCandidateSource::CommittedKeyframe) + .min_by(|left, right| { + left.mean_abs_diff_x100 + .cmp(&right.mean_abs_diff_x100) + .then(left.motion_rows.cmp(&right.motion_rows)) + }); + let Some(committed) = committed else { + return Some(local); + }; + let committed_is_nearby = committed.viewport_top_y.abs_diff(local.viewport_top_y) + < DOWNWARD_VIEWPORT_AUTHORITY_GAP_ROWS; + let committed_is_only_modestly_better = + committed.mean_abs_diff_x100.saturating_add(DIRECTION_WARNING_MARGIN_X100) + >= local.mean_abs_diff_x100; + + if committed_is_nearby && committed_is_only_modestly_better { Some(local) } else { None } +} + +#[cfg(test)] +fn detect_vertical_overlap_in_range( + previous: &RgbaImage, + next: &RgbaImage, + range: RangeInclusive, + direction: ScrollDirection, + config: OverlapSearchConfig, + informative_span: Option, +) -> OverlapMatch { + if previous.width() == 0 || next.width() == 0 || previous.height() == 0 || next.height() == 0 { + return OverlapMatch { rows: 0, matched: false, mean_abs_diff_x100: u32::MAX }; + } + + let Some(informative_span) = informative_span else { + return OverlapMatch { rows: 0, matched: false, mean_abs_diff_x100: u32::MAX }; + }; + let max_overlap = previous.height().min(next.height()); + let effective_min_overlap = + if max_overlap <= config.min_overlap_rows { 1 } else { config.min_overlap_rows.max(1) }; + let max_motion_rows = max_overlap.saturating_sub(effective_min_overlap).max(1); + let search_start = (*range.start()).max(1).min(max_motion_rows); + let search_end = (*range.end()).max(search_start).min(max_motion_rows); + let orientation = match direction { + ScrollDirection::Down => OverlapOrientation::PreviousBottomToNextTop, + ScrollDirection::Up => OverlapOrientation::PreviousTopToNextBottom, + }; + let mut best = OverlapMatch { rows: 0, matched: false, mean_abs_diff_x100: u32::MAX }; + + for motion_rows in search_start..=search_end { + let overlap_rows = max_overlap.saturating_sub(motion_rows); + + if overlap_rows < effective_min_overlap { + continue; + } + + let diff = motion_mean_abs_diff_x100( + previous, + next, + motion_rows, + config, + orientation, + informative_span, + ); + + if diff > config.max_mean_abs_diff_x100 { + continue; + } + if !best.matched + || diff < best.mean_abs_diff_x100 + || (diff == best.mean_abs_diff_x100 && overlap_rows > best.rows) + { + best = OverlapMatch { rows: overlap_rows, matched: true, mean_abs_diff_x100: diff }; + } + } + + best +} + fn motion_mean_abs_diff_x100( previous: &RgbaImage, next: &RgbaImage, @@ -832,75 +904,3 @@ fn overlap_global_informative_span(left: &RgbaImage, right: &RgbaImage) -> Optio (None, None) => None, } } - -pub(super) fn informative_column_span( - image: &RgbaImage, - start_y: u32, - rows: u32, -) -> Option { - if image.width() == 0 || image.height() == 0 || rows == 0 { - return None; - } - - let clamped_rows = rows.min(image.height().saturating_sub(start_y)).max(1); - let row_samples = clamped_rows.min(INFORMATIVE_SPAN_ROW_SAMPLES.max(2)).max(2); - let mut scores = vec![0_u32; image.width() as usize]; - let mut max_score = 0_u32; - - for row in 0..row_samples.saturating_sub(1) { - let local_y = evenly_spaced_sample(0, clamped_rows, row, row_samples); - let next_local_y = (local_y.saturating_add(1)).min(clamped_rows.saturating_sub(1)); - let y = start_y.saturating_add(local_y).min(image.height().saturating_sub(1)); - let next_y = start_y.saturating_add(next_local_y).min(image.height().saturating_sub(1)); - - for x in 0..image.width() { - let pixel = image.get_pixel(x, y).0; - let next_pixel = image.get_pixel(x, next_y).0; - let score = u32::from(pixel[0].abs_diff(next_pixel[0])) - .saturating_add(u32::from(pixel[1].abs_diff(next_pixel[1]))) - .saturating_add(u32::from(pixel[2].abs_diff(next_pixel[2]))); - let slot = &mut scores[x as usize]; - - *slot = slot.saturating_add(score); - max_score = max_score.max(*slot); - } - } - - if max_score == 0 { - return None; - } - - let threshold = (max_score / 6).max(INFORMATIVE_SPAN_SCORE_FLOOR_X100); - let mut start_x = None; - let mut end_x = None; - - for (x, score) in scores.iter().enumerate() { - if *score >= threshold { - start_x.get_or_insert(x as u32); - - end_x = Some((x as u32).saturating_add(1)); - } - } - - let start_x = start_x?; - let end_exclusive_x = end_x?; - let padding = INFORMATIVE_SPAN_HORIZONTAL_PADDING_PX.min(image.width() / 8); - let start_x = start_x.saturating_sub(padding); - let end_exclusive_x = - end_exclusive_x.saturating_add(padding).min(image.width()).max(start_x.saturating_add(1)); - - Some(InformativeSpan { start_x, end_exclusive_x }) -} - -pub(super) fn evenly_spaced_sample(start: u32, end_exclusive: u32, index: u32, count: u32) -> u32 { - let span = end_exclusive.saturating_sub(start).max(1); - - if count <= 1 { - return start.min(end_exclusive.saturating_sub(1)); - } - - let numerator = - (u64::from(index) * u64::from(span.saturating_sub(1))) / u64::from(count.saturating_sub(1)); - - start.saturating_add(numerator as u32).min(end_exclusive.saturating_sub(1)) -} diff --git a/packages/rsnap-overlay/src/scroll_capture/tests.rs b/packages/rsnap-overlay/src/scroll_capture/tests.rs index 89b7ebaf..0a217593 100644 --- a/packages/rsnap-overlay/src/scroll_capture/tests.rs +++ b/packages/rsnap-overlay/src/scroll_capture/tests.rs @@ -1,10 +1,11 @@ use image::Rgba; +use crate::scroll_capture::support; use crate::scroll_capture::{ self, DirectionMatch, DownwardRegistration, DownwardSampleMatch, DownwardSampleMatchSource, DownwardViewportCandidate, DownwardViewportCandidateSource, DownwardViewportResolution, - GrowthCommit, MotionObservation, OverlapSearchConfig, PreviewOnlyDownwardLocalSample, - ScrollDirection, ScrollFrameFingerprint, ScrollObserveOutcome, ScrollSession, + MotionObservation, OverlapSearchConfig, PreviewOnlyDownwardLocalSample, ScrollDirection, + ScrollFrameFingerprint, ScrollObserveOutcome, ScrollSession, }; fn make_test_image(width: u32, rows: &[[u8; 4]]) -> image::RgbaImage { @@ -198,7 +199,7 @@ fn session_commits_downward_growth_on_first_matching_sample() { fn worker_pairwise_vision_commits_substantial_downward_growth_with_corroboration() { let base = make_sparse_textlike_window(512, 640, 0); let moved = make_sparse_textlike_window(512, 640, 90); - let matched = scroll_capture::classify_vision_downward_sample_motion_against(&base, &moved) + let matched = support::classify_vision_downward_sample_motion_against(&base, &moved) .expect("vision registration should detect the substantial downward motion"); let mut session = ScrollSession::new(base, 320).unwrap(); let outcome = session.observe_worker_pairwise_vision_frame(moved).unwrap(); @@ -220,7 +221,7 @@ fn pairwise_downward_shift_estimate_matches_sparse_textlike_motion() { let base = make_sparse_textlike_window(512, 640, 0); let moved = make_sparse_textlike_window(512, 640, 58); - assert_eq!(scroll_capture::estimate_pairwise_downward_shift_rows(&base, &moved), Some(58)); + assert_eq!(support::estimate_pairwise_downward_shift_rows(&base, &moved), Some(58)); } #[test] @@ -228,7 +229,7 @@ fn pairwise_downward_shift_estimate_matches_browser_like_motion_above_legacy_cap let base = make_browser_like_window(512, 640, 0); let moved = make_browser_like_window(512, 640, 320); - assert_eq!(scroll_capture::estimate_pairwise_downward_shift_rows(&base, &moved), Some(320)); + assert_eq!(support::estimate_pairwise_downward_shift_rows(&base, &moved), Some(320)); } #[test] @@ -240,7 +241,7 @@ fn pairwise_downward_shift_estimate_tracks_successive_browser_like_steps() { for window in frames.windows(2) { assert_eq!( - scroll_capture::estimate_pairwise_downward_shift_rows(&window[0], &window[1]), + support::estimate_pairwise_downward_shift_rows(&window[0], &window[1]), Some(180) ); } @@ -252,11 +253,10 @@ fn worker_pairwise_vision_uses_latest_committed_live_frame_for_followup_growth() let base = make_sparse_textlike_window(512, 640, 0); let step_one = make_sparse_textlike_window(512, 640, 180); let step_two = make_sparse_textlike_window(512, 640, 360); - let first_match = - scroll_capture::classify_vision_downward_sample_motion_against(&base, &step_one) - .expect("first pairwise registration should detect downward motion"); + let first_match = support::classify_vision_downward_sample_motion_against(&base, &step_one) + .expect("first pairwise registration should detect downward motion"); let followup_match = - scroll_capture::classify_vision_downward_sample_motion_against(&step_one, &step_two) + support::classify_vision_downward_sample_motion_against(&step_one, &step_two) .expect("followup pairwise registration should detect downward motion"); let mut session = ScrollSession::new(base, 320).unwrap(); @@ -290,11 +290,10 @@ fn worker_pairwise_vision_handles_repeated_frame_between_growth_steps() { let base = make_sparse_textlike_window(512, 640, 0); let step_one = make_sparse_textlike_window(512, 640, 180); let step_two = make_sparse_textlike_window(512, 640, 360); - let first_match = - scroll_capture::classify_vision_downward_sample_motion_against(&base, &step_one) - .expect("first pairwise registration should detect downward motion"); + let first_match = support::classify_vision_downward_sample_motion_against(&base, &step_one) + .expect("first pairwise registration should detect downward motion"); let followup_match = - scroll_capture::classify_vision_downward_sample_motion_against(&step_one, &step_two) + support::classify_vision_downward_sample_motion_against(&step_one, &step_two) .expect("followup pairwise registration should detect downward motion"); let mut session = ScrollSession::new(base, 320).unwrap(); @@ -328,10 +327,8 @@ fn worker_pairwise_vision_recovers_after_blocked_overshot_frame() { let base = make_browser_like_window(512, 640, 0); let blocked = make_browser_like_window(512, 640, 760); let followup = make_browser_like_window(512, 640, 844); - let matched = scroll_capture::classify_vision_downward_sample_motion_against( - &blocked, &followup, - ) - .expect("pairwise registration should detect the followup step after the blocked overshot"); + let matched = support::classify_vision_downward_sample_motion_against(&blocked, &followup) + .expect("pairwise registration should detect the followup step after the blocked overshot"); let mut session = ScrollSession::new(base, 320).unwrap(); assert_eq!( @@ -390,7 +387,7 @@ fn worker_pairwise_vision_clears_preview_local_followup_carryover_on_no_change() fn worker_pairwise_vision_clears_preview_local_followup_carryover_on_commit() { let base = make_sparse_textlike_window(512, 640, 0); let moved = make_sparse_textlike_window(512, 640, 180); - let matched = scroll_capture::classify_vision_downward_sample_motion_against(&base, &moved) + let matched = support::classify_vision_downward_sample_motion_against(&base, &moved) .expect("pairwise registration should detect downward motion"); let mut session = ScrollSession::new(base, 320).unwrap(); @@ -439,9 +436,8 @@ fn worker_pairwise_vision_commits_successive_slowdown_steps() { for window in frames.windows(2) { let previous = &window[0]; let next = window[1].clone(); - let matched = - scroll_capture::classify_vision_downward_sample_motion_against(previous, &next) - .expect("pairwise registration should detect each slowdown step"); + let matched = support::classify_vision_downward_sample_motion_against(previous, &next) + .expect("pairwise registration should detect each slowdown step"); assert_eq!( session.observe_worker_pairwise_vision_frame(next).unwrap(), @@ -464,7 +460,7 @@ fn worker_pairwise_vision_commits_successive_slowdown_steps() { fn worker_pairwise_vision_commits_browser_like_growth_above_legacy_cap() { let base = make_browser_like_window(512, 640, 0); let moved = make_browser_like_window(512, 640, 320); - let matched = scroll_capture::classify_vision_downward_sample_motion_against(&base, &moved) + let matched = support::classify_vision_downward_sample_motion_against(&base, &moved) .expect("vision registration should detect the browser-like downward motion"); let mut session = ScrollSession::new(base, 320).unwrap(); @@ -494,9 +490,8 @@ fn worker_pairwise_vision_commits_successive_browser_like_steps() { for window in frames.windows(2) { let previous = &window[0]; let next = window[1].clone(); - let matched = - scroll_capture::classify_vision_downward_sample_motion_against(previous, &next) - .expect("pairwise registration should detect each browser-like step"); + let matched = support::classify_vision_downward_sample_motion_against(previous, &next) + .expect("pairwise registration should detect each browser-like step"); assert_eq!( session.observe_worker_pairwise_vision_frame(next).unwrap(), @@ -520,11 +515,10 @@ fn worker_pairwise_vision_handles_repeated_browser_like_frame_between_growth_ste let base = make_browser_like_window(512, 640, 0); let step_one = make_browser_like_window(512, 640, 180); let step_two = make_browser_like_window(512, 640, 360); - let first_match = - scroll_capture::classify_vision_downward_sample_motion_against(&base, &step_one) - .expect("first browser-like step should register downward motion"); + let first_match = support::classify_vision_downward_sample_motion_against(&base, &step_one) + .expect("first browser-like step should register downward motion"); let followup_match = - scroll_capture::classify_vision_downward_sample_motion_against(&step_one, &step_two) + support::classify_vision_downward_sample_motion_against(&step_one, &step_two) .expect("followup browser-like step should register downward motion"); let mut session = ScrollSession::new(base, 320).unwrap(); @@ -558,10 +552,10 @@ fn worker_pairwise_vision_browser_like_followup_uses_adjacent_worker_frame() { let base = make_browser_like_window(512, 640, 0); let blocked = make_browser_like_window(512, 640, 700); let followup = make_browser_like_window(512, 640, 784); - let matched = scroll_capture::classify_vision_downward_sample_motion_against( - &blocked, &followup, - ) - .expect("browser-like pairwise registration should use the immediately previous worker frame"); + let matched = support::classify_vision_downward_sample_motion_against(&blocked, &followup) + .expect( + "browser-like pairwise registration should use the immediately previous worker frame", + ); let mut session = ScrollSession::new(base, 320).unwrap(); assert_eq!( @@ -1169,7 +1163,7 @@ fn viewport_selection_fails_closed_when_observed_and_committed_authority_conflic let mut candidates = [observed, committed]; assert_eq!( - scroll_capture::select_downward_viewport_candidate(&mut candidates), + support::select_downward_viewport_candidate(&mut candidates), DownwardViewportResolution::Ambiguous { preferred: committed, competing: observed } ); } @@ -1287,7 +1281,7 @@ fn nearby_local_candidate_wins_when_committed_is_only_modestly_better() { let mut candidates = [observed, committed]; assert_eq!( - scroll_capture::select_downward_viewport_candidate(&mut candidates), + support::select_downward_viewport_candidate(&mut candidates), DownwardViewportResolution::Selected(observed) ); } @@ -1820,7 +1814,7 @@ fn preview_local_slowdown_followup_can_prefer_one_pixel_preview_local_recovery() session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { frame: previous.clone(), viewport_top_y: 145 }); - session.growth_history.push(GrowthCommit { + session.growth_history.push(crate::scroll_capture::GrowthCommit { frame: previous, growth_rows: 4, viewport_top_y: 145, @@ -1852,7 +1846,7 @@ fn preview_local_slowdown_followup_can_prefer_near_continuity_preview_local_reco session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { frame: previous.clone(), viewport_top_y: 416 }); - session.growth_history.push(GrowthCommit { + session.growth_history.push(crate::scroll_capture::GrowthCommit { frame: previous, growth_rows: 10, viewport_top_y: 416, @@ -1884,7 +1878,7 @@ fn preview_local_slowdown_followup_without_recent_small_preview_commit_does_not_ session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { frame: previous.clone(), viewport_top_y: 145 }); - session.growth_history.push(GrowthCommit { + session.growth_history.push(crate::scroll_capture::GrowthCommit { frame: previous, growth_rows: 12, viewport_top_y: 145, @@ -2443,7 +2437,7 @@ fn preview_local_slowdown_followup_range_allows_one_pixel_tail_in_burst() { session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { frame: previous.clone(), viewport_top_y: 145 }); - session.growth_history.push(GrowthCommit { + session.growth_history.push(crate::scroll_capture::GrowthCommit { frame: previous.clone(), growth_rows: 4, viewport_top_y: 145, @@ -2472,7 +2466,7 @@ fn preview_local_followup_without_recent_small_preview_commit_keeps_hint_floor_i session.last_preview_only_downward_local_sample = Some(PreviewOnlyDownwardLocalSample { frame: previous.clone(), viewport_top_y: 145 }); - session.growth_history.push(GrowthCommit { + session.growth_history.push(crate::scroll_capture::GrowthCommit { frame: previous.clone(), growth_rows: 12, viewport_top_y: 145, From e2353104a90f893454e1eb6056c4df0efaf13e10 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Sat, 4 Apr 2026 14:01:00 +0800 Subject: [PATCH 4/5] {"schema":"delivery/1","type":"fix","scope":"rsnap-overlay","summary":"gate macos-only overlay imports for linux lint","intent":"repair the PR head after CI revealed that a few imports left behind by the modularization lane were only used on macOS and therefore tripped linux clippy with -D warnings","impact":"overlay.rs and overlay tests now gate macOS-only imports behind target_os so the language checks match actual platform usage without changing runtime behavior","breaking":false,"risk":"low","authority":"review","delivery_mode":"status-only","refs":[{"system":"linear","id":"XY-226","role":"authority"}]} --- packages/rsnap-overlay/src/overlay.rs | 1 + packages/rsnap-overlay/src/overlay/tests.rs | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index 1bedb4ff..7c955b9f 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -125,6 +125,7 @@ use wgpu::{self}; use winit::dpi::{LogicalPosition, LogicalSize, PhysicalPosition}; use winit::event::KeyEvent; use winit::event::Modifiers; +#[cfg(target_os = "macos")] use winit::window::Window; use winit::{ dpi::PhysicalSize, diff --git a/packages/rsnap-overlay/src/overlay/tests.rs b/packages/rsnap-overlay/src/overlay/tests.rs index 7dd592d4..308535c7 100644 --- a/packages/rsnap-overlay/src/overlay/tests.rs +++ b/packages/rsnap-overlay/src/overlay/tests.rs @@ -10,15 +10,20 @@ mod worker_tick_runtime; use std::collections::VecDeque; #[cfg(target_os = "macos")] use std::sync::Arc; +#[cfg(target_os = "macos")] use std::sync::Mutex; +#[cfg(target_os = "macos")] use std::sync::atomic::AtomicUsize; +#[cfg(target_os = "macos")] use std::sync::atomic::Ordering; #[cfg(target_os = "macos")] use std::thread; use std::time::Duration; use std::time::Instant; +#[cfg(target_os = "macos")] use color_eyre::eyre; +#[cfg(target_os = "macos")] use color_eyre::eyre::Result; use egui::FontDefinitions; use egui::FontFamily; From bb431750c27f56bffbd55df2a34e7c4e022d5a52 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Sat, 4 Apr 2026 14:06:26 +0800 Subject: [PATCH 5/5] {"schema":"delivery/1","type":"fix","scope":"rsnap-overlay","summary":"gate macos-only freeze-capture test on linux","intent":"repair the PR head after Linux CI surfaced a split-test regression where a macOS-only freeze-capture assertion was left enabled on non-macOS targets and contradicted the intended off-macOS behavior","impact":"the focused rendering behavior test now runs only on macOS, matching should_dispatch_pending_freeze_capture semantics while preserving the existing non-macOS assertion and leaving production code unchanged","breaking":false,"risk":"low","authority":"review","delivery_mode":"status-only","refs":[{"system":"linear","id":"XY-226","role":"authority"}]} --- packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs index e828a628..0598323c 100644 --- a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs @@ -19,6 +19,7 @@ use crate::overlay::tests::{ WorkerErrorSource, WorkerResponse, overlay, }; +#[cfg(target_os = "macos")] #[test] fn pending_freeze_capture_dispatches_even_with_seeded_preview() { let monitor = tests::test_monitor();