diff --git a/crates/base/src/input/base/blink_cursor.rs b/crates/base/src/input/base/blink_cursor.rs index 74113adf97..dc36700910 100644 --- a/crates/base/src/input/base/blink_cursor.rs +++ b/crates/base/src/input/base/blink_cursor.rs @@ -84,7 +84,17 @@ impl BlinkCursor { } /// Show the cursor immediately and restart the idle delay before blinking resumes. + /// + /// This is a no-op on a cursor that is not blinking. `epoch` is zero only + /// before the first [`Self::start`] and after [`Self::stop`], so a zero + /// epoch means the input is not focused, and there is no cursor on screen + /// to keep visible. Pausing it anyway would start a blink loop that no blur + /// is left to stop, and every blink repaints the view that the input is in. pub(crate) fn pause(&mut self, cx: &mut Context) { + if self.epoch == 0 { + return; + } + self.paused = true; self.visible = true; cx.notify(); @@ -118,6 +128,9 @@ mod tests { fn repeated_pauses_keep_cursor_visible_until_idle(cx: &mut TestAppContext) { let cursor = cx.new(|_| BlinkCursor::new()); assert!(!cursor.read_with(cx, |cursor, _| cursor.visible())); + // Only a focused input blinks, and only a blinking cursor pauses. + cursor.update(cx, |cursor, cx| cursor.start(cx)); + cx.run_until_parked(); for _ in 0..5 { cursor.update(cx, |cursor, cx| cursor.pause(cx)); cx.run_until_parked(); @@ -133,6 +146,34 @@ mod tests { assert!(cursor.read_with(cx, |cursor, _| cursor.visible())); } + #[gpui::test] + fn pausing_a_cursor_that_is_not_blinking_does_not_start_it(cx: &mut TestAppContext) { + // Never focused, so `start` was never called: the epoch is still zero. + let cursor = cx.new(|_| BlinkCursor::new()); + + // What a programmatic `set_value` on an unfocused input does. + cursor.update(cx, |cursor, cx| cursor.pause(cx)); + cx.run_until_parked(); + cx.executor().advance_clock(PAUSE_DELAY); + cx.run_until_parked(); + assert!(!cursor.read_with(cx, |cursor, _| cursor.visible())); + + let notifies = Rc::new(Cell::new(0usize)); + let counter = notifies.clone(); + let _observer = + cx.update(|cx| cx.observe(&cursor, move |_, _| counter.set(counter.get() + 1))); + cx.run_until_parked(); + + cx.executor().advance_clock(INTERVAL * 6); + cx.run_until_parked(); + + assert_eq!( + notifies.get(), + 0, + "a cursor that was never started is blinking, and every blink repaints the view" + ); + } + #[gpui::test] fn blurring_a_paused_cursor_leaves_the_next_focus_blinking(cx: &mut TestAppContext) { let cursor = cx.new(|_| BlinkCursor::new()); diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index 06638af857..431e582c1e 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -4856,6 +4856,12 @@ mod tests { cx.run_until_parked(); let input = input.unwrap(); let before = input.read_with(cx, |state, _| state.cursor_layout()); + // The caret geometry only exists once the input has painted, and a + // notify sent from inside a draw marks the view dirty without asking + // for another frame, so the consumer reads the geometry on the next + // invalidation rather than during the paint that produced it. + input.update(cx, |_, cx| cx.notify()); + cx.run_until_parked(); assert_eq!(observed.get(), before); window .update(cx, |_, _, cx| { @@ -4935,6 +4941,46 @@ mod tests { ); } + #[gpui::test] + fn test_set_value_on_unfocused_input_stays_quiet(cx: &mut TestAppContext) { + use std::{cell::Cell, rc::Rc}; + + cx.update(crate::init); + let mut input = None; + let window = cx.open_window(size(px(400.), px(100.)), |window, cx| { + input = Some(cx.new(|cx| crate::input::InputState::new(window, cx))); + gpui::EmptyView + }); + let input = input.unwrap(); + cx.run_until_parked(); + + let notifications = Rc::new(Cell::new(0)); + let count = notifications.clone(); + let _subscription = + cx.update(|cx| cx.observe(&input, move |_, _| count.set(count.get() + 1))); + cx.run_until_parked(); + + // Seeding a form is a write, so it notifies once. The input is not + // focused and draws no caret, so nothing may notify after that. + window + .update(cx, |_, window, cx| { + input.update(cx, |state, cx| state.set_value("seeded", window, cx)); + }) + .unwrap(); + cx.run_until_parked(); + let settled = notifications.get(); + + cx.executor() + .advance_clock(std::time::Duration::from_secs(3)); + cx.run_until_parked(); + + assert_eq!( + notifications.get(), + settled, + "an unfocused input is blinking, and every blink repaints the view it is in" + ); + } + #[gpui::test] fn test_input_does_not_invalidate_cached_parent_during_paint(cx: &mut TestAppContext) { use std::{cell::Cell, rc::Rc}; @@ -7421,13 +7467,25 @@ mod tests { let view = InputView::::new(cx); let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); setup_cursors(&mut cx, &view.input, "ab\na|b\nab"); - cx.update(|window, cx| { - view.input.update(cx, |state, cx| { - // Start each action in the hidden phase without depending on a - // key-down listener: actions and text input also arrive directly. - for action in 0..6 { + // Start each action in the hidden phase without depending on a + // key-down listener: actions and text input also arrive directly. + for action in 0..6 { + cx.update(|_, cx| { + view.input.update(cx, |state, cx| { state.blink_cursor = cx.new(|_| BlinkCursor::new()); - assert!(!state.blink_cursor.read(cx).visible()); + state.blink_cursor.update(cx, |cursor, cx| cursor.start(cx)); + }); + }); + cx.run_until_parked(); + cx.executor() + .advance_clock(std::time::Duration::from_millis(500)); + cx.run_until_parked(); + view.input.read_with(&cx, |state, cx| { + assert!(!state.blink_cursor.read(cx).visible(), "action {action}"); + }); + + cx.update(|window, cx| { + view.input.update(cx, |state, cx| { match action { 0 => state.add_cursor_above(&AddCursorAbove, window, cx), 1 => state.add_cursor_below(&AddCursorBelow, window, cx), @@ -7437,9 +7495,9 @@ mod tests { _ => state.backspace(&Backspace, window, cx), } assert!(state.blink_cursor.read(cx).visible(), "action {action}"); - } + }); }); - }); + } } #[gpui::test]