scroll the area when the caret moves out of view

This commit is contained in:
temportalflux
2026-07-11 09:31:07 -04:00
parent 0ae4c8f146
commit 126db6dbbe
3 changed files with 139 additions and 50 deletions
+1 -1
View File
@@ -11,10 +11,10 @@ pub use storage::*;
/* TODO list
- remove gpuikit based input
- auto-scroll when cursor moves
- cursor blinking
- color styling configs
- text sanitation
- add page up/down actions to nav by an entire page or expand selection by an entire page
- test IME (char palette only available on macos)
*/
@@ -16,7 +16,7 @@ use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc};
#[track_caller]
pub fn editable_text(id: impl Into<ElementId>) -> EditableTextElement {
let mut this = EditableTextElement {
interactivity: Interactivity::new(),
interactivity: Interactivity::default(),
state_entity: Rc::new(RefCell::new(WeakEntity::new_invalid())),
supports_multiline: true,
init_storage: InitStorage::default(),
@@ -192,23 +192,28 @@ impl Element for EditableTextElement {
focus_handle = state.focus_handle(cx);
show_placeholder = state.storage().content_utf8().is_empty();
storage_version = state.storage().version();
if let Some(scroll_offset) = state.layout_data.next_scroll_offset {
self.interactivity
.set_scroll_offset(global_id, window, -scroll_offset);
}
}
// TODO: This required a gpui api change in order to sync the focus handle between Interactivity and TextInputStateBase
self.interactivity().track_focus(focus_handle);
self.interactivity.track_focus(focus_handle);
let placeholder = self.placeholder.clone();
let placeholder_color = Hsla::white().opacity(0.5); // TODO: as an element param
let supports_multiline = self.supports_multiline;
let accepts_input = self.accepts_input;
let layout_id = self.interactivity().request_layout(
let layout_id = self.interactivity.request_layout(
global_id,
inspector_id,
window,
cx,
|style, window, cx| {
let state = state.clone();
window.with_text_style(style.text_style().cloned(), |window| {
window.with_text_style(style.text_style().cloned(), move |window| {
// NOTE: Loosely mirrors TextLayout::layout
let text_layout_id = window.request_measured_layout(Default::default(), {
let text_style = window.text_style();
@@ -325,10 +330,13 @@ impl Element for EditableTextElement {
let layout_data = TextInputLayoutData {
supports_multiline,
accepts_input,
scroll_bounds: Bounds::default(),
wrap_width,
size: Some(size),
last_seen_storage_version,
lines,
line_height,
next_scroll_offset: None,
};
// Update the state for use in prepaint, paint, and action handlers.
@@ -370,7 +378,7 @@ impl Element for EditableTextElement {
struct InteractivityPrepaint {
hitbox: Option<Hitbox>,
scroll_offset: Point<Pixels>,
padding: gpui::Edges<Pixels>,
inner_bounds: Bounds<Pixels>,
}
let prepaint = self.interactivity().prepaint(
global_id,
@@ -379,31 +387,38 @@ impl Element for EditableTextElement {
content_size,
window,
cx,
|style, scroll_offset, hitbox, window, _cx| {
|style, scroll_offset, hitbox, window, cx| {
let hitbox =
hitbox.or_else(|| Some(window.insert_hitbox(bounds, HitboxBehavior::Normal)));
let padding = style
.padding
.to_pixels(bounds.size.into(), window.rem_size());
let inner_bounds = {
let padding = style
.padding
.to_pixels(bounds.size.into(), window.rem_size());
let mut bounds = bounds;
bounds.origin += point(padding.left, padding.top);
bounds.size.width -= padding.left + padding.right;
bounds.size.height -= padding.top + padding.bottom;
bounds
};
request_layout.state.update(cx, |state, _cx| {
// while gpui tracks scroll_offset with negative values,
// this is converted into positive for usage with bounds
state.layout_data.scroll_bounds =
Bounds::new(-scroll_offset, inner_bounds.size);
});
InteractivityPrepaint {
hitbox,
scroll_offset,
padding,
inner_bounds,
}
},
);
let InteractivityPrepaint {
hitbox,
scroll_offset,
padding,
inner_bounds,
} = prepaint;
let inner_bounds = {
let mut bounds = bounds;
bounds.origin += point(padding.left, padding.top);
bounds.size.width -= padding.left + padding.right;
bounds.size.height -= padding.top + padding.bottom;
bounds
};
let selection_color = Hsla::blue().opacity(0.5); // TODO: as an element param
let caret_color = Hsla::white(); // TODO: as an element param
@@ -503,11 +518,7 @@ impl Element for EditableTextElement {
}
}
let is_cursor_in_line = if segment_is_empty {
caret_pos == segment.text_range.start
} else {
segment.text_range.contains(&caret_pos) || caret_pos == segment.text_range.end
};
let is_cursor_in_line = segment.contains_position(caret_pos);
if is_cursor_in_line && let Some(wrapped) = &segment.wrapped_line {
// TODO: when the cursor is functionally at a character that is on the next line
// (a line that spans multiple rows), the cursor displays at the end of the previous
+104 -26
View File
@@ -8,7 +8,7 @@ use gpui::{
App, Bounds, ClipboardItem, Context, EntityInputHandler, EventEmitter, FocusHandle, Focusable,
NavigationDirection, Pixels, Point, Size, UTF16Selection, Window, WrappedLine, point,
};
use std::{ops::Range, sync::Arc};
use std::{borrow::Cow, ops::Range, sync::Arc};
pub struct EditableTextState {
storage: Box<dyn UnicodeTextStorage>,
@@ -39,8 +39,12 @@ pub struct EditableTextState {
#[derive(Default)]
pub(super) struct TextInputLayoutData {
/// Whether the element supports multiple lines of text
pub supports_multiline: bool,
/// Whether the element is currently accepting inputs
pub accepts_input: bool,
/// The last seen scroll position and size of the element
pub scroll_bounds: Bounds<Pixels>,
/// The last known width at which the lines were wrapped.
pub wrap_width: Option<Pixels>,
/// The last known size of the text, as generated during layout.
@@ -50,6 +54,10 @@ pub(super) struct TextInputLayoutData {
/// The `ShapedLine` produced by the painter's `prepaint`.
/// Cached so IME `bounds_for_range` / `character_index_for_point` can evaluate without re-shaping.
pub lines: Vec<TextLineSegment>,
pub line_height: Pixels,
/// The next position the scroll view should move to.
/// Set by the state in response to user actions.
pub next_scroll_offset: Option<Point<Pixels>>,
}
/// A segment of text that is a single logical/document line but can take up multiple rows due to wrapping.
pub(super) struct TextLineSegment {
@@ -72,6 +80,14 @@ impl TextLineSegment {
.map(|line| line.wrap_boundaries().len());
count.unwrap_or_default() + 1
}
pub fn contains_position(&self, pos: usize) -> bool {
if self.text_range.is_empty() {
pos == self.text_range.start
} else {
pos >= self.text_range.start && pos < self.text_range.end
}
}
}
impl EventEmitter<TextChanged> for EditableTextState {}
@@ -248,11 +264,58 @@ impl EditableTextState {
}
impl EditableTextState {
fn scroll_to_caret(&mut self) {
if self.layout_data.scroll_bounds.is_empty() {
return;
}
let Some(content_size) = self.layout_data.size else {
return;
};
// point will be relative to content_size, and may or may not be within the current scroll_bounds
let point = self.find_point_for_character_position(self.caret_pos());
println!("{point:?} ?= {:?}", self.layout_data.scroll_bounds);
// this scroll_offset diverges from the rest of gpui, as it is stored in the
// positive real number space (interactivity stores it in the negatives)
let mut scroll_offset = Cow::Borrowed(&self.layout_data.scroll_bounds.origin);
if self.layout_data.scroll_bounds.contains(&point) {
return;
}
// No existing "shift bounds origin so <point> is contained", but that is effectively what this does
if point.x < self.layout_data.scroll_bounds.left() {
scroll_offset.to_mut().x = point.x;
}
if point.y < self.layout_data.scroll_bounds.top() {
scroll_offset.to_mut().y = point.y;
}
let right = self.layout_data.scroll_bounds.right();
if point.x > right {
scroll_offset.to_mut().x += point.x - right;
}
let bottom = self.layout_data.scroll_bounds.bottom();
let point_bottom = point.y + self.layout_data.line_height;
if point_bottom > bottom {
let delta = point_bottom - bottom;
scroll_offset.to_mut().y += delta;
}
if let Cow::Owned(mut offset) = scroll_offset {
offset.x = offset.x.clamp(Pixels::ZERO, content_size.width);
offset.y = offset.y.clamp(Pixels::ZERO, content_size.height);
println!("shift to {offset:?}");
self.layout_data.next_scroll_offset = Some(offset);
}
}
pub fn move_to(&mut self, caret_pos: usize, cx: &mut Context<Self>) {
//cx.emit(CursorTrigger::PauseBlinkingForUserAction);
let caret_pos = caret_pos.min(self.storage.content_utf8().len());
self.selected_range = caret_pos..caret_pos;
//self.scroll_to_cursor();
self.scroll_to_caret();
cx.notify();
}
@@ -260,7 +323,7 @@ impl EditableTextState {
//cx.emit(CursorTrigger::PauseBlinkingForUserAction);
let caret_pos = caret_pos.min(self.storage().content_utf8().len());
self.selected_range.start = caret_pos;
//self.scroll_to_cursor();
self.scroll_to_caret();
cx.notify();
}
@@ -309,7 +372,24 @@ impl EditableTextState {
self.move_to(caret_pos, cx);
}
pub fn line_index_and_point_at_caret(&self, line_height: Pixels) -> (usize, Point<Pixels>) {
pub fn select_document(&mut self, cx: &mut Context<Self>) {
self.selected_range = 0..self.storage.content_utf8().len();
cx.notify();
}
pub fn select_linear(
&mut self,
direction: NavigationDirection,
boundary: TextBoundary,
cx: &mut Context<Self>,
) {
let caret_pos = self
.storage
.offset_from_caret(self.caret_pos(), direction, boundary);
self.select_to(caret_pos, cx);
}
fn line_index_and_point_at_caret(&self, line_height: Pixels) -> (usize, Point<Pixels>) {
if self.layout_data.lines.is_empty() {
return (0, Point::default());
}
@@ -342,13 +422,12 @@ impl EditableTextState {
(segment_index.saturating_sub(1), Point::default())
}
pub fn find_position_in_vertical_direction(
fn find_position_in_vertical_direction(
&self,
direction: i32,
line_height: Pixels,
) -> Option<usize> {
let (line_index, point) = self.line_index_and_point_at_caret(line_height);
println!("{line_index:?} {point:?}");
let line_index = line_index.saturating_add_signed(direction as isize);
let mut current_visual_line = 0;
@@ -372,7 +451,6 @@ impl EditableTextState {
let closest_idx = closest_result.unwrap_or_else(|closest| closest);
let clamped = closest_idx.min(wrapped.text.len());
let result = segment.text_range.start + clamped;
println!("{result:?}");
return Some(result);
}
@@ -385,21 +463,26 @@ impl EditableTextState {
(direction > 0).then(|| self.storage.content_utf8().len())
}
pub fn select_document(&mut self, cx: &mut Context<Self>) {
self.selected_range = 0..self.storage.content_utf8().len();
cx.notify();
}
fn find_point_for_character_position(&self, character_pos: usize) -> Point<Pixels> {
let line_height = self.layout_data.line_height;
let mut row_count = 0;
for segment in &self.layout_data.lines {
if !segment.contains_position(character_pos) {
row_count += segment.row_count();
continue;
}
pub fn select_linear(
&mut self,
direction: NavigationDirection,
boundary: TextBoundary,
cx: &mut Context<Self>,
) {
let caret_pos = self
.storage
.offset_from_caret(self.caret_pos(), direction, boundary);
self.select_to(caret_pos, cx);
let line_origin = point(Pixels::ZERO, row_count * line_height);
return match &segment.wrapped_line {
None => line_origin,
Some(wrapped) => {
let local_offset = character_pos.saturating_sub(segment.text_range.start);
let position = wrapped.position_for_index(local_offset, line_height);
position.unwrap_or_default() + line_origin
}
};
}
Point::default()
}
}
@@ -602,7 +685,6 @@ impl<'app> EditableTextActionHandler<Context<'app, Self>> for EditableTextState
if !self.layout_data.accepts_input {
return;
}
// TODO: Why is the cursor appearing at the start of the entire field instead of on the new line?
self.replace_text_in_range(None, "\n", window, cx);
}
@@ -738,8 +820,6 @@ impl<'app> EditableTextActionHandler<Context<'app, Self>> for EditableTextState
if let Some(caret_pos) = self.find_position_in_vertical_direction(-1, window.line_height())
{
self.select_to(caret_pos, cx);
//self.scroll_to_cursor();
cx.notify();
}
}
@@ -752,8 +832,6 @@ impl<'app> EditableTextActionHandler<Context<'app, Self>> for EditableTextState
if let Some(caret_pos) = self.find_position_in_vertical_direction(1, window.line_height()) {
self.select_to(caret_pos, cx);
//self.scroll_to_cursor();
cx.notify();
}
}