From 817f46470440a0f130b3602977872eac831b7889 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Sat, 4 Apr 2026 01:12:19 +0800 Subject: [PATCH 1/4] {"schema":"delivery/1","type":"fix","scope":"overlay","summary":"improve selection flow hover behavior","intent":"rework live hover selection feedback so flow remains optional, light and dark themes keep usable contrast, and hovered-window targeting survives startup edge cases","impact":"the settings surface now exposes a single Selection flow toggle and thickness control, live hover keeps its scrim when flow is disabled without inheriting the frozen dashed border, and macOS live targeting filters rsnap-owned windows while retrying occluded startup presents","breaking":false,"risk":"medium","authority":"linear","delivery_mode":"status-only","refs":[{"system":"linear","id":"XY-223","role":"authority"},{"system":"github","repo":"hack-ink/rsnap","number":52,"role":"mirror"}]} --- apps/rsnap/src/app/capture.rs | 2 +- apps/rsnap/src/settings.rs | 42 ++- .../src/settings_window/bench_support.rs | 2 +- apps/rsnap/src/settings_window/sections.rs | 4 +- packages/rsnap-overlay/src/backend.rs | 51 ++- packages/rsnap-overlay/src/overlay.rs | 335 +++++++++++++----- 6 files changed, 321 insertions(+), 115 deletions(-) diff --git a/apps/rsnap/src/app/capture.rs b/apps/rsnap/src/app/capture.rs index 0fea1413..91635b3b 100644 --- a/apps/rsnap/src/app/capture.rs +++ b/apps/rsnap/src/app/capture.rs @@ -46,7 +46,7 @@ impl App { OverlayConfig { hud_anchor: HudAnchor::Cursor, show_alt_hint_keycap: self.settings.show_alt_hint_keycap, - selection_particles: self.settings.selection_particles, + selection_flow_enabled: self.settings.selection_flow_enabled, selection_flow_stroke_width_px: self .settings .selection_flow_stroke_width_px diff --git a/apps/rsnap/src/settings.rs b/apps/rsnap/src/settings.rs index a6d7da5a..5965e583 100644 --- a/apps/rsnap/src/settings.rs +++ b/apps/rsnap/src/settings.rs @@ -46,6 +46,7 @@ impl LoupeSampleSize { } #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub(crate) struct AppSettings { #[serde(default)] pub show_alt_hint_keycap: bool, @@ -63,8 +64,8 @@ pub(crate) struct AppSettings { pub hud_tint_hue: f32, #[serde(default)] pub alt_activation: AltActivationMode, - #[serde(default = "default_selection_particles")] - pub selection_particles: bool, + #[serde(default = "default_selection_flow_enabled")] + pub selection_flow_enabled: bool, #[serde(default = "default_selection_flow_stroke_width_px")] pub selection_flow_stroke_width_px: f32, pub log_filter: Option, @@ -156,7 +157,7 @@ impl Default for AppSettings { hud_tint: default_hud_tint(), hud_tint_hue: default_hud_tint_hue(), alt_activation: AltActivationMode::default(), - selection_particles: default_selection_particles(), + selection_flow_enabled: default_selection_flow_enabled(), selection_flow_stroke_width_px: default_selection_flow_stroke_width_px(), log_filter: None, output_dir: default_output_dir(), @@ -203,7 +204,7 @@ fn default_hud_tint_hue() -> f32 { 215.0 / 360.0 } -fn default_selection_particles() -> bool { +fn default_selection_flow_enabled() -> bool { true } @@ -331,7 +332,7 @@ mod tests { hud_tint = 0.25 hud_tint_hue = 0.4 alt_activation = "toggle" - selection_particles = true + selection_flow_enabled = false selection_flow_stroke_width_px = 2.4 output_dir = "/tmp/rsnap-output" output_filename_prefix = "shot" @@ -344,7 +345,7 @@ mod tests { let settings: AppSettings = toml::from_str(input).unwrap(); assert_eq!(settings.alt_activation, AltActivationMode::Toggle); - assert!(settings.selection_particles); + assert!(!settings.selection_flow_enabled); assert_eq!(settings.selection_flow_stroke_width_px, 2.4); assert_eq!(settings.output_dir, PathBuf::from("/tmp/rsnap-output")); assert_eq!(settings.output_filename_prefix, "shot"); @@ -356,23 +357,34 @@ mod tests { } #[test] - fn toml_ignores_legacy_tray_icon_keys() { - let baseline: AppSettings = toml::from_str("").unwrap(); - let tray_icon_inverted: AppSettings = toml::from_str("tray_icon_inverted = true").unwrap(); - let tray_icon_filled: AppSettings = toml::from_str("tray_icon_filled = true").unwrap(); + fn toml_rejects_legacy_tray_icon_keys() { + assert!(toml::from_str::("tray_icon_inverted = true").is_err()); + assert!(toml::from_str::("tray_icon_filled = true").is_err()); + } + + #[test] + fn selection_flow_defaults_to_enabled_when_missing() { + let settings: AppSettings = toml::from_str("").unwrap(); + + assert!(settings.selection_flow_enabled); + } - assert_eq!(tray_icon_inverted, baseline); - assert_eq!(tray_icon_filled, baseline); + #[test] + fn selection_flow_rejects_legacy_key() { + let input = r#" + selection_particles = false + "#; + + assert!(toml::from_str::(input).is_err()); } #[test] - fn window_capture_alpha_mode_preserve_alias_maps_to_background() { + fn window_capture_alpha_mode_rejects_legacy_preserve_value() { let input = r#" window_capture_alpha_mode = "preserve" "#; - let settings: AppSettings = toml::from_str(input).unwrap(); - assert_eq!(settings.window_capture_alpha_mode, WindowCaptureAlphaMode::Background); + assert!(toml::from_str::(input).is_err()); } #[test] diff --git a/apps/rsnap/src/settings_window/bench_support.rs b/apps/rsnap/src/settings_window/bench_support.rs index 0c81f32d..cfd6de7f 100644 --- a/apps/rsnap/src/settings_window/bench_support.rs +++ b/apps/rsnap/src/settings_window/bench_support.rs @@ -290,7 +290,7 @@ fn settings_for_scenario(scenario: SettingsUiBenchScenario) -> AppSettings { settings.hud_tint = 0.68; settings.hud_tint_hue = 0.88; settings.alt_activation = AltActivationMode::Toggle; - settings.selection_particles = true; + settings.selection_flow_enabled = true; settings.selection_flow_stroke_width_px = 6.4; settings.log_filter = Some(String::from("rsnap=trace,rsnap_overlay=trace")); settings.output_dir = diff --git a/apps/rsnap/src/settings_window/sections.rs b/apps/rsnap/src/settings_window/sections.rs index a666efc3..12f100a5 100644 --- a/apps/rsnap/src/settings_window/sections.rs +++ b/apps/rsnap/src/settings_window/sections.rs @@ -557,12 +557,12 @@ fn render_overlay_section(combo_width: f32, ui: &mut Ui, settings: &mut AppSetti changed |= ui.checkbox(&mut settings.show_alt_hint_keycap, "Show Alt hint in HUD").changed(); changed |= ui.checkbox(&mut settings.hud_glass_enabled, "Glass HUD").changed(); - changed |= ui.checkbox(&mut settings.selection_particles, "Selection particles").changed(); + changed |= ui.checkbox(&mut settings.selection_flow_enabled, "Selection flow").changed(); changed |= overlay_range_slider_row( ui, "Flow thickness", &mut settings.selection_flow_stroke_width_px, - settings.selection_particles, + settings.selection_flow_enabled, ); ui.add_space(SETTINGS_SECTION_GAP); diff --git a/packages/rsnap-overlay/src/backend.rs b/packages/rsnap-overlay/src/backend.rs index b30e0920..e5d5774e 100644 --- a/packages/rsnap-overlay/src/backend.rs +++ b/packages/rsnap-overlay/src/backend.rs @@ -7,7 +7,6 @@ use std::collections::HashMap; #[cfg(target_os = "macos")] use std::ffi::{CString, c_char, c_void}; -#[cfg(not(target_os = "macos"))] use std::process; #[cfg(target_os = "macos")] use std::ptr; @@ -288,6 +287,8 @@ pub struct XcapCaptureBackend { window_cache: Option>, window_cache_ttl: Duration, #[cfg(target_os = "macos")] + self_capture_exception_window_ids: Vec, + #[cfg(target_os = "macos")] live_frame_stream: MacLiveFrameStream, #[cfg(target_os = "macos")] last_region_capture: HashMap, @@ -313,6 +314,8 @@ impl XcapCaptureBackend { window_cache: None, window_cache_ttl: Duration::from_millis(250), #[cfg(target_os = "macos")] + self_capture_exception_window_ids: self_capture_exception_window_ids.clone(), + #[cfg(target_os = "macos")] live_frame_stream: MacLiveFrameStream::with_self_capture_exception_window_ids( self_capture_exception_window_ids, ), @@ -626,7 +629,11 @@ impl XcapCaptureBackend { } fn refresh_window_cache_impl(&mut self) -> Result> { - let windows = collect_window_geometries().wrap_err("failed to refresh window cache")?; + let windows = collect_window_geometries( + #[cfg(target_os = "macos")] + &self.self_capture_exception_window_ids, + ) + .wrap_err("failed to refresh window cache")?; let snapshot = Arc::new(WindowListSnapshot { captured_at: Instant::now(), windows: Arc::new(windows), @@ -1221,7 +1228,7 @@ fn capture_screenshot_cg_image(rect: CGRect) -> Result> { } #[cfg(target_os = "macos")] -fn collect_window_geometries() -> Result> { +fn collect_window_geometries(self_capture_exception_window_ids: &[u32]) -> Result> { let window_list_ref = unsafe { CGWindowListCopyWindowInfo( KCG_WINDOW_LIST_OPTION_ON_SCREEN_ONLY | KCG_WINDOW_LIST_OPTION_EXCLUDE_DESKTOP, @@ -1250,7 +1257,9 @@ fn collect_window_geometries() -> Result> { continue; }; - if let Some(window_geometry) = window_geometry_from_dictionary(window_dict) { + if let Some(window_geometry) = + window_geometry_from_dictionary(window_dict, self_capture_exception_window_ids) + { windows.push(window_geometry); } @@ -1261,9 +1270,13 @@ fn collect_window_geometries() -> Result> { } #[cfg(target_os = "macos")] -fn window_geometry_from_dictionary(window_dictionary: CFDictionaryRef) -> Option { +fn window_geometry_from_dictionary( + window_dictionary: CFDictionaryRef, + self_capture_exception_window_ids: &[u32], +) -> Option { let is_on_screen = cf_bool_value(window_dictionary, "kCGWindowIsOnscreen")?; let window_id = cf_number_to_u32(window_dictionary, "kCGWindowNumber"); + let owner_pid = cf_number_to_u32(window_dictionary, "kCGWindowOwnerPID"); let layer = cf_number_to_u64(window_dictionary, "kCGWindowLayer")?; let bounds_dict = cf_dictionary_value(window_dictionary, "kCGWindowBounds")?; let x = cf_number_to_i64(bounds_dict, "X")?; @@ -1274,10 +1287,27 @@ fn window_geometry_from_dictionary(window_dictionary: CFDictionaryRef) -> Option if !is_on_screen || layer > KCG_WINDOW_LAYER_MAX_FOR_TARGETING || width <= 0 || height <= 0 { return None; } + if should_exclude_current_process_window( + window_id, + owner_pid, + self_capture_exception_window_ids, + ) { + return None; + } Some(WindowRect { window_id, x, y, width, height }) } +#[cfg(target_os = "macos")] +fn should_exclude_current_process_window( + window_id: Option, + owner_pid: Option, + self_capture_exception_window_ids: &[u32], +) -> bool { + owner_pid.is_some_and(|pid| pid == process::id()) + && !window_id.is_some_and(|id| self_capture_exception_window_ids.contains(&id)) +} + #[cfg(target_os = "macos")] fn cf_dictionary_value(dictionary: CFDictionaryRef, key: &str) -> Option { let key_ref = cf_string_ref_for_key(key)?; @@ -1590,6 +1620,17 @@ mod tests { )); } + #[cfg(target_os = "macos")] + #[test] + fn current_process_windows_are_excluded_from_window_targeting_unless_excepted() { + let self_pid = std::process::id(); + + assert!(backend::should_exclude_current_process_window(Some(41), Some(self_pid), &[])); + assert!(!backend::should_exclude_current_process_window(Some(41), Some(self_pid), &[41],)); + assert!(!backend::should_exclude_current_process_window(Some(41), Some(self_pid + 1), &[])); + assert!(!backend::should_exclude_current_process_window(None, None, &[])); + } + #[test] fn normalize_capture_image_extent_pads_inward_rounded_edges_with_border_pixels() { let image = RgbaImage::from_vec( diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index dd1e2450..1d814808 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -243,6 +243,7 @@ const SLOW_OP_WARN_OUTER_POSITION: Duration = Duration::from_millis(24); const SLOW_OP_WARN_RENDER: Duration = Duration::from_millis(24); const SLOW_OP_WARN_WINDOW_EVENT: Duration = Duration::from_millis(40); const SLOW_OP_WARN_INTERVAL: Duration = Duration::from_secs(1); +const OCCLUDED_FRAME_REDRAW_RETRY_WINDOW: Duration = Duration::from_secs(2); const REDRAW_SUBSTEP_CONTRIBUTION_FLOOR: Duration = Duration::from_millis(4); // macOS trackpad/wheel sequences can keep delivering usable follow-up frames after the // initiating input event. Keep the observation window wide enough for the capture pipeline @@ -292,7 +293,10 @@ const SELECTION_FLOW_CORE_WIDTH_PX: f32 = 2.4; const SELECTION_FLOW_CORE_FLOW_WIDTH: f32 = 0.06; const SELECTION_FLOW_FLOW_BOOST: f32 = 2.8; const INTERACTIVE_REPAINT_FPS_CAP: f32 = 120.0; -const SELECTION_FLOW_PALETTE: [(u8, u8, u8); 3] = [(94, 200, 255), (165, 103, 255), (255, 150, 60)]; +const SELECTION_FLOW_PALETTE: [(u8, u8, u8); 3] = + [(196, 226, 255), (228, 198, 255), (176, 244, 224)]; +const SELECTION_FLOW_LIGHT_PALETTE: [(u8, u8, u8); 3] = + [(196, 226, 255), (228, 198, 255), (176, 244, 224)]; const SELECTION_FLOW_FROZEN_ALPHA_SCALE: f32 = 0.70; const SELECTION_FLOW_FROZEN_INTENSITY: f32 = 1.25; const LIVE_DRAG_SELECTION_SCRIM_ALPHA_LIGHT: u8 = 96; @@ -408,7 +412,6 @@ pub enum OutputNaming { /// Controls how transparent window captures are composited before export. pub enum WindowCaptureAlphaMode { #[default] - #[serde(alias = "preserve")] /// Preserve the observed screen background behind transparent pixels. Background, /// Composite transparency against a light matte color. @@ -580,8 +583,8 @@ pub struct OverlayConfig { pub show_alt_hint_keycap: bool, /// Enables blur or its platform fallback for HUD windows. pub show_hud_blur: bool, - /// Enables animated particles around the live selection border. - pub selection_particles: bool, + /// Enables the animated flow ring drawn around live and pending selections. + pub selection_flow_enabled: bool, /// Sets the core stroke width used for the animated selection border. pub selection_flow_stroke_width_px: f32, /// Forces an opaque HUD background instead of glass styling. @@ -619,7 +622,7 @@ impl Default for OverlayConfig { hud_anchor: HudAnchor::Cursor, show_alt_hint_keycap: true, show_hud_blur: true, - selection_particles: true, + selection_flow_enabled: true, selection_flow_stroke_width_px: SELECTION_FLOW_CORE_WIDTH_PX, hud_opaque: false, hud_opacity: 0.35, @@ -1355,7 +1358,6 @@ impl OverlaySession { self.mark_progress(OverlayEventLoopPhase::AboutToWait); self.maybe_request_keepalive_redraw(); self.maybe_keep_selection_flow_repaint(); - if self.is_active() { self.sync_alt_held_from_global_keys(); } @@ -1464,7 +1466,7 @@ impl OverlaySession { } fn maybe_keep_selection_flow_repaint(&self) { - if !self.is_active() || !self.config.selection_particles { + if !self.is_active() || !self.config.selection_flow_enabled { return; } @@ -1491,6 +1493,10 @@ impl OverlaySession { } 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) }) @@ -2307,7 +2313,6 @@ impl OverlaySession { 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(); } @@ -2337,10 +2342,9 @@ impl OverlaySession { || self.state.alt_held }); - if !needs_refresh - || now.duration_since(self.last_window_list_refresh_request_at) - < self.window_list_refresh_interval - { + let throttled = now.duration_since(self.last_window_list_refresh_request_at) + < self.window_list_refresh_interval; + if !needs_refresh || throttled { return false; } @@ -4321,7 +4325,7 @@ impl OverlaySession { self.config.hud_milk_amount, self.config.hud_tint_hue, self.config.theme_mode, - self.config.selection_particles, + self.config.selection_flow_enabled, self.config.selection_flow_stroke_width_px, false, false, @@ -6513,7 +6517,7 @@ impl OverlaySession { self.config.hud_milk_amount, self.config.hud_tint_hue, self.config.theme_mode, - self.config.selection_particles, + self.config.selection_flow_enabled, self.config.selection_flow_stroke_width_px, true, false, @@ -7326,11 +7330,6 @@ impl OverlaySession { ); } - let capture_in_progress = matches!(self.state.mode, OverlayMode::Frozen) - && self.state.monitor == Some(overlay_monitor) - && !self.frozen_final_capture_ready(); - let draw_selection_particles = - (self.config.selection_particles || self.scroll_capture.active) && !capture_in_progress; 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) @@ -7384,7 +7383,7 @@ impl OverlaySession { self.config.hud_milk_amount, self.config.hud_tint_hue, self.config.theme_mode, - draw_selection_particles, + self.config.selection_flow_enabled, self.config.selection_flow_stroke_width_px, !self.scroll_capture.active, self.scroll_capture.active, @@ -8275,6 +8274,27 @@ impl SurfaceFrameSkipReason { } } +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 + }, + }, + } +} + enum AcquiredSurfaceFrame { Ready(SurfaceTexture), Skipped(SurfaceFrameSkipReason), @@ -8963,8 +8983,13 @@ struct WindowRenderer { 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); @@ -9439,7 +9464,7 @@ impl WindowRenderer { hud_milk_amount: f32, hud_tint_hue: f32, theme: HudTheme, - selection_particles: bool, + selection_flow_enabled: bool, selection_flow_stroke_width_px: f32, needs_frozen_surface_bg: bool, show_frozen_capture_affordance: bool, @@ -9461,7 +9486,7 @@ impl WindowRenderer { None }; let mut hud_pill = None; - let mut _show_selection_particles = false; + 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(); @@ -9512,17 +9537,16 @@ impl WindowRenderer { ); let painter = ctx.layer_painter(layer); - _show_selection_particles |= Self::render_live_capture_affordances( + _show_selection_affordance |= Self::render_live_capture_affordances( ctx, &painter, state, monitor, screen_rect, theme, - selection_particles, + selection_flow_enabled, selection_flow_stroke_width_px, selection_flow_geometry_cache, - selection_dashed_border_cache, ); } if matches!(state.mode, OverlayMode::Frozen) @@ -9532,7 +9556,7 @@ impl WindowRenderer { { let screen_rect = ctx.input(|i| i.viewport_rect()); - _show_selection_particles |= Self::render_frozen_capture_affordance( + _show_selection_affordance |= Self::render_frozen_capture_affordance( ctx, state, monitor, @@ -9540,7 +9564,7 @@ impl WindowRenderer { theme, frozen_toolbar_reserved_rect, frozen_capture_is_fullscreen_fallback, - selection_particles, + selection_flow_enabled, selection_flow_stroke_width_px, selection_flow_geometry_cache, selection_dashed_border_cache, @@ -9559,10 +9583,9 @@ impl WindowRenderer { monitor: MonitorRect, screen_rect: Rect, theme: HudTheme, - selection_particles: 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 mut has_rect = false; @@ -9572,8 +9595,7 @@ impl WindowRenderer { let primary_not_down = !ctx.input(|i| i.pointer.primary_down()); - if selection_particles - && let Some(hovered_window) = state.hovered_window_rect + if let Some(hovered_window) = state.hovered_window_rect && hovered_window.monitor_id == monitor.id { let rect = Rect::from_min_size( @@ -9585,28 +9607,26 @@ impl WindowRenderer { if rect.width() >= LIVE_DRAG_START_THRESHOLD_PX && rect.height() >= LIVE_DRAG_START_THRESHOLD_PX { - Self::render_selection_flow_ring( - painter, - rect, - ctx, - theme, - SelectionFlowStyle::Band, - selection_flow_stroke_width_px, - selection_flow_geometry_cache, - ); + 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, - selection_dashed_border_cache, - ); + Self::render_live_drag_selection_scrim(painter, rect, screen_rect, theme); has_rect = true; } @@ -9632,7 +9652,7 @@ impl WindowRenderer { 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_particles + if selection_flow_enabled && !has_hovered_window_for_this_monitor && !has_drag_rect_for_this_monitor && cursor_on_monitor @@ -9663,7 +9683,7 @@ impl WindowRenderer { theme: HudTheme, frozen_toolbar_reserved_rect: Option, frozen_capture_is_fullscreen_fallback: bool, - selection_particles: bool, + selection_flow_enabled: bool, selection_flow_stroke_width_px: f32, selection_flow_geometry_cache: &mut SelectionFlowGeometryCache, selection_dashed_border_cache: &mut SelectionDashedBorderCache, @@ -9700,7 +9720,15 @@ impl WindowRenderer { return has_affordance; } - if !selection_particles { + 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, @@ -9712,10 +9740,10 @@ impl WindowRenderer { theme, ); - return true; + has_affordance = true; } - return false; + return has_affordance; } Self::render_selection_flow_ring( @@ -10190,14 +10218,12 @@ impl WindowRenderer { focus_rect: Rect, screen_rect: Rect, theme: HudTheme, - selection_dashed_border_cache: &mut SelectionDashedBorderCache, ) -> bool { - Self::render_selection_scrim( + Self::render_selection_scrim_fill( painter, focus_rect, screen_rect, Self::live_drag_selection_scrim_color(theme), - selection_dashed_border_cache, ) } @@ -10207,6 +10233,24 @@ impl WindowRenderer { 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; @@ -10221,14 +10265,7 @@ impl WindowRenderer { drew_scrim = true; } - let drew_border = Self::render_selection_dashed_border( - painter, - focus_rect, - screen_rect, - selection_dashed_border_cache, - ); - - drew_scrim || drew_border + drew_scrim } fn render_selection_dashed_border( @@ -10469,10 +10506,7 @@ impl WindowRenderer { return; } - let corner_radius = SELECTION_FLOW_CORNER_RADIUS_PX - .min(rect.width() / 2.0 - 0.25) - .min(rect.height() / 2.0 - 0.25) - .max(0.0); + 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); @@ -10488,10 +10522,7 @@ impl WindowRenderer { sample_count, seam_offset, ); - let base_alpha_scale = match theme { - HudTheme::Light => 0.86, - HudTheme::Dark => 1.0, - }; + let base_alpha_scale = 1.0; let stroke_width = selection_flow_stroke_width_px.clamp(1.0, 8.0); if samples.is_empty() { @@ -10525,6 +10556,20 @@ impl WindowRenderer { } } + 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, @@ -10888,7 +10933,7 @@ impl WindowRenderer { alpha_scale: f32, intensity: f32, ) -> Color32 { - let palette = SELECTION_FLOW_PALETTE; + 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(); @@ -10898,10 +10943,7 @@ impl WindowRenderer { 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 = match theme { - HudTheme::Dark => 1.0, - HudTheme::Light => 0.82, - }; + let theme_alpha = 1.0; let alpha = (255.0 * alpha_scale * intensity * theme_alpha).clamp(0.0, 255.0); Color32::from_rgba_unmultiplied( @@ -12415,6 +12457,7 @@ impl WindowRenderer { selection_flow_cache: SelectionFlowGeometryCache::default(), selection_dashed_border_cache: SelectionDashedBorderCache::default(), slow_op_logger: SlowOperationLogger::default(), + occluded_redraw_retry_until: None, }) } @@ -12551,7 +12594,11 @@ impl WindowRenderer { "Skipped overlay window frame acquisition." ); - if reason.should_request_redraw() { + if should_request_overlay_redraw_after_surface_skip( + reason, + Instant::now(), + &mut self.occluded_redraw_retry_until, + ) { self.window.request_redraw(); } @@ -12568,6 +12615,7 @@ impl WindowRenderer { &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(); @@ -12611,7 +12659,7 @@ impl WindowRenderer { hud_milk_amount: f32, hud_tint_hue: f32, theme_mode: ThemeMode, - selection_particles: bool, + selection_flow_enabled: bool, selection_flow_stroke_width_px: f32, allow_frozen_surface_bg: bool, show_frozen_capture_affordance: bool, @@ -12671,7 +12719,7 @@ impl WindowRenderer { hud_milk_amount, hud_tint_hue, theme, - selection_particles, + selection_flow_enabled, selection_flow_stroke_width_px, hud_cfg.needs_frozen_surface_bg, show_frozen_capture_affordance, @@ -13630,25 +13678,24 @@ mod tests { }; use crate::overlay::{ FrozenSelectionDragState, FrozenToolbarState, FrozenToolbarTool, - HUD_LOUPE_STRIP_GAP_POINTS, HudRedrawSummary, HudTheme, 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, - TOOLBAR_CAPTURE_GAP_PX, TOOLBAR_SCREEN_MARGIN_PX, ToolbarPlacement, Vec2, WindowRenderer, - hud_helpers, regular, + 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, + should_request_overlay_redraw_after_surface_skip, }; 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, + RectPoints, Rgb, WindowListSnapshot, WindowRect, }; #[cfg(target_os = "macos")] - use crate::state::{WindowListSnapshot, WindowRect}; - #[cfg(target_os = "macos")] use crate::worker::OverlayWorker; use crate::worker::{WorkerErrorSource, WorkerResponse}; @@ -14452,6 +14499,35 @@ mod tests { ); } + #[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)); @@ -15307,13 +15383,46 @@ mod tests { HudTheme::Dark, None, false, - 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 mut state = OverlayState::new(); + let mut selection_flow_geometry_cache = SelectionFlowGeometryCache::default(); + let selection_dashed_border_cache = SelectionDashedBorderCache::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(); @@ -16398,6 +16507,12 @@ mod tests { 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), @@ -19716,4 +19831,42 @@ mod tests { 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!(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!(should_request_overlay_redraw_after_surface_skip( + SurfaceFrameSkipReason::Occluded, + now + Duration::from_millis(500), + &mut retry_until, + )); + assert!(!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!(should_request_overlay_redraw_after_surface_skip( + SurfaceFrameSkipReason::Timeout, + now, + &mut retry_until, + )); + assert_eq!(retry_until, Some(retry_deadline)); + } } From 09cf7bf087532282ace566ad7886bdeb961c2046 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Sat, 4 Apr 2026 01:26:04 +0800 Subject: [PATCH 2/4] {"schema":"delivery/1","type":"fix","scope":"overlay","summary":"fix Linux lint regression in overlay tests","intent":"keep the selection flow hover branch green on shared Linux CI by restoring platform-aware test imports and repository style conformance in overlay tests","impact":"overlay test imports once again match their macOS-only usage and the associated backend and overlay test files now satisfy clippy, rustfmt, and vstyle without changing runtime behavior","breaking":false,"risk":"low","authority":"ci","delivery_mode":"status-only","refs":[]} --- packages/rsnap-overlay/src/backend.rs | 4 +- packages/rsnap-overlay/src/overlay.rs | 171 +++++++++++++------------- 2 files changed, 90 insertions(+), 85 deletions(-) diff --git a/packages/rsnap-overlay/src/backend.rs b/packages/rsnap-overlay/src/backend.rs index e5d5774e..b46cb7e2 100644 --- a/packages/rsnap-overlay/src/backend.rs +++ b/packages/rsnap-overlay/src/backend.rs @@ -1528,6 +1528,8 @@ fn xcap_find_monitor(monitor: MonitorRect) -> Result { #[cfg(test)] mod tests { + use std::process; + use image::RgbaImage; #[cfg(target_os = "macos")] use objc2_foundation::NSOperatingSystemVersion; @@ -1623,7 +1625,7 @@ mod tests { #[cfg(target_os = "macos")] #[test] fn current_process_windows_are_excluded_from_window_targeting_unless_excepted() { - let self_pid = std::process::id(); + let self_pid = process::id(); assert!(backend::should_exclude_current_process_window(Some(41), Some(self_pid), &[])); assert!(!backend::should_exclude_current_process_window(Some(41), Some(self_pid), &[41],)); diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index 1d814808..328f3eb7 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -574,6 +574,49 @@ impl DeviceCursorPointSource { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SelectionFlowStyle { + Band, + FullBorder, +} + +#[derive(Clone, Copy, Debug)] +enum WindowRendererPath { + Overlay, + LoupeTile, +} +impl WindowRendererPath { + const fn as_str(self) -> &'static str { + match self { + Self::Overlay => "overlay", + Self::LoupeTile => "loupe_tile", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SurfaceFrameSkipReason { + Timeout, + Occluded, +} +impl SurfaceFrameSkipReason { + const fn as_str(self) -> &'static str { + match self { + Self::Timeout => "timeout", + Self::Occluded => "occluded", + } + } + + const fn should_request_redraw(self) -> bool { + matches!(self, Self::Timeout) + } +} + +enum AcquiredSurfaceFrame { + Ready(SurfaceTexture), + Skipped(SurfaceFrameSkipReason), +} + #[derive(Clone, Debug)] /// Runtime configuration applied to a capture overlay session. pub struct OverlayConfig { @@ -1358,6 +1401,7 @@ impl OverlaySession { self.mark_progress(OverlayEventLoopPhase::AboutToWait); self.maybe_request_keepalive_redraw(); self.maybe_keep_selection_flow_repaint(); + if self.is_active() { self.sync_alt_held_from_global_keys(); } @@ -2313,6 +2357,7 @@ impl OverlaySession { 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(); } @@ -2341,9 +2386,9 @@ impl OverlaySession { 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; } @@ -8236,70 +8281,6 @@ impl Default for OverlaySession { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SelectionFlowStyle { - Band, - FullBorder, -} - -#[derive(Clone, Copy, Debug)] -enum WindowRendererPath { - Overlay, - LoupeTile, -} -impl WindowRendererPath { - const fn as_str(self) -> &'static str { - match self { - Self::Overlay => "overlay", - Self::LoupeTile => "loupe_tile", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SurfaceFrameSkipReason { - Timeout, - Occluded, -} -impl SurfaceFrameSkipReason { - const fn as_str(self) -> &'static str { - match self { - Self::Timeout => "timeout", - Self::Occluded => "occluded", - } - } - - const fn should_request_redraw(self) -> bool { - matches!(self, Self::Timeout) - } -} - -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 - }, - }, - } -} - -enum AcquiredSurfaceFrame { - Ready(SurfaceTexture), - Skipped(SurfaceFrameSkipReason), -} - #[cfg(target_os = "macos")] #[derive(Clone, Debug, Eq, PartialEq)] struct PendingRecognizeTextRequest { @@ -9624,7 +9605,6 @@ impl WindowRenderer { 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); @@ -13344,6 +13324,29 @@ struct MacOSCGPoint { 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, @@ -13667,17 +13670,8 @@ mod tests { use crate::overlay::PngAction; #[cfg(target_os = "macos")] use crate::overlay::session_state::ScrollCaptureLiveFrame; - #[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::overlay::{ - FrozenSelectionDragState, FrozenToolbarState, FrozenToolbarTool, + 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, @@ -13686,16 +13680,26 @@ mod tests { SelectionDashedBorderCache, SelectionDashedBorderMetrics, SelectionFlowGeometryCache, SelectionSizeBadgeTarget, SurfaceFrameSkipReason, TOOLBAR_CAPTURE_GAP_PX, TOOLBAR_SCREEN_MARGIN_PX, ToolbarPlacement, Vec2, WindowRenderer, hud_helpers, regular, - should_request_overlay_redraw_after_surface_skip, + }; + #[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, WindowListSnapshot, WindowRect, + RectPoints, Rgb, }; #[cfg(target_os = "macos")] + use crate::state::{WindowListSnapshot, WindowRect}; + #[cfg(target_os = "macos")] use crate::worker::OverlayWorker; use crate::worker::{WorkerErrorSource, WorkerResponse}; @@ -15399,9 +15403,9 @@ mod tests { 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(); - let selection_dashed_border_cache = SelectionDashedBorderCache::default(); state.mode = OverlayMode::Live; state.hovered_window_rect = Some(MonitorRectPoints { @@ -16512,7 +16516,6 @@ mod tests { 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), @@ -19837,18 +19840,18 @@ mod tests { let now = Instant::now(); let mut retry_until = None; - assert!(should_request_overlay_redraw_after_surface_skip( + 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!(should_request_overlay_redraw_after_surface_skip( + assert!(overlay::should_request_overlay_redraw_after_surface_skip( SurfaceFrameSkipReason::Occluded, now + Duration::from_millis(500), &mut retry_until, )); - assert!(!should_request_overlay_redraw_after_surface_skip( + assert!(!overlay::should_request_overlay_redraw_after_surface_skip( SurfaceFrameSkipReason::Occluded, now + OCCLUDED_FRAME_REDRAW_RETRY_WINDOW, &mut retry_until, @@ -19862,7 +19865,7 @@ mod tests { let retry_deadline = now + Duration::from_millis(250); let mut retry_until = Some(retry_deadline); - assert!(should_request_overlay_redraw_after_surface_skip( + assert!(overlay::should_request_overlay_redraw_after_surface_skip( SurfaceFrameSkipReason::Timeout, now, &mut retry_until, From 2fcdb2dc0c80c8cb17cac8606a5b8e43da55584d Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Sat, 4 Apr 2026 01:31:03 +0800 Subject: [PATCH 3/4] {"schema":"delivery/1","type":"fix","scope":"backend","summary":"fix Linux-only backend test import","intent":"remove the remaining macOS-only backend test import from Linux lint paths after the selection flow hover repair branch was updated","impact":"backend tests now gate process-id access to the same macOS-only path as the test that uses it, eliminating the last Linux clippy failure without affecting runtime behavior","breaking":false,"risk":"low","authority":"ci","delivery_mode":"status-only","refs":[]} --- packages/rsnap-overlay/src/backend.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/rsnap-overlay/src/backend.rs b/packages/rsnap-overlay/src/backend.rs index b46cb7e2..2d9843d3 100644 --- a/packages/rsnap-overlay/src/backend.rs +++ b/packages/rsnap-overlay/src/backend.rs @@ -1528,6 +1528,7 @@ fn xcap_find_monitor(monitor: MonitorRect) -> Result { #[cfg(test)] mod tests { + #[cfg(target_os = "macos")] use std::process; use image::RgbaImage; From 6960eda820062b1151a88e1c33a7b4709d26ddc1 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Sat, 4 Apr 2026 01:49:36 +0800 Subject: [PATCH 4/4] {"schema":"delivery/1","type":"fix","scope":"settings","summary":"quarantine invalid settings files on load","intent":"reject stale settings keys without letting one parse error silently reset the rest of the user config on the next save","impact":"invalid settings.toml files are moved aside to settings.invalid backups before rsnap falls back to defaults, and regression coverage now locks the non-destructive load path","breaking":false,"risk":"medium","authority":"review","delivery_mode":"status-only","refs":[]} --- apps/rsnap/src/settings.rs | 95 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/apps/rsnap/src/settings.rs b/apps/rsnap/src/settings.rs index 5965e583..3369e998 100644 --- a/apps/rsnap/src/settings.rs +++ b/apps/rsnap/src/settings.rs @@ -90,13 +90,31 @@ impl AppSettings { let Some(path) = Self::path() else { return Self::default(); }; - let Ok(bytes) = fs::read(&path) else { + + Self::load_from_path(&path) + } + + #[must_use] + fn load_from_path(path: &Path) -> Self { + let Ok(bytes) = fs::read(path) else { return Self::default(); }; let Ok(contents) = std::str::from_utf8(&bytes) else { + quarantine_invalid_settings_file(path, "Settings file is not valid UTF-8"); + return Self::default(); }; - let mut settings: Self = toml::from_str(contents).unwrap_or_default(); + let mut settings: Self = match toml::from_str(contents) { + Ok(settings) => settings, + Err(err) => { + quarantine_invalid_settings_file( + path, + &format!("Failed to parse settings TOML: {err}"), + ); + + return Self::default(); + }, + }; settings.capture_hotkey = sanitize_capture_hotkey(&settings.capture_hotkey) .unwrap_or_else(default_capture_hotkey); @@ -305,9 +323,51 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { Ok(()) } +fn quarantine_invalid_settings_file(path: &Path, message: &str) { + let backup_path = invalid_settings_backup_path(path); + + match fs::rename(path, &backup_path) { + Ok(()) => eprintln!( + "{message}. Moved invalid settings file from {:?} to {:?}.", + path, backup_path + ), + Err(err) => eprintln!( + "{message}. Failed to move invalid settings file {:?} to {:?}: {err}", + path, backup_path + ), + } +} + +fn invalid_settings_backup_path(path: &Path) -> PathBuf { + let parent = path.parent().map(Path::to_path_buf).unwrap_or_default(); + let stem = path.file_stem().and_then(|stem| stem.to_str()).unwrap_or("settings"); + let ext = path.extension().and_then(|ext| ext.to_str()); + + for suffix in 0_u32.. { + let candidate_name = match (ext, suffix) { + (Some(ext), 0) => format!("{stem}.invalid.{ext}"), + (Some(ext), suffix) => format!("{stem}.invalid-{suffix}.{ext}"), + (None, 0) => format!("{stem}.invalid"), + (None, suffix) => format!("{stem}.invalid-{suffix}"), + }; + let candidate = parent.join(candidate_name); + + if !candidate.exists() { + return candidate; + } + } + + unreachable!("u32 suffix space exhausted for invalid settings backups") +} + #[cfg(test)] mod tests { - use std::path::PathBuf; + use std::{ + env, fs, + path::PathBuf, + process, + time::{SystemTime, UNIX_EPOCH}, + }; use crate::settings::{AltActivationMode, AppSettings, LoupeSampleSize}; use rsnap_overlay::{OutputNaming, ThemeMode, ToolbarPlacement, WindowCaptureAlphaMode}; @@ -406,4 +466,33 @@ mod tests { assert_eq!(sanitized, "rsnap__demo"); } + + #[test] + fn load_quarantines_invalid_settings_instead_of_silently_dropping_them() { + let root = env::temp_dir().join(format!( + "rsnap-settings-test-{}-{}", + process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(), + )); + + fs::create_dir_all(&root).unwrap(); + + let settings_path = root.join("settings.toml"); + let original = r#" +show_alt_hint_keycap = false +selection_particles = false +"#; + + fs::write(&settings_path, original).unwrap(); + + let settings = AppSettings::load_from_path(&settings_path); + let backup_path = root.join("settings.invalid.toml"); + + assert_eq!(settings, AppSettings::default()); + assert!(!settings_path.exists()); + assert_eq!(fs::read_to_string(&backup_path).unwrap(), original); + + fs::remove_file(&backup_path).unwrap(); + fs::remove_dir(&root).unwrap(); + } }