add caret blink to input
This commit is contained in:
+114
-4
@@ -11,7 +11,8 @@ use gpui::{
|
||||
EntityInputHandler, EventEmitter, Font, InteractiveElement as _, IntoElement, KeyBinding,
|
||||
KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _,
|
||||
Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _,
|
||||
TextAlign, TextRun, UTF16Selection, Window, actions, canvas, div, fill, point, px,
|
||||
Subscription, TextAlign, TextRun, UTF16Selection, Window, actions, canvas, div, fill, point,
|
||||
px,
|
||||
};
|
||||
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size};
|
||||
|
||||
@@ -21,6 +22,8 @@ use crate::common::{Sizable, h_flex};
|
||||
pub const INPUT_CONTEXT: &str = "Input";
|
||||
/// Consecutive edits within this interval merge into one undo entry.
|
||||
const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300);
|
||||
/// How often the focused caret blinks.
|
||||
const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500);
|
||||
/// Maximum undo entries kept; the oldest drops off the front.
|
||||
const MAX_HISTORY: usize = 100;
|
||||
|
||||
@@ -316,6 +319,93 @@ impl HistoryEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Caret blink controller.
|
||||
struct CaretBlink {
|
||||
interval: Duration,
|
||||
generation: usize,
|
||||
visible: bool,
|
||||
active: bool,
|
||||
paused: bool,
|
||||
}
|
||||
|
||||
impl CaretBlink {
|
||||
fn new(_cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
interval: CARET_BLINK_INTERVAL,
|
||||
generation: 0,
|
||||
visible: true,
|
||||
active: false,
|
||||
paused: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn visible(&self) -> bool {
|
||||
self.visible
|
||||
}
|
||||
|
||||
/// Starts blinking; the caret shows first and hides after one interval.
|
||||
fn enable(&mut self, cx: &mut Context<Self>) {
|
||||
if self.active {
|
||||
return;
|
||||
}
|
||||
self.active = true;
|
||||
self.visible = true;
|
||||
self.paused = false;
|
||||
self.schedule(cx);
|
||||
}
|
||||
|
||||
/// Stops blinking. The caret stays hidden; paint gates on focus anyway.
|
||||
fn disable(&mut self, cx: &mut Context<Self>) {
|
||||
self.active = false;
|
||||
self.visible = false;
|
||||
self.paused = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Holds the caret visible and restarts the hide timer.
|
||||
fn pause(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.active {
|
||||
return;
|
||||
}
|
||||
if !self.visible {
|
||||
self.visible = true;
|
||||
cx.notify();
|
||||
}
|
||||
self.paused = true;
|
||||
self.generation += 1;
|
||||
self.schedule_pause(cx);
|
||||
}
|
||||
|
||||
fn tick(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.active || self.paused {
|
||||
return;
|
||||
}
|
||||
self.visible = !self.visible;
|
||||
cx.notify();
|
||||
self.schedule(cx);
|
||||
}
|
||||
|
||||
fn schedule(&mut self, cx: &mut Context<Self>) {
|
||||
self.generation += 1;
|
||||
self.schedule_pause(cx);
|
||||
}
|
||||
|
||||
fn schedule_pause(&mut self, cx: &mut Context<Self>) {
|
||||
let generation = self.generation;
|
||||
let interval = self.interval;
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(interval).await;
|
||||
this.update(cx, |this, cx| {
|
||||
if this.generation == generation {
|
||||
this.paused = false;
|
||||
this.tick(cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Input state for a single-line text field.
|
||||
pub struct InputState {
|
||||
text: String,
|
||||
@@ -341,15 +431,20 @@ pub struct InputState {
|
||||
group_interval: Duration,
|
||||
/// When set, user edits are refused, focus, movement, selection and copy stay.
|
||||
read_only: bool,
|
||||
/// Drives the focused caret's blink.
|
||||
caret_blink: Entity<CaretBlink>,
|
||||
/// The shaped line and element bounds, recorded during each paint,
|
||||
/// and shared with hit-testing and the IME geometry conversions.
|
||||
layout: Rc<RefCell<Option<InputLayout>>>,
|
||||
/// Keeps the field repainting on blink ticks. Dropping it unsubscribes.
|
||||
_caret_blink_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl EventEmitter<InputStateEvent> for InputState {}
|
||||
|
||||
impl InputState {
|
||||
fn new_inner(cx: &Context<Self>) -> Self {
|
||||
fn new_inner(cx: &mut Context<Self>) -> Self {
|
||||
let caret_blink = cx.new(CaretBlink::new);
|
||||
Self {
|
||||
text: String::new(),
|
||||
cursor: 0,
|
||||
@@ -372,13 +467,15 @@ impl InputState {
|
||||
redo_stack: Vec::new(),
|
||||
group_interval: DEFAULT_GROUP_INTERVAL,
|
||||
read_only: false,
|
||||
caret_blink: caret_blink.clone(),
|
||||
layout: Rc::new(RefCell::new(None)),
|
||||
_caret_blink_subscription: cx.observe(&caret_blink, |_, _, cx| cx.notify()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the state entity for an application-owned input.
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self::new_inner(cx))
|
||||
cx.new(Self::new_inner)
|
||||
}
|
||||
|
||||
/// Creates the state entity with initial text, caret at the end.
|
||||
@@ -522,6 +619,8 @@ impl InputState {
|
||||
|
||||
fn notify(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.notify();
|
||||
// Any change holds the caret solid and restarts the blink cycle.
|
||||
self.caret_blink.update(cx, |blink, cx| blink.pause(cx));
|
||||
window.notify_text_input_changed(&self.focus_handle);
|
||||
}
|
||||
|
||||
@@ -532,6 +631,12 @@ impl InputState {
|
||||
}
|
||||
self.focused = focused;
|
||||
|
||||
if focused {
|
||||
self.caret_blink.update(cx, |blink, cx| blink.enable(cx));
|
||||
} else {
|
||||
self.caret_blink.update(cx, |blink, cx| blink.disable(cx));
|
||||
}
|
||||
|
||||
cx.emit(if focused {
|
||||
InputStateEvent::Focused
|
||||
} else {
|
||||
@@ -539,6 +644,11 @@ impl InputState {
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the caret should paint.
|
||||
pub fn caret_visible(&mut self, focused: bool, cx: &mut Context<Self>) -> bool {
|
||||
focused && !self.read_only && self.caret_blink.read(cx).visible()
|
||||
}
|
||||
|
||||
/// Replaces a byte range with `text` and collapses the caret to its end.
|
||||
fn replace_range(
|
||||
&mut self,
|
||||
@@ -792,7 +902,7 @@ impl InputState {
|
||||
self.paint_text(origin, line_height, window, cx);
|
||||
|
||||
// A caret promises that typing lands there; in read-only it does not.
|
||||
if focused && !self.read_only && self.selection().is_empty() {
|
||||
if focused && self.caret_visible(focused, cx) && self.selection().is_empty() {
|
||||
self.paint_caret(
|
||||
origin,
|
||||
caret_x,
|
||||
|
||||
@@ -262,6 +262,44 @@ fn read_only_refuses_edits_but_keeps_selection_and_copy() {
|
||||
.unwrap_or_default()
|
||||
});
|
||||
assert_eq!(clipboard, "lo");
|
||||
|
||||
// The caret is suppressed entirely while read-only.
|
||||
let caret = cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(true, cx)));
|
||||
assert!(!caret);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_blinks_while_focused_and_holds_solid_while_typing() {
|
||||
let (cx, state, mut visual) = input_window("", false);
|
||||
focus(&mut visual, &state);
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
visual.run_until_parked();
|
||||
|
||||
let caret = || cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(true, cx)));
|
||||
|
||||
// The caret shows on focus, hides after one interval, and reappears
|
||||
// after another.
|
||||
assert!(caret());
|
||||
cx.executor().advance_clock(Duration::from_millis(500));
|
||||
visual.run_until_parked();
|
||||
assert!(!caret());
|
||||
cx.executor().advance_clock(Duration::from_millis(500));
|
||||
visual.run_until_parked();
|
||||
assert!(caret());
|
||||
|
||||
// Typing holds the caret solid and restarts the cycle.
|
||||
visual.simulate_keystrokes("a");
|
||||
assert!(caret());
|
||||
cx.executor().advance_clock(Duration::from_millis(500));
|
||||
visual.run_until_parked();
|
||||
assert!(!caret());
|
||||
|
||||
// An unfocused field reports no caret.
|
||||
let caret_unfocused =
|
||||
cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(false, cx)));
|
||||
assert!(!caret_unfocused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user