From 150ac4e5b2fc94ac195fb5c1985d99bb85e820b3 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Sat, 4 Apr 2026 01:59:21 +0800 Subject: [PATCH 1/3] {"schema":"delivery/1","type":"fix","scope":"overlay","summary":"change loupe activation from Alt to Tab","intent":"move loupe activation off Alt so hold and toggle behavior run from Tab without letting unrelated Tab chords or Tab-held plain-character shortcuts change overlay state","impact":"the overlay now drives loupe activation from plain Tab key events, ignores modified Tab chords, blocks frozen plain-character shortcuts while Tab is physically down, removes the conflicting Tab copy-hex shortcut, gates text clipboard export to macOS so Linux lint stays clean, and updates the HUD, settings labels, and docs to match the new activation key","breaking":false,"risk":"low","authority":"review","delivery_mode":"status-only","refs":[]} --- README.md | 2 +- apps/rsnap/src/settings_window/chrome.rs | 4 +- apps/rsnap/src/settings_window/sections.rs | 4 +- docs/spec/v0.md | 6 +- packages/rsnap-overlay/src/overlay.rs | 217 ++++++++----------- packages/rsnap-overlay/src/overlay/output.rs | 1 + 6 files changed, 103 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index beacb363..7e992d37 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Pure-Rust menubar screenshot prototype (macOS-first). - Upward scrolling may be observed for rewind/reacquire, but it never appends stitched rows. - `Esc` cancels capture; during scroll capture, `Esc` / `Back` returns to normal Frozen mode. - Glass HUD with configurable blur, tint, and hue controls. -- Alt-triggered loupe sample and frozen-mode toolbar for quick action access. +- Tab-triggered loupe sample and frozen-mode toolbar for quick action access. ## Status diff --git a/apps/rsnap/src/settings_window/chrome.rs b/apps/rsnap/src/settings_window/chrome.rs index 018f85cf..056fd583 100644 --- a/apps/rsnap/src/settings_window/chrome.rs +++ b/apps/rsnap/src/settings_window/chrome.rs @@ -54,11 +54,11 @@ impl SettingsWindow { "Output directory", "Filename prefix", "Filename naming", - "Show Alt hint in HUD", + "Show Tab hint in HUD", "Glass HUD", "Selection particles", "Flow thickness", - "Alt activation", + "Loupe activation", "Loupe sample size", "Opacity", "Blur", diff --git a/apps/rsnap/src/settings_window/sections.rs b/apps/rsnap/src/settings_window/sections.rs index 12f100a5..ed7418a8 100644 --- a/apps/rsnap/src/settings_window/sections.rs +++ b/apps/rsnap/src/settings_window/sections.rs @@ -555,7 +555,7 @@ fn render_general_section( fn render_overlay_section(combo_width: f32, ui: &mut Ui, settings: &mut AppSettings) -> bool { let mut changed = false; - changed |= ui.checkbox(&mut settings.show_alt_hint_keycap, "Show Alt hint in HUD").changed(); + changed |= ui.checkbox(&mut settings.show_alt_hint_keycap, "Show Tab hint in HUD").changed(); changed |= ui.checkbox(&mut settings.hud_glass_enabled, "Glass HUD").changed(); changed |= ui.checkbox(&mut settings.selection_flow_enabled, "Selection flow").changed(); changed |= overlay_range_slider_row( @@ -571,7 +571,7 @@ fn render_overlay_section(combo_width: f32, ui: &mut Ui, settings: &mut AppSetti let before_alt = settings.alt_activation; - ComboBox::from_label("Alt activation") + ComboBox::from_label("Loupe activation") .selected_text(alt_activation_label(settings.alt_activation)) .width(combo_width) .show_ui(ui, |ui| { diff --git a/docs/spec/v0.md b/docs/spec/v0.md index 2bdd084d..3901e62b 100644 --- a/docs/spec/v0.md +++ b/docs/spec/v0.md @@ -112,7 +112,7 @@ cross-platform architecture. Rendering is implemented through three floating widgets: - Main HUD (live info + action hint) -- Loupe (Alt-held magnified sample) +- Loupe (Tab-held magnified sample) - Frozen toolbar (only visible in frozen + captured states) All three are styled using the same HUD styling pipeline. @@ -143,7 +143,7 @@ All three are styled using the same HUD styling pipeline. - HUD blur: `50` (stored `0.5`) - Tint amount: `50` (stored `0.5`) - Hue: `215` (stored `215.0 / 360.0`) - - Alt mode: `Hold` + - Loupe activation: `Hold` (`Tab`) - Loupe sample size: `Medium (21x21)` - Toolbar placement: `Bottom` - Slider semantics: @@ -189,7 +189,7 @@ Research and cross-platform notes live in: ## HUD/toolbar lifecycle - All floating HUD windows are created at overlay start. -- In Frozen mode, loupe/toolbar visibility follows Alt + current mode state and +- In Frozen mode, loupe/toolbar visibility follows Tab + current mode state and `show_frozen_capture` state. ## Non-goals (v0) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index 328f3eb7..4a7a25b7 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -31,7 +31,7 @@ use std::{ use color_eyre::eyre::{self, Result, WrapErr}; #[cfg(not(target_os = "macos"))] -use device_query::{DeviceQuery, Keycode}; +use device_query::DeviceQuery; use egui::FullOutput; use egui::Mesh; use egui::Painter; @@ -324,8 +324,6 @@ const SCROLL_CAPTURE_PREVIEW_WIDTH_PX: u32 = 320; #[cfg(target_os = "macos")] const KCG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE: u32 = 0; #[cfg(target_os = "macos")] -const KCG_EVENT_FLAGS_MASK_ALTERNATE: u64 = 1_u64 << 19; -#[cfg(target_os = "macos")] const STARTUP_LIVE_SAMPLE_WAIT_TIMEOUT: Duration = Duration::from_millis(120); #[cfg(target_os = "macos")] const STARTUP_LIVE_SAMPLE_WAIT_POLL_INTERVAL: Duration = Duration::from_millis(4); @@ -376,12 +374,12 @@ pub enum OverlayControl { #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] -/// Controls how the Alt-triggered loupe interaction is activated. +/// Controls how the Tab-triggered loupe interaction is activated. pub enum AltActivationMode { #[default] - /// Enable the loupe only while Alt is held. + /// Enable the loupe only while Tab is held. Hold, - /// Toggle the loupe on and off with Alt presses. + /// Toggle the loupe on and off with Tab presses. Toggle, } @@ -622,7 +620,7 @@ enum AcquiredSurfaceFrame { pub struct OverlayConfig { /// Positions the live HUD relative to the cursor or another anchor point. pub hud_anchor: HudAnchor, - /// Shows the Alt-key hint chip in the live HUD when enabled. + /// Shows the Tab-key hint chip in the live HUD when enabled. pub show_alt_hint_keycap: bool, /// Enables blur or its platform fallback for HUD windows. pub show_hud_blur: bool, @@ -640,7 +638,7 @@ pub struct OverlayConfig { pub hud_milk_amount: f32, /// Hue value for tint, 0..=1. pub hud_tint_hue: f32, - /// Selects whether Alt must be held or can toggle the loupe. + /// Selects whether Tab must be held or can toggle the loupe. pub alt_activation: AltActivationMode, /// Chooses where the frozen toolbar is placed. pub toolbar_placement: ToolbarPlacement, @@ -743,8 +741,7 @@ pub struct OverlaySession { live_sample_stall_started_at: Option, last_live_sample_stall_log_at: Option, slow_op_logger: SlowOperationLogger, - last_alt_press_at: Option, - alt_modifier_down: bool, + loupe_activation_key_down: bool, keyboard_modifiers: ModifiersState, event_loop_phase: OverlayEventLoopPhase, event_loop_progress_seq: u64, @@ -916,8 +913,7 @@ impl OverlaySession { live_sample_stall_started_at: None, last_live_sample_stall_log_at: None, slow_op_logger: SlowOperationLogger::default(), - last_alt_press_at: None, - alt_modifier_down: false, + loupe_activation_key_down: false, keyboard_modifiers: ModifiersState::default(), event_loop_phase: OverlayEventLoopPhase::Idle, event_loop_progress_seq: 0, @@ -1401,11 +1397,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(); - } - self.maybe_keep_frozen_capture_redraw(); self.maybe_tick_toolbar_window_warmup_redraw(); self.maybe_tick_loupe_window_warmup_redraw(); @@ -4458,42 +4449,7 @@ impl OverlaySession { fn handle_modifiers_changed(&mut self, modifiers: &winit::event::Modifiers) -> OverlayControl { self.keyboard_modifiers = modifiers.state(); - let alt = self.resolve_alt_modifier_state(self.keyboard_modifiers.alt_key()); - - if !self.apply_alt_input_state(alt) { - return OverlayControl::Continue; - } - - self.request_redraw_for_alt_state_change() - } - - fn resolve_alt_modifier_state(&mut self, alt: bool) -> bool { - let transient_alt_release = !alt - && self.state.alt_held - && self - .last_alt_press_at - .is_some_and(|press| press.elapsed() <= Duration::from_millis(120)) - && self.is_option_key_down(); - - if transient_alt_release { true } else { alt } - } - - #[cfg(not(target_os = "macos"))] - fn is_option_key_down(&self) -> bool { - let Some(cursor_device) = self.cursor_device.as_ref() else { - return false; - }; - let keys = cursor_device.get_keys(); - - keys.contains(&Keycode::LOption) - || keys.contains(&Keycode::ROption) - || keys.contains(&Keycode::LAlt) - || keys.contains(&Keycode::RAlt) - } - - #[cfg(target_os = "macos")] - fn is_option_key_down(&self) -> bool { - macos_is_option_key_down() + OverlayControl::Continue } #[cfg(not(target_os = "macos"))] @@ -4540,14 +4496,6 @@ impl OverlaySession { Some(event_cursor) } - fn sync_alt_held_from_global_keys(&mut self) { - let alt = self.is_option_key_down(); - - if self.apply_alt_input_state(alt) { - let _ = self.request_redraw_for_alt_state_change(); - } - } - fn set_alt_held(&mut self, alt: bool) { if self.state.alt_held == alt { return; @@ -4561,8 +4509,6 @@ impl OverlaySession { return; } - self.last_alt_press_at = Some(Instant::now()); - let Some((monitor, cursor)) = self.alt_activation_cursor_context() else { return; }; @@ -4579,22 +4525,21 @@ impl OverlaySession { } } - fn apply_alt_input_state(&mut self, alt: bool) -> bool { + fn apply_loupe_activation_input(&mut self, pressed: bool, repeat: bool) -> bool { let previous_alt_held = self.state.alt_held; - let previous_alt_modifier_down = self.alt_modifier_down; + + self.loupe_activation_key_down = pressed; match self.config.alt_activation { - AltActivationMode::Hold => self.set_alt_held(alt), + AltActivationMode::Hold => self.set_alt_held(pressed), AltActivationMode::Toggle => { - if alt && !self.alt_modifier_down { + if pressed && !repeat { self.set_alt_held(!self.state.alt_held); } }, } - self.alt_modifier_down = alt; - - previous_alt_held != self.state.alt_held || previous_alt_modifier_down != alt + previous_alt_held != self.state.alt_held } fn request_redraw_for_alt_state_change(&mut self) -> OverlayControl { @@ -4656,7 +4601,6 @@ impl OverlaySession { } fn handle_alt_release(&mut self) { - self.last_alt_press_at = None; self.state.loupe = None; self.loupe_outer_pos = None; self.pending_loupe_outer_pos = None; @@ -5803,10 +5747,16 @@ impl OverlaySession { } fn handle_key_event(&mut self, event: &KeyEvent) -> OverlayControl { - if matches!(event.logical_key, Key::Named(NamedKey::Alt)) - && self.apply_alt_input_state(event.state == ElementState::Pressed) - { - return self.request_redraw_for_alt_state_change(); + if matches!(event.logical_key, Key::Named(NamedKey::Tab)) { + let pressed = event.state == ElementState::Pressed; + let loupe_activation_event = (pressed && self.loupe_activation_shortcut_available()) + || (!pressed && self.loupe_activation_key_down); + + if loupe_activation_event && self.apply_loupe_activation_input(pressed, event.repeat) { + return self.request_redraw_for_alt_state_change(); + } + + return OverlayControl::Continue; } if event.state != ElementState::Pressed { return OverlayControl::Continue; @@ -5820,23 +5770,10 @@ impl OverlaySession { match &event.logical_key { Key::Named(NamedKey::Escape) => self.cancel_overlay("escape_key"), - Key::Named(NamedKey::Tab) => { - let Some(rgb) = self.state.rgb else { - return OverlayControl::Continue; - }; - let hex = rgb.hex_upper(); - - match output::write_text_to_clipboard(&hex) { - Ok(()) => {}, - Err(err) => { - self.state.set_error(format!("{err:#}")); - self.request_redraw_all(); - }, - } - - OverlayControl::Continue - }, - Key::Character(key_text) if key_text == "h" || key_text == "H" => { + 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(); @@ -5859,7 +5796,10 @@ impl OverlaySession { OverlayControl::Continue }, - Key::Character(key_text) if key_text.as_str().eq_ignore_ascii_case("s") => { + 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(); @@ -5899,8 +5839,16 @@ impl OverlaySession { } } + 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.keyboard_modifiers.alt_key() + !self.loupe_activation_key_down + && !self.keyboard_modifiers.alt_key() && !self.keyboard_modifiers.control_key() && !self.keyboard_modifiers.super_key() } @@ -7742,6 +7690,7 @@ impl OverlaySession { self.ocr_inflight = false; self.png_encode_inflight = false; } + self.loupe_activation_key_down = false; self.keyboard_modifiers = ModifiersState::default(); } @@ -11813,7 +11762,7 @@ impl WindowRenderer { ..Frame::default() } .show(ui, |ui| { - ui.label(RichText::new("Alt").color(keycap_text).monospace()); + ui.label(RichText::new("Tab").color(keycap_text).monospace()); }); } }); @@ -13394,13 +13343,6 @@ fn frozen_toolbar_matches_default_slot(toolbar_pos: Pos2, default_pos: Pos2) -> && dy <= TOOLBAR_DEFAULT_SLOT_POSITION_EPSILON_POINTS } -#[cfg(target_os = "macos")] -fn macos_is_option_key_down() -> bool { - let flags = unsafe { CGEventSourceFlagsState(macos_hid_event_source_state_id()) }; - - flags & KCG_EVENT_FLAGS_MASK_ALTERNATE != 0 -} - #[cfg(target_os = "macos")] fn macos_hid_event_source_state_id() -> u32 { KCG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE @@ -13428,7 +13370,6 @@ unsafe extern "C" { ) -> CGEventRef; fn CGEventPost(tap_location: u32, event: CGEventRef); fn CGEventSetLocation(event: CGEventRef, location: MacOSCGPoint); - fn CGEventSourceFlagsState(source_state_id: u32) -> u64; } #[cfg(target_os = "macos")] @@ -16249,8 +16190,7 @@ mod tests { pending_self_capture_exception_window_ids_worker_refresh: true, authoritative_frozen_capture_ready: true, capture_windows_hidden: true, - last_alt_press_at: Some(Instant::now()), - alt_modifier_down: true, + loupe_activation_key_down: true, keyboard_modifiers: ModifiersState::SHIFT, left_mouse_button_down: true, left_mouse_button_down_monitor: Some(test_monitor()), @@ -16270,8 +16210,7 @@ mod tests { assert!(!session.pending_self_capture_exception_window_ids_worker_refresh); assert!(!session.authoritative_frozen_capture_ready); assert!(!session.capture_windows_hidden); - assert!(session.last_alt_press_at.is_none()); - assert!(!session.alt_modifier_down); + 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()); @@ -16374,41 +16313,73 @@ mod tests { #[cfg(target_os = "macos")] #[test] - fn apply_alt_input_state_toggle_uses_press_edges_only() { + fn apply_loupe_activation_input_toggle_ignores_release_and_repeat() { let mut session = OverlaySession::new(); session.config.alt_activation = AltActivationMode::Toggle; - assert!(session.apply_alt_input_state(true)); + assert!(session.apply_loupe_activation_input(true, false)); assert!(session.state.alt_held); - assert!(session.alt_modifier_down); - assert!(!session.apply_alt_input_state(true)); + assert!(!session.apply_loupe_activation_input(true, true)); assert!(session.state.alt_held); - assert!(session.alt_modifier_down); - assert!(session.apply_alt_input_state(false)); + assert!(!session.apply_loupe_activation_input(false, false)); assert!(session.state.alt_held); - assert!(!session.alt_modifier_down); - assert!(session.apply_alt_input_state(true)); + assert!(session.apply_loupe_activation_input(true, false)); assert!(!session.state.alt_held); - assert!(session.alt_modifier_down); } #[cfg(target_os = "macos")] #[test] - fn apply_alt_input_state_hold_tracks_polled_key_state() { + fn apply_loupe_activation_input_hold_tracks_pressed_state() { let mut session = OverlaySession::new(); session.config.alt_activation = AltActivationMode::Hold; - assert!(session.apply_alt_input_state(true)); + assert!(session.apply_loupe_activation_input(true, false)); assert!(session.state.alt_held); - assert!(session.alt_modifier_down); - assert!(!session.apply_alt_input_state(true)); + assert!(!session.apply_loupe_activation_input(true, false)); assert!(session.state.alt_held); - assert!(session.alt_modifier_down); - assert!(session.apply_alt_input_state(false)); + assert!(session.apply_loupe_activation_input(false, false)); assert!(!session.state.alt_held); - assert!(!session.alt_modifier_down); + } + + #[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_input(true, false)); + assert!(!session.plain_character_shortcut_available()); + assert!(!session.apply_loupe_activation_input(false, false)); + assert!(session.state.alt_held); + 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")] diff --git a/packages/rsnap-overlay/src/overlay/output.rs b/packages/rsnap-overlay/src/overlay/output.rs index b14694f1..4db2f212 100644 --- a/packages/rsnap-overlay/src/overlay/output.rs +++ b/packages/rsnap-overlay/src/overlay/output.rs @@ -93,6 +93,7 @@ pub(super) fn write_png_bytes_to_clipboard(png_bytes: &[u8]) -> Result<()> { Ok(()) } +#[cfg(target_os = "macos")] pub(super) fn write_text_to_clipboard(text: &str) -> Result<()> { let mut clipboard = Clipboard::new().wrap_err("Failed to initialize clipboard")?; From 138cd7c8393d0ab4f874f28e3bae03fce0b38421 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Sat, 4 Apr 2026 03:31:24 +0800 Subject: [PATCH 2/3] {"schema":"delivery/1","type":"fix","scope":"overlay","summary":"clear loupe activation on focus loss","intent":"release Tab-driven loupe activation when the overlay loses focus so missed key-up events do not leave hold mode latched or keep plain-character shortcuts blocked","impact":"the overlay now clears loupe activation state on Focused(false), routes scroll preview window dispatch through a helper without changing behavior, and adds hold-mode plus toggle-mode regression tests for focus-loss cleanup","breaking":false,"risk":"low","authority":"review","delivery_mode":"status-only","refs":[]} --- packages/rsnap-overlay/src/overlay.rs | 103 +++++++++++++++++++++----- 1 file changed, 83 insertions(+), 20 deletions(-) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index 4a7a25b7..e0ae4fbf 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -3980,28 +3980,16 @@ impl OverlaySession { self.maybe_log_event_loop_stall(now); self.mark_progress_with_detail(OverlayEventLoopPhase::WindowEvent, Some(kind)); - if let WindowEvent::MouseInput { state, button, .. } = event { - self.maybe_stop_frozen_selection_drag_for_mouse_input(*state, *button); + match event { + WindowEvent::MouseInput { state, button, .. } => { + self.maybe_stop_frozen_selection_drag_for_mouse_input(*state, *button); + }, + WindowEvent::Focused(false) => self.clear_loupe_activation_on_focus_loss(), + _ => {}, } - if self - .scroll_preview_window - .as_ref() - .is_some_and(|preview_window| preview_window.window.id() == window_id) - { - return 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), - }; + if let Some(control) = self.handle_scroll_preview_event(window_id, event) { + return control; } let toolbar_window_id = self @@ -4087,6 +4075,32 @@ impl OverlaySession { 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 handle_toolbar_mouse_input(&mut self, state: ElementState) -> OverlayControl { let toolbar_left_button_down = matches!(state, ElementState::Pressed); @@ -4542,6 +4556,19 @@ impl OverlaySession { previous_alt_held != self.state.alt_held } + 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; + } + if self.apply_loupe_activation_input(false, false) { + let _ = self.request_redraw_for_alt_state_change(); + } + } + fn request_redraw_for_alt_state_change(&mut self) -> OverlayControl { if matches!(self.state.mode, OverlayMode::Live) { self.request_redraw_hud_window(); @@ -16358,6 +16385,42 @@ mod tests { 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_input(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_input(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() { From c40627933500160a1d753d207f5071cfd2366e30 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Sat, 4 Apr 2026 09:27:02 +0800 Subject: [PATCH 3/3] {"schema":"delivery/1","type":"fix","scope":"overlay","summary":"repair loupe focus and modified-tab state tracking","intent":"preserve Tab-held loupe behavior across internal overlay focus transfers while still clearing activation after true focus loss and keeping plain-character shortcut suppression aligned with the physical Tab key state","impact":"the overlay now defers focus-loss cleanup until no overlay-owned windows remain focused, tracks Tab-down state even for modified Tab chords without activating the loupe, and adds regression tests for internal focus transfers, external focus loss, and modified-Tab shortcut suppression","breaking":false,"risk":"low","authority":"review","delivery_mode":"status-only","refs":[]} --- packages/rsnap-overlay/src/overlay.rs | 136 +++++++++++++++++++++++--- 1 file changed, 125 insertions(+), 11 deletions(-) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index e0ae4fbf..ffd5323a 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -23,7 +23,7 @@ use std::thread; use std::{ borrow::Cow, cmp::Ordering, - collections::HashMap, + collections::{HashMap, HashSet}, path::PathBuf, sync::{Arc, Mutex}, time::{Duration, Instant}, @@ -697,6 +697,8 @@ pub struct OverlaySession { cursor_monitor: Option, egui_repaint_deadline: Arc>>, windows: HashMap, + focused_window_ids: HashSet, + pending_focus_loss_cleanup: bool, hud_window: Option, loupe_window: Option, toolbar_window: Option, @@ -869,6 +871,8 @@ impl OverlaySession { state: runtime.state, cursor_monitor: None, windows: HashMap::new(), + focused_window_ids: HashSet::new(), + pending_focus_loss_cleanup: false, hud_window: None, loupe_window: None, toolbar_window: None, @@ -1395,6 +1399,7 @@ impl OverlaySession { 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(); @@ -3984,7 +3989,9 @@ impl OverlaySession { WindowEvent::MouseInput { state, button, .. } => { self.maybe_stop_frozen_selection_drag_for_mouse_input(*state, *button); }, - WindowEvent::Focused(false) => self.clear_loupe_activation_on_focus_loss(), + WindowEvent::Focused(focused) => { + self.note_window_focus_change(window_id, *focused); + }, _ => {}, } @@ -4101,6 +4108,22 @@ impl OverlaySession { }) } + 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); @@ -4542,8 +4565,6 @@ impl OverlaySession { fn apply_loupe_activation_input(&mut self, pressed: bool, repeat: bool) -> bool { let previous_alt_held = self.state.alt_held; - self.loupe_activation_key_down = pressed; - match self.config.alt_activation { AltActivationMode::Hold => self.set_alt_held(pressed), AltActivationMode::Toggle => { @@ -4556,6 +4577,19 @@ impl OverlaySession { 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) @@ -4564,11 +4598,24 @@ impl OverlaySession { 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(); @@ -5776,10 +5823,8 @@ impl OverlaySession { fn handle_key_event(&mut self, event: &KeyEvent) -> OverlayControl { if matches!(event.logical_key, Key::Named(NamedKey::Tab)) { let pressed = event.state == ElementState::Pressed; - let loupe_activation_event = (pressed && self.loupe_activation_shortcut_available()) - || (!pressed && self.loupe_activation_key_down); - if loupe_activation_event && self.apply_loupe_activation_input(pressed, event.repeat) { + if self.apply_loupe_activation_key_event(pressed, event.repeat) { return self.request_redraw_for_alt_state_change(); } @@ -7717,6 +7762,10 @@ impl OverlaySession { 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(); } @@ -13627,6 +13676,8 @@ mod tests { 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; @@ -16378,9 +16429,9 @@ mod tests { session.config.alt_activation = AltActivationMode::Toggle; assert!(session.plain_character_shortcut_available()); - assert!(session.apply_loupe_activation_input(true, false)); + assert!(session.apply_loupe_activation_key_event(true, false)); assert!(!session.plain_character_shortcut_available()); - assert!(!session.apply_loupe_activation_input(false, false)); + assert!(!session.apply_loupe_activation_key_event(false, false)); assert!(session.state.alt_held); assert!(session.plain_character_shortcut_available()); } @@ -16392,7 +16443,7 @@ mod tests { session.config.alt_activation = AltActivationMode::Hold; - assert!(session.apply_loupe_activation_input(true, false)); + assert!(session.apply_loupe_activation_key_event(true, false)); assert!(session.state.alt_held); assert!(session.loupe_activation_key_down); @@ -16410,7 +16461,7 @@ mod tests { session.config.alt_activation = AltActivationMode::Toggle; - assert!(session.apply_loupe_activation_input(true, false)); + assert!(session.apply_loupe_activation_key_event(true, false)); assert!(session.state.alt_held); assert!(session.loupe_activation_key_down); @@ -16445,6 +16496,69 @@ mod tests { 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() {