From d1e679bb96d7d8df71d4ef92f72eb04f749309a7 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Fri, 10 Apr 2026 23:07:28 +0800 Subject: [PATCH 1/2] {"schema":"maestro/commit/1","summary":"fix frozen text caret blink while editing","authority":"manual","breaking":false} --- packages/rsnap-overlay/src/overlay.rs | 3 + .../src/overlay/aux_window_runtime.rs | 25 +++++++ .../src/overlay/rendering/affordances.rs | 5 +- .../src/overlay/session_state.rs | 18 +++++ packages/rsnap-overlay/src/overlay/tests.rs | 67 +++++++++++++++++++ 5 files changed, 116 insertions(+), 2 deletions(-) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index 0229273a..b771b340 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -4435,6 +4435,7 @@ impl OverlaySession { edit_state.ime_preedit = None; edit_state.ime_preedit_cursor_char_range = None; + edit_state.reset_caret_blink(); self.frozen_text_recent_input = None; true @@ -4452,6 +4453,7 @@ impl OverlaySession { let changed = had_preedit || edit_state.text.pop().is_some(); if changed { + edit_state.reset_caret_blink(); self.frozen_text_recent_input = None; } @@ -4515,6 +4517,7 @@ impl OverlaySession { edit_state.ime_preedit = normalized; edit_state.ime_preedit_cursor_char_range = normalized_cursor_range; + edit_state.reset_caret_blink(); true } diff --git a/packages/rsnap-overlay/src/overlay/aux_window_runtime.rs b/packages/rsnap-overlay/src/overlay/aux_window_runtime.rs index 9ecb4955..0d8c7869 100644 --- a/packages/rsnap-overlay/src/overlay/aux_window_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/aux_window_runtime.rs @@ -68,6 +68,7 @@ impl OverlaySession { self.maybe_keep_selection_flow_repaint(); self.maybe_keep_frozen_text_caret_repaint(); self.maybe_keep_frozen_capture_redraw(); + self.maybe_request_due_egui_repaint(now); self.maybe_tick_toolbar_window_warmup_redraw(); self.maybe_tick_loupe_window_warmup_redraw(); self.maybe_tick_live_cursor_tracking(); @@ -203,6 +204,30 @@ impl OverlaySession { self.schedule_egui_repaint_after(FROZEN_TEXT_CARET_REPAINT_INTERVAL); } + pub(super) fn maybe_request_due_egui_repaint(&self, now: Instant) { + if !self.take_due_egui_repaint_deadline(now) { + return; + } + + self.request_redraw_all(); + } + + pub(super) fn take_due_egui_repaint_deadline(&self, now: Instant) -> bool { + let mut next_repaint = + self.egui_repaint_deadline.lock().unwrap_or_else(|err| err.into_inner()); + let Some(deadline) = *next_repaint else { + return false; + }; + + if deadline > now { + return false; + } + + *next_repaint = None; + + true + } + pub(super) fn live_overlay_selection_flow_repaint_active(&self) -> bool { if !self.config.selection_flow_enabled { return false; diff --git a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs index f270ab73..3a48d335 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs @@ -449,8 +449,9 @@ impl WindowRenderer { Self::paint_frozen_text_label(painter, text_edit.anchor, text, &font_id, color); if let Some(caret_char_index) = caret_char_index - && Self::frozen_text_caret_visible(painter.ctx().input(|i| i.time)) - { + && Self::frozen_text_caret_visible( + text_edit.caret_blink_elapsed_secs_at(std::time::Instant::now()), + ) { Self::paint_frozen_text_caret( painter, text_edit.anchor, diff --git a/packages/rsnap-overlay/src/overlay/session_state.rs b/packages/rsnap-overlay/src/overlay/session_state.rs index 647ea849..721b7a08 100644 --- a/packages/rsnap-overlay/src/overlay/session_state.rs +++ b/packages/rsnap-overlay/src/overlay/session_state.rs @@ -299,16 +299,22 @@ pub(super) struct FrozenTextEditState { pub(super) text: String, pub(super) ime_preedit: Option, pub(super) ime_preedit_cursor_char_range: Option<(usize, usize)>, + pub(super) caret_blink_started_at: Instant, pub(super) dragging: bool, pub(super) drag_offset: Vec2, } impl FrozenTextEditState { pub(super) fn new(anchor: Pos2) -> Self { + Self::new_at(anchor, Instant::now()) + } + + pub(super) fn new_at(anchor: Pos2, caret_blink_started_at: Instant) -> Self { Self { anchor, text: String::new(), ime_preedit: None, ime_preedit_cursor_char_range: None, + caret_blink_started_at, dragging: false, drag_offset: Vec2::ZERO, } @@ -322,6 +328,18 @@ impl FrozenTextEditState { self.ime_preedit.is_some() } + pub(super) fn reset_caret_blink(&mut self) { + self.reset_caret_blink_at(Instant::now()); + } + + pub(super) fn reset_caret_blink_at(&mut self, caret_blink_started_at: Instant) { + self.caret_blink_started_at = caret_blink_started_at; + } + + pub(super) fn caret_blink_elapsed_secs_at(&self, now: Instant) -> f64 { + now.duration_since(self.caret_blink_started_at).as_secs_f64() + } + pub(super) fn visible_text_and_caret_char_index(&self) -> (String, Option) { let committed_char_count = self.text.chars().count(); diff --git a/packages/rsnap-overlay/src/overlay/tests.rs b/packages/rsnap-overlay/src/overlay/tests.rs index 4e721b09..f7887dfb 100644 --- a/packages/rsnap-overlay/src/overlay/tests.rs +++ b/packages/rsnap-overlay/src/overlay/tests.rs @@ -1236,6 +1236,61 @@ fn backspace_clears_recent_input_dedupe_marker_before_cross_source_retype() { assert_eq!(session.frozen_text_edit.as_ref().map(|edit| edit.text.as_str()), Some("A")); } +#[test] +fn text_input_resets_frozen_text_caret_blink_phase() { + 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.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + + let stale_started_at = Instant::now() - FROZEN_TEXT_CARET_REPAINT_INTERVAL * 3; + + session.frozen_text_edit.as_mut().expect("text edit").reset_caret_blink_at(stale_started_at); + + let generation = session.note_frozen_text_input_event(); + + assert!(session.append_text_to_frozen_edit_for_input_event( + FrozenTextInputSource::Key, + generation, + "A", + )); + + let edit_state = session.frozen_text_edit.as_ref().expect("text edit"); + + assert!(edit_state.caret_blink_started_at > stale_started_at); + assert!(edit_state.caret_blink_elapsed_secs_at(edit_state.caret_blink_started_at) == 0.0); +} + +#[test] +fn ime_preedit_updates_reset_frozen_text_caret_blink_phase() { + 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.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + + let stale_started_at = Instant::now() - FROZEN_TEXT_CARET_REPAINT_INTERVAL * 3; + + session.frozen_text_edit.as_mut().expect("text edit").reset_caret_blink_at(stale_started_at); + + assert!(session.set_frozen_text_ime_preedit(Some(String::from("汉")), Some((0, 0)))); + + let edit_state = session.frozen_text_edit.as_ref().expect("text edit"); + + assert!(edit_state.caret_blink_started_at > stale_started_at); +} + #[test] fn ime_disabled_clears_frozen_text_preedit_state() { let monitor = test_monitor(); @@ -1379,6 +1434,18 @@ fn frozen_text_caret_repaint_schedules_delayed_repaint_while_editing() { ); } +#[test] +fn due_egui_repaint_deadline_is_consumed_once_ready() { + let session = OverlaySession::new(); + let due_at = Instant::now() - Duration::from_millis(1); + + *session.egui_repaint_deadline.lock().unwrap_or_else(|err| err.into_inner()) = Some(due_at); + + assert!(session.take_due_egui_repaint_deadline(Instant::now())); + assert!(session.egui_repaint_deadline.lock().unwrap_or_else(|err| err.into_inner()).is_none()); + assert!(!session.take_due_egui_repaint_deadline(Instant::now())); +} + #[test] fn finish_frozen_text_editing_commits_current_toolbar_text_style() { let monitor = test_monitor(); From 49abf0146dbda32026282e7b38bb401a90496798 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Fri, 10 Apr 2026 23:21:56 +0800 Subject: [PATCH 2/2] {"schema":"maestro/commit/1","summary":"fix ci style violations for frozen text caret","authority":"manual","breaking":false} --- packages/rsnap-overlay/src/overlay.rs | 4 ++++ packages/rsnap-overlay/src/overlay/rendering/affordances.rs | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index b771b340..b650fa39 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -4435,7 +4435,9 @@ impl OverlaySession { edit_state.ime_preedit = None; edit_state.ime_preedit_cursor_char_range = None; + edit_state.reset_caret_blink(); + self.frozen_text_recent_input = None; true @@ -4454,6 +4456,7 @@ impl OverlaySession { if changed { edit_state.reset_caret_blink(); + self.frozen_text_recent_input = None; } @@ -4517,6 +4520,7 @@ impl OverlaySession { edit_state.ime_preedit = normalized; edit_state.ime_preedit_cursor_char_range = normalized_cursor_range; + edit_state.reset_caret_blink(); true diff --git a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs index 3a48d335..eecc991a 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs @@ -1,4 +1,5 @@ use std::sync::{Arc, OnceLock}; +use std::time::Instant; use egui::Context; use egui::FontDefinitions; @@ -450,7 +451,7 @@ impl WindowRenderer { if let Some(caret_char_index) = caret_char_index && Self::frozen_text_caret_visible( - text_edit.caret_blink_elapsed_secs_at(std::time::Instant::now()), + text_edit.caret_blink_elapsed_secs_at(Instant::now()), ) { Self::paint_frozen_text_caret( painter,