rework cursor to render itself

This commit is contained in:
temportalflux
2026-07-11 09:31:07 -04:00
parent 454b3210fb
commit 02531c48f7
6 changed files with 190 additions and 121 deletions
-3
View File
@@ -5,8 +5,6 @@ use gpui::Hsla;
pub struct InputColors {
/// This is the background color applied to the range of text that is currently selected by the user.
pub selection: Hsla,
/// This is the color of the user's text cursor.
pub cursor: Hsla,
/// This is the color of the placeholder string, when one is assigned and the text field is empty.
pub placeholder: Hsla,
pub marked: Hsla,
@@ -16,7 +14,6 @@ impl Default for InputColors {
fn default() -> Self {
Self {
selection: gpui::hsla(0.583, 0.519, 0.31, 1.0),
cursor: Hsla::white().opacity(0.8),
marked: Hsla::white().opacity(0.6),
placeholder: gpui::hsla(0., 0., 0.5, 1.0),
}
+109 -20
View File
@@ -1,47 +1,46 @@
use gpui::Context;
use gpui::{Bounds, Context, Element, Hsla, IntoElement, Pixels, Point, Render};
use std::time::Duration;
/// Default interval for cursor blinking.
pub const DEFAULT_BLINK_INTERVAL: Duration = Duration::from_millis(500);
pub trait Cursor {}
/// Configuration for cursor blinking, to be provided to InputState.
pub enum CursorBlinkType<'app> {
/// The cursor will not blink.
Disabled,
/// The cursor will blink at some interval.
Enabled {
/// Provide the app so that the internal state to track cursor blinking can be created.
app: &'app mut gpui::App,
/// The interval to blink at. If none, the default value of 500ms is used (defined by `DEFAULT_BLINK_INTERVAL`).
interval: Option<Duration>,
},
}
/// The state of an input's cursor blinking. While active, the cursor's visibility changes at some interval.
/// This blinking can be temporarily paused (e.g. during typing).
pub(super) struct CursorBlink {
pub struct Cursor {
interval: Duration,
generation: usize,
visible: bool,
active: bool,
paused: bool,
color: Hsla,
/// Tracks whether we were focused on the last update.
was_focused: bool,
point: Point<Pixels>,
height: Pixels,
}
impl CursorBlink {
impl Cursor {
/// Initializes the cursor blinking with the cursor already being visible.
#[track_caller]
pub fn new(interval: Duration) -> Self {
pub fn new(interval: Option<Duration>) -> Self {
Self {
interval,
interval: interval.unwrap_or_default(),
generation: 0,
visible: true,
active: false,
paused: false,
color: Hsla::white(),
was_focused: false,
point: Point::default(),
height: Pixels::ZERO,
}
}
pub fn color(mut self, color: Hsla) -> Self {
self.color = color;
self
}
/// Returns whether the cursor should currently be rendered.
pub fn visible(&self) -> bool {
self.visible
@@ -121,4 +120,94 @@ impl CursorBlink {
})
.detach();
}
pub fn update_input(
&mut self,
is_focused: bool,
pos: Point<Pixels>,
line_height: Pixels,
cx: &mut Context<Self>,
) -> bool {
let was_focused = self.was_focused;
self.was_focused = is_focused;
self.point = pos;
self.height = line_height;
match (self.interval.is_zero(), is_focused, was_focused) {
(true, _, _) => true,
(false, true, false) => {
self.enable(cx);
true
}
(false, false, true) => {
self.disable(cx);
false
}
(false, _, _) => self.visible,
}
}
}
impl IntoElement for Cursor {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for Cursor {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&gpui::GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
window: &mut gpui::Window,
cx: &mut gpui::App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let layout_id = window.request_layout(gpui::Style::default(), None, cx);
(layout_id, ())
}
fn prepaint(
&mut self,
_id: Option<&gpui::GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
_bounds: gpui::Bounds<gpui::Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_window: &mut gpui::Window,
_cx: &mut gpui::App,
) -> Self::PrepaintState {
()
}
fn paint(
&mut self,
_id: Option<&gpui::GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
bounds: gpui::Bounds<gpui::Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_prepaint: &mut Self::PrepaintState,
window: &mut gpui::Window,
_cx: &mut gpui::App,
) {
const CURSOR_WIDTH: f32 = 2.0;
window.paint_quad(gpui::fill(
Bounds::new(
gpui::point(bounds.left(), bounds.top()) + self.point,
gpui::size(gpui::px(CURSOR_WIDTH), self.height),
),
self.color,
));
}
}
+3 -14
View File
@@ -15,7 +15,7 @@ pub struct Input {
pub(super) interactivity: Interactivity,
pub(super) placeholder: Option<SharedString>,
pub(super) colors: InputColors,
pub(super) cursor: Option<AnyElement>,
pub(super) cursor: Option<Entity<Cursor>>,
}
impl Input {
@@ -145,13 +145,6 @@ impl Input {
self
}
/// Sets the "cursor" color for the input element.
/// This is the color of the user's text cursor.
pub fn cursor_color(mut self, color: Hsla) -> Self {
self.colors.cursor = color;
self
}
/// Sets the "placeholder" color for the input element.
/// This is the color of the placeholder string, when one is assigned and the text field is empty.
pub fn placeholder_color(mut self, color: Hsla) -> Self {
@@ -166,12 +159,8 @@ impl Input {
self
}
pub fn cursor<T>(mut self, entity: Entity<T>) -> Self
where
T: Cursor,
Entity<T>: Into<AnyElement>,
{
self.cursor = Some(entity.into());
pub fn cursor(mut self, entity: Entity<Cursor>) -> Self {
self.cursor = Some(entity);
self
}
}
+60 -33
View File
@@ -1,4 +1,4 @@
use crate::input::{Input, InputColors, InputLayoutData, InputLogicalLine, InputState};
use crate::input::{Cursor, Input, InputColors, InputLayoutData, InputLogicalLine, InputState};
use gpui::{
Along, App, Axis, Bounds, ContentMask, CursorStyle, DispatchPhase, Display, Element, ElementId,
ElementInputHandler, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla,
@@ -9,17 +9,18 @@ use gpui::{
use smallvec::SmallVec;
use std::ops::Range;
const CURSOR_WIDTH: f32 = 2.0;
const MARKED_TEXT_UNDERLINE_THICKNESS: f32 = 2.0;
pub struct InputLayoutState {
text_style: TextStyle,
#[allow(dead_code)]
child_layout_ids: SmallVec<[LayoutId; 2]>,
cursor_layout: Option<<Cursor as Element>::RequestLayoutState>,
}
pub struct InputPrepaintState {
hitbox: Option<Hitbox>,
cursor_prepaint: Option<<Cursor as Element>::PrepaintState>,
}
impl Element for Input {
@@ -43,6 +44,7 @@ impl Element for Input {
) -> (LayoutId, Self::RequestLayoutState) {
let mut resolved_text_style = None;
let mut child_layout_ids = SmallVec::new();
let mut cursor_layout = None;
let layout_id = self.interactivity.request_layout(
global_id,
@@ -65,11 +67,13 @@ impl Element for Input {
}
}
child_layout_ids = self
.cursor
.iter_mut()
.map(|cursor| cursor.request_layout(window, cx))
.collect::<SmallVec<_>>();
if let Some(cursor) = &self.cursor {
let (layout_id, layout) = cursor.update(cx, |cursor, cx| {
cursor.request_layout(global_id, inspector_id, window, cx)
});
child_layout_ids.push(layout_id);
cursor_layout = Some(layout);
}
window.request_layout(layout_style, child_layout_ids.iter().copied(), cx)
})
@@ -79,6 +83,7 @@ impl Element for Input {
let layout_state = InputLayoutState {
text_style: resolved_text_style.unwrap_or_else(|| window.text_style()),
child_layout_ids,
cursor_layout,
};
(layout_id, layout_state)
}
@@ -112,6 +117,7 @@ impl Element for Input {
input.apply_layout_update(layout_data, window);
});
let mut cursor_prepaint = None;
let hitbox = self.interactivity.prepaint(
global_id,
inspector_id,
@@ -125,8 +131,21 @@ impl Element for Input {
if style.display != Display::None {
window.with_element_offset(scroll_offset, |window| {
if let Some(cursor) = &mut self.cursor {
cursor.prepaint(window, cx);
match (&mut self.cursor, &mut layout_state.cursor_layout) {
(Some(cursor), Some(layout)) => {
let prepaint = cursor.update(cx, |cursor, cx| {
cursor.prepaint(
global_id,
inspector_id,
bounds,
layout,
window,
cx,
)
});
cursor_prepaint = Some(prepaint);
}
_ => {}
}
});
}
@@ -135,7 +154,10 @@ impl Element for Input {
},
);
InputPrepaintState { hitbox }
InputPrepaintState {
hitbox,
cursor_prepaint,
}
}
fn paint(
@@ -166,10 +188,6 @@ impl Element for Input {
let is_focused = focus_handle.is_focused(window);
let colors = self.colors;
let is_cursor_visible = self.input.update(cx, |input, cx| {
input.toggle_cursor_on_focus_change(is_focused, cx)
});
let perform_paint = |style: &Style, window: &mut Window, cx: &mut App| {
if style.display == Display::None {
return;
@@ -182,14 +200,39 @@ impl Element for Input {
text_style: &text_style,
placeholder: placeholder.as_ref(),
colors: &colors,
cursor_visible: is_cursor_visible,
};
context.process_mouse_events(&self.input, window, cx);
window.with_content_mask(Some(ContentMask { bounds }), |window| {
context.paint(window, cx);
if let Some(cursor) = &mut self.cursor {
cursor.paint(window, cx);
match (
&mut self.cursor,
&mut layout_state.cursor_layout,
&mut prepaint_state.cursor_prepaint,
) {
(Some(cursor), Some(layout), Some(prepaint)) => {
cursor.update(cx, |cursor, cx| {
let cursor_pos = context.find_cursor_position_in_layouts();
let visible = cursor.update_input(
is_focused,
cursor_pos,
context.snapshot.line_height,
cx,
);
if is_focused && visible && context.snapshot.selected_range.is_empty() {
cursor.paint(
global_id,
inspector_id,
bounds,
layout,
prepaint,
window,
cx,
);
}
});
}
_ => {}
}
});
};
@@ -253,7 +296,6 @@ struct PaintContext<'app> {
text_style: &'app TextStyle,
placeholder: Option<&'app SharedString>,
colors: &'app InputColors,
cursor_visible: bool,
}
impl<'app> PaintContext<'app> {
@@ -388,10 +430,6 @@ impl<'app> PaintContext<'app> {
}
self.paint_marked_underline(window);
if self.is_focused && self.snapshot.selected_range.is_empty() && self.cursor_visible {
self.paint_cursor(window);
}
}
fn paint_selection(&self, window: &mut Window) {
@@ -557,17 +595,6 @@ impl<'app> PaintContext<'app> {
Point::default()
}
fn paint_cursor(&self, window: &mut Window) {
let cursor_pos = self.find_cursor_position_in_layouts();
window.paint_quad(fill(
Bounds::new(
point(self.bounds.left(), self.bounds.top()) + cursor_pos,
size(px(CURSOR_WIDTH), self.snapshot.line_height),
),
self.colors.cursor,
));
}
fn is_line_visible(&self, line: &InputLogicalLine) -> bool {
let line_y = line.y_offset - self.snapshot.scroll_distance;
let line_bottom = line_y + self.snapshot.line_height * line.visual_line_count as f32;
+16 -49
View File
@@ -1,5 +1,5 @@
use super::actions::*;
use crate::input::{CursorBlinkType, InputLayoutStyle, InputStorage};
use crate::input::{InputLayoutStyle, InputStorage};
use gpui::{
App, AppContext, ClipboardItem, Context, Entity, EntityId, EntityInputHandler, EventEmitter,
FocusHandle, Focusable, NavigationDirection, Pixels, Point, Render, SharedString, Size,
@@ -26,9 +26,15 @@ pub enum InputStateEvent {
/// Emitted when a redo operation is performed.
Redo,
}
impl EventEmitter<InputStateEvent> for InputState {}
#[derive(Clone, Debug)]
pub enum CursorTrigger {
// TODO: cursor needs to receive this
PauseBlinkingForUserAction,
}
impl EventEmitter<CursorTrigger> for InputState {}
/// `Input` is the state model for text input components. It handles:
/// - Text content storage and manipulation
/// - Selection and cursor management
@@ -56,8 +62,6 @@ pub struct InputState {
layout_data: InputLayoutData,
/// A reinterpretation of `content` as wrapped lines with layout information. Regenerated when content changes or the layout changes during element painting.
logical_lines: Vec<InputLogicalLine>,
/// Tracks whether we were focused on the last update.
was_focused: bool,
/// True while the user is in the act of highlighting a section of the text (e.g. during mouse pressed & dragging).
is_selecting: bool,
@@ -135,7 +139,6 @@ impl InputState {
layout_data: InputLayoutData::default(),
logical_lines: Vec::new(),
was_focused: false,
is_selecting: false,
last_click_position: None,
@@ -164,7 +167,7 @@ impl InputState {
self.history_undo_stack.clear();
self.history_redo_stack.clear();
self.mark_layout_dirty();
self.pause_cursor_blink(cx);
cx.emit(CursorTrigger::PauseBlinkingForUserAction);
cx.emit(InputStateEvent::TextChanged);
cx.notify();
}
@@ -305,7 +308,7 @@ impl InputState {
self.marked_range.take();
self.mark_layout_dirty();
self.pause_cursor_blink(cx);
cx.emit(CursorTrigger::PauseBlinkingForUserAction);
cx.emit(InputStateEvent::TextChanged);
cx.notify();
}
@@ -429,7 +432,7 @@ impl InputState {
}
pub(super) fn up(&mut self, _: &Up, _window: &mut Window, cx: &mut Context<Self>) {
self.pause_cursor_blink(cx);
cx.emit(CursorTrigger::PauseBlinkingForUserAction);
match self.layout_style {
InputLayoutStyle::SingleLine => {
// In single-line mode, up moves to start
@@ -450,7 +453,7 @@ impl InputState {
}
pub(super) fn down(&mut self, _: &Down, _window: &mut Window, cx: &mut Context<Self>) {
self.pause_cursor_blink(cx);
cx.emit(CursorTrigger::PauseBlinkingForUserAction);
match self.layout_style {
InputLayoutStyle::SingleLine => {
// In single-line mode, down moves to end
@@ -480,7 +483,7 @@ impl InputState {
}
pub(super) fn select_up(&mut self, _: &SelectUp, _window: &mut Window, cx: &mut Context<Self>) {
self.pause_cursor_blink(cx);
cx.emit(CursorTrigger::PauseBlinkingForUserAction);
match self.layout_style {
InputLayoutStyle::SingleLine => {
// In single-line mode, select_up selects to start
@@ -503,7 +506,7 @@ impl InputState {
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.pause_cursor_blink(cx);
cx.emit(CursorTrigger::PauseBlinkingForUserAction);
match self.layout_style {
InputLayoutStyle::SingleLine => {
// In single-line mode, select_down selects to end
@@ -808,13 +811,6 @@ impl InputState {
self.content.update_utf8(range, text_to_insert);
}
/// Temporarily pauses blinking and leaves the cursor visible. Blinking will resume after the pre-established interval elapses from the time this is called.
pub(super) fn pause_cursor_blink(&self, cx: &mut Context<Self>) {
if let Some((cursor_blink, _)) = &self.cursor_blink {
cursor_blink.update(cx, |cb, cx| cb.pause_blinking(cx));
}
}
/// Records a patch for undo. Called before making changes to content.
/// Returns true if a new entry was created, false if grouped with previous.
pub(super) fn push_undo_patch(&mut self, range: Range<usize>, new_text_len: usize) {
@@ -1110,37 +1106,8 @@ impl InputState {
logical_lines
}
/// Processes a focus-flag update during window paint, returning whether the cursor should be visible in this frame.
/// Returns false if the cursor is blinking and not currently visible.
pub(super) fn toggle_cursor_on_focus_change(
&mut self,
is_focused: bool,
cx: &mut Context<Self>,
) -> bool {
// Update cursor blink based on focus changes
let was_focused = self.was_focused;
self.was_focused = is_focused;
match &self.cursor_blink {
None => true,
Some((cursor_blink, _)) => match (is_focused, was_focused) {
(true, false) => {
cursor_blink.update(cx, |cursor, cx| cursor.enable(cx));
cx.emit(InputStateEvent::Focus);
true
}
(false, true) => {
cursor_blink.update(cx, |cursor, cx| cursor.disable(cx));
cx.emit(InputStateEvent::Blur);
false
}
_ => cursor_blink.read(cx).visible(),
},
}
}
fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) {
self.pause_cursor_blink(cx);
cx.emit(CursorTrigger::PauseBlinkingForUserAction);
let offset = offset.min(self.content.len());
self.selected_range = offset..offset;
self.selection_direction = NavigationDirection::Forward;
@@ -1149,7 +1116,7 @@ impl InputState {
}
fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) {
self.pause_cursor_blink(cx);
cx.emit(CursorTrigger::PauseBlinkingForUserAction);
let offset = offset.min(self.content.len());
self.apply_selection_offset(offset);
self.scroll_to_cursor();
@@ -1,4 +1,4 @@
use crate::input::InputStateEvent;
use crate::input::{CursorTrigger, InputStateEvent};
use gpui::{
Bounds, Context, EntityInputHandler, NavigationDirection, Pixels, Point, UTF16Selection,
Window, point, px,
@@ -73,7 +73,7 @@ impl EntityInputHandler for super::InputState {
self.set_marked_range(None);
self.mark_layout_dirty();
self.pause_cursor_blink(cx);
cx.emit(CursorTrigger::PauseBlinkingForUserAction);
cx.emit(InputStateEvent::TextChanged);
cx.notify();
}