input: Improve Input performance by only shape visible lines. (#1222)

Close #1193

Ref issue #1220 

This PR aim to shape text in visible ranges to reduce `shape_text` time
for large document case.

- Fix rendering might be delayed and cause blanks when scrolling to
follow the selection.
- Fix `RopeExt::lines` method to avoid call `line` method on skipped
iter.
This commit is contained in:
Jason Lee 2025-09-09 14:41:52 +08:00 committed by GitHub
parent 84b2d2a6f5
commit 03c2327cdb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 266 additions and 144 deletions

3
.gitignore vendored
View file

@ -1,6 +1,7 @@
/target
.DS_Store
/docks.json
/profile.json
.vscode
index.scip
*.log
*.log

View file

@ -6,6 +6,7 @@ use gpui::{
Path, Pixels, Point, SharedString, Size, Style, TextAlign, TextRun, UnderlineStyle, Window,
WrappedLine,
};
use rope::Rope;
use smallvec::SmallVec;
use crate::{
@ -60,13 +61,18 @@ impl TextElement {
/// - current line index
fn layout_cursor(
&self,
lines: &[WrappedLine],
line_height: Pixels,
last_layout: &LastLayout,
bounds: &mut Bounds<Pixels>,
line_number_width: Pixels,
window: &mut Window,
cx: &mut App,
) -> (Option<Bounds<Pixels>>, Point<Pixels>, Option<usize>) {
let line_height = last_layout.line_height;
let visible_range = &last_layout.visible_range;
let visible_top = last_layout.visible_top;
let visible_start_offset = last_layout.visible_start_offset;
let lines = &last_layout.lines;
let line_number_width = last_layout.line_number_width;
let state = self.state.read(cx);
let mut selected_range = state.selected_range;
if let Some(marked_range) = &state.marked_range {
@ -89,9 +95,11 @@ impl TextElement {
let mut cursor_start = None;
let mut cursor_end = None;
let mut prev_lines_offset = 0;
let mut offset_y = px(0.);
let mut prev_lines_offset = visible_start_offset;
let mut offset_y = visible_top;
for (line_ix, line) in lines.iter().enumerate() {
let line_ix = visible_range.start + line_ix;
// break loop if all cursor positions are found
if cursor_pos.is_some() && cursor_start.is_some() && cursor_end.is_some() {
break;
@ -124,6 +132,14 @@ impl TextElement {
prev_lines_offset += line.len() + 1;
}
// If cursor_pos and cursor_end is still None, it means the cursor is at the end of the text.
if cursor_pos.is_none() {
cursor_pos = Some(point(px(0.), offset_y));
}
if cursor_end.is_none() {
cursor_end = cursor_pos;
}
if let (Some(cursor_pos), Some(cursor_start), Some(cursor_end)) =
(cursor_pos, cursor_start, cursor_end)
{
@ -195,13 +211,17 @@ impl TextElement {
fn layout_selections(
&self,
lines: &[WrappedLine],
line_height: Pixels,
last_layout: &LastLayout,
bounds: &mut Bounds<Pixels>,
line_number_width: Pixels,
_: &mut Window,
cx: &mut App,
) -> Option<Path<Pixels>> {
let line_height = last_layout.line_height;
let visible_top = last_layout.visible_top;
let visible_start_offset = last_layout.visible_start_offset;
let lines = &last_layout.lines;
let line_number_width = last_layout.line_number_width;
let state = self.state.read(cx);
let mut selected_range = state.selected_range;
if let Some(marked_range) = &state.marked_range {
@ -219,10 +239,10 @@ impl TextElement {
(selected_range.end, selected_range.start)
};
let mut prev_lines_offset = 0;
let mut prev_lines_offset = visible_start_offset;
let mut offset_y = visible_top;
let mut line_corners = vec![];
let mut offset_y = px(0.);
for line in lines.iter() {
let line_size = line.size(line_height);
let line_wrap_width = line_size.width;
@ -330,48 +350,54 @@ impl TextElement {
/// Calculate the visible range of lines in the viewport.
///
/// The visible range is based on unwrapped lines (Zero based).
/// Returns
///
/// - visible_range: The visible range is based on unwrapped lines (Zero based).
/// - visible_top: The top position of the first visible line in the scroll viewport.
fn calculate_visible_range(
&self,
state: &InputState,
line_height: Pixels,
input_height: Pixels,
) -> Range<usize> {
) -> (Range<usize>, Pixels) {
// Add extra rows to avoid showing empty space when scroll to bottom.
let extra_rows = 1;
let mut visible_top = px(0.);
if state.mode.is_single_line() {
return 0..1;
return (0..1, visible_top);
}
let Some(last_layout) = state.last_layout.as_ref() else {
return 0..1;
};
let total_lines = state.text_wrapper.len();
let scroll_top = state.scroll_handle.offset().y;
let total_lines = last_layout.lines.len();
let mut visible_range = 0..total_lines;
let mut line_bottom = px(0.);
for (ix, line) in last_layout.lines.iter().enumerate() {
line_bottom += (line.wrap_boundaries.len() + 1) * line_height;
for (ix, line) in state.text_wrapper.lines.iter().enumerate() {
let wrapped_height = (line.wrap_lines + 1) * line_height;
line_bottom += wrapped_height;
if line_bottom < -scroll_top {
visible_top = line_bottom - wrapped_height;
visible_range.start = ix;
}
if line_bottom + scroll_top >= input_height {
visible_range.end = (ix + 1).min(total_lines);
visible_range.end = (ix + extra_rows).min(total_lines);
break;
}
}
visible_range
(visible_range, visible_top)
}
/// First usize is the offset of skipped.
fn highlight_lines(
&mut self,
visible_range: &Range<usize>,
_visible_top: Pixels,
visible_start_offset: usize,
cx: &mut App,
) -> Option<(usize, Vec<(Range<usize>, HighlightStyle)>)> {
) -> Option<Vec<(Range<usize>, HighlightStyle)>> {
let theme = cx.theme().highlight_theme.clone();
self.state.update(cx, |state, cx| match &state.mode {
InputMode::CodeEditor {
@ -389,22 +415,17 @@ impl TextElement {
return None;
};
let mut offset = 0;
let mut skipped_offset = 0;
let mut offset = visible_start_offset;
let mut styles = vec![];
for (ix, line) in state.text.lines().enumerate() {
for line in state
.text
.lines()
.skip(visible_range.start)
.take(visible_range.len())
{
// +1 for `\n`
let line_len = line.len() + 1;
if ix < visible_range.start {
offset += line_len;
skipped_offset = offset;
continue;
}
if ix > visible_range.end {
break;
}
let range = offset..offset + line_len;
let line_styles = highlighter.styles(&range, &theme);
styles = gpui::combine_highlights(styles, line_styles).collect();
@ -412,26 +433,24 @@ impl TextElement {
offset = range.end;
}
let mut marker_styles = vec![];
for marker in markers.iter() {
if let Some(range) = &marker.range {
if range.start < skipped_offset {
continue;
}
// Combine marker styles
if !markers.is_empty() {
let mut marker_styles = vec![];
for marker in markers.iter() {
if let Some(range) = &marker.range {
if range.start < visible_start_offset {
continue;
}
let node_range = range.start..range.end;
if node_range.start >= visible_range.start
|| node_range.end <= visible_range.end
{
marker_styles
.push((node_range, marker.severity.highlight_style(&theme, cx)));
.push((range.clone(), marker.severity.highlight_style(&theme, cx)));
}
}
styles = gpui::combine_highlights(marker_styles, styles).collect();
}
styles = gpui::combine_highlights(marker_styles, styles).collect();
Some((skipped_offset, styles))
Some(styles)
}
_ => None,
})
@ -543,8 +562,12 @@ impl Element for TextElement {
let state = self.state.read(cx);
let line_height = window.line_height();
let visible_range = self.calculate_visible_range(&state, line_height, bounds.size.height);
let highlight_styles = self.highlight_lines(&visible_range, cx);
let (visible_range, visible_top) =
self.calculate_visible_range(&state, line_height, bounds.size.height);
let visible_start_offset = state.text.line_start_offset(visible_range.start);
let highlight_styles =
self.highlight_lines(&visible_range, visible_top, visible_start_offset, cx);
let state = self.state.read(cx);
let multi_line = state.mode.is_multi_line();
@ -556,11 +579,17 @@ impl Element for TextElement {
let mut bounds = bounds;
let (display_text, text_color) = if is_empty {
(placeholder, cx.theme().muted_foreground)
(
Rope::from(placeholder.as_str()),
cx.theme().muted_foreground,
)
} else if state.masked {
("*".repeat(text.chars_count()).into(), cx.theme().foreground)
(
Rope::from("*".repeat(text.chars_count())),
cx.theme().foreground,
)
} else {
(text.to_string().into(), cx.theme().foreground)
(text.clone(), cx.theme().foreground)
};
let text_style = window.text_style();
@ -607,14 +636,8 @@ impl Element for TextElement {
};
let runs = if !is_empty {
if let Some((skipped_offset, highlight_styles)) = highlight_styles {
if let Some(highlight_styles) = highlight_styles {
let mut runs = vec![];
if skipped_offset > 0 {
runs.push(TextRun {
len: skipped_offset,
..run.clone()
});
}
runs.extend(highlight_styles.iter().map(|(range, style)| {
let mut run = text_style.clone().highlight(*style).to_run(range.len());
@ -663,21 +686,23 @@ impl Element for TextElement {
None
};
// NOTE: If there have about 10K lines, this will take about 5~6ms.
// let measure = Measure::new("shape_text");
// NOTE: Here 50 lines about 150µs
// let measure = crate::Measure::new("shape_text");
let visible_text = display_text
.slice_rows(visible_range.start as u32..visible_range.end as u32)
.to_string();
let lines = window
.text_system()
.shape_text(display_text, font_size, &runs, wrap_width, None)
.shape_text(visible_text.into(), font_size, &runs, wrap_width, None)
.expect("failed to shape text");
// measure.end();
let mut max_line_width = px(0.);
let mut total_wrapped_lines = 0;
let total_wrapped_lines = state.text_wrapper.len();
for line in lines.iter() {
// FIXME: The `shape_text` measured width is not stable, sometime will large, sometime small.
max_line_width = max_line_width.max(line.width());
// +1 is the first line, `wrap_boundaries` is the wrapped lines after the `\n`.
total_wrapped_lines += 1 + line.wrap_boundaries.len();
}
let scroll_size = size(
@ -689,6 +714,16 @@ impl Element for TextElement {
(total_wrapped_lines as f32 * line_height).max(bounds.size.height),
);
let last_layout = LastLayout {
visible_range,
visible_top,
visible_start_offset,
line_height,
wrap_width,
line_number_width,
lines: Rc::new(lines),
};
// `position_for_index` for example
//
// #### text
@ -720,23 +755,10 @@ impl Element for TextElement {
// Calculate the scroll offset to keep the cursor in view
let (cursor_bounds, cursor_scroll_offset, current_line_index) = self.layout_cursor(
&lines,
line_height,
&mut bounds,
line_number_width,
window,
cx,
);
let (cursor_bounds, cursor_scroll_offset, current_line_index) =
self.layout_cursor(&last_layout, &mut bounds, window, cx);
let selection_path = self.layout_selections(
&lines,
line_height,
&mut bounds,
line_number_width,
window,
cx,
);
let selection_path = self.layout_selections(&last_layout, &mut bounds, window, cx);
let state = self.state.read(cx);
let line_numbers = if state.mode.line_number() {
@ -760,13 +782,8 @@ impl Element for TextElement {
}];
// build line numbers
for (ix, line) in lines
.iter()
.skip(visible_range.start)
.take(visible_range.len())
.enumerate()
{
let ix = ix + visible_range.start;
for (ix, line) in last_layout.lines.iter().enumerate() {
let ix = last_layout.visible_range.start + ix;
let line_no = ix + 1;
let mut line_no_text = format!("{:>4}", line_no);
@ -793,13 +810,7 @@ impl Element for TextElement {
PrepaintState {
bounds,
last_layout: LastLayout {
lines: Rc::new(lines),
line_height,
visible_range,
line_number_width,
wrap_width,
},
last_layout,
scroll_size,
line_numbers,
cursor_bounds,
@ -859,10 +870,7 @@ impl Element for TextElement {
let line_height = window.line_height();
let origin = bounds.origin;
let mut invisible_top_padding = px(0.);
for line in prepaint.last_layout.lines.iter().take(visible_range.start) {
invisible_top_padding += line.size(line_height).height;
}
let invisible_top_padding = prepaint.last_layout.visible_top;
let mut mask_offset_y = px(0.);
if self.state.read(cx).masked {
@ -910,12 +918,7 @@ impl Element for TextElement {
// Paint text
let mut offset_y = mask_offset_y + invisible_top_padding;
for line in prepaint
.last_layout
.iter()
.skip(visible_range.start)
.take(visible_range.len())
{
for line in prepaint.last_layout.lines.iter() {
let p = point(
origin.x + prepaint.last_layout.line_number_width,
origin.y + offset_y,

View file

@ -2,18 +2,26 @@ use rope::{Point, Rope};
/// An extension trait for `Rope` to provide additional utility methods.
pub trait RopeExt {
/// Get the line at the given row index, including the `\r` at the end, but not `\n`.
/// Get the line at the given row (0-based) index, including the `\r` at the end, but not `\n`.
///
/// Return empty rope if the row is out of bounds.
/// Return empty rope if the row (0-based) is out of bounds.
fn line(&self, row: usize) -> Rope;
/// Start offset of the line at the given row (0-based) index.
fn line_start_offset(&self, row: usize) -> usize;
/// Line the end offset (including `\n`) of the line at the given row (0-based) index.
///
/// Return the end of the rope if the row is out of bounds.
fn line_end_offset(&self, row: usize) -> usize;
/// Return the number of lines in the rope.
fn lines_len(&self) -> usize;
/// Return the lines iterator.
///
/// Each line is including the `\n` at the end, but not `\n`.
fn lines(&self) -> impl Iterator<Item = Rope>;
fn lines(&self) -> RopeLines;
/// Check is equal to another rope.
fn eq(&self, other: &Rope) -> bool;
@ -29,6 +37,55 @@ pub trait RopeExt {
fn char_at(&self, offset: usize) -> Option<char>;
}
/// An iterator over the lines of a `Rope`.
pub struct RopeLines {
row: usize,
end_row: usize,
rope: Rope,
}
impl RopeLines {
/// Create a new `RopeLines` iterator.
pub fn new(rope: Rope) -> Self {
let end_row = rope.lines_len();
Self {
row: 0,
end_row,
rope,
}
}
}
impl Iterator for RopeLines {
type Item = Rope;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
if self.row >= self.end_row {
return None;
}
let line = self.rope.line(self.row);
self.row += 1;
Some(line)
}
#[inline]
fn nth(&mut self, n: usize) -> Option<Self::Item> {
self.row = self.row.saturating_add(n);
self.next()
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.end_row - self.row;
(len, Some(len))
}
}
impl std::iter::ExactSizeIterator for RopeLines {}
impl std::iter::FusedIterator for RopeLines {}
impl RopeExt for Rope {
fn line(&self, row: usize) -> Rope {
let row = row as u32;
@ -37,12 +94,25 @@ impl RopeExt for Rope {
self.slice(start..end)
}
fn line_start_offset(&self, row: usize) -> usize {
let row = row as u32;
self.point_to_offset(Point::new(row, 0))
}
fn line_end_offset(&self, row: usize) -> usize {
if row > self.max_point().row as usize {
return self.len();
}
self.line_start_offset(row) + self.line_len(row as u32) as usize
}
fn lines_len(&self) -> usize {
self.max_point().row as usize + 1
}
fn lines(&self) -> impl Iterator<Item = Rope> {
(0..self.lines_len()).map(move |row| self.line(row))
fn lines(&self) -> RopeLines {
RopeLines::new(self.clone())
}
fn eq(&self, other: &Rope) -> bool {
@ -108,6 +178,25 @@ mod tests {
);
}
#[test]
fn test_line_start_end_offset() {
let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
assert_eq!(rope.line_start_offset(0), 0);
assert_eq!(rope.line_end_offset(0), 5);
assert_eq!(rope.line_start_offset(1), 6);
assert_eq!(rope.line_end_offset(1), 12);
assert_eq!(rope.line_start_offset(2), 13);
assert_eq!(rope.line_end_offset(2), 34);
assert_eq!(rope.line_start_offset(3), 35);
assert_eq!(rope.line_end_offset(3), 39);
assert_eq!(rope.line_start_offset(4), 39);
assert_eq!(rope.line_end_offset(4), 39);
}
#[test]
fn test_chars_count() {
let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文🎉\nRope");

View file

@ -7,7 +7,7 @@ use rope::Rope;
use serde::Deserialize;
use smallvec::SmallVec;
use std::cell::RefCell;
use std::ops::{Deref, Range};
use std::ops::Range;
use std::rc::Rc;
use sum_tree::Bias;
use unicode_segmentation::*;
@ -31,6 +31,7 @@ use super::{
};
use crate::input::hover_popover::DiagnosticPopover;
use crate::input::marker::Marker;
use crate::input::text_wrapper::LineWrap;
use crate::input::{Cursor, LineColumn, RopeExt as _, Selection};
use crate::{history::History, scroll::ScrollbarState, Root};
@ -215,26 +216,22 @@ pub fn init(cx: &mut App) {
#[derive(Clone)]
pub(super) struct LastLayout {
/// The last layout lines.
/// The visible range (no wrap) of lines in the viewport, the value is row (0-based) index.
pub(super) visible_range: Range<usize>,
/// The first visible line top position in scroll viewport.
pub(super) visible_top: Pixels,
/// The start byte offset of the first visible line.
pub(super) visible_start_offset: usize,
/// The last layout lines (Only have visible lines).
pub(super) lines: Rc<SmallVec<[WrappedLine; 1]>>,
/// The line_height of text layout, this will change will InputElement painted.
pub(super) line_height: Pixels,
/// The visible range (no wrap) of lines in the viewport.
pub(super) visible_range: Range<usize>,
/// The wrap width of text layout, this will change will InputElement painted.
pub(super) wrap_width: Option<Pixels>,
/// The line number area width of text layout, if not line number, this will be 0px.
pub(super) line_number_width: Pixels,
}
impl Deref for LastLayout {
type Target = Rc<SmallVec<[WrappedLine; 1]>>;
fn deref(&self) -> &Self::Target {
&self.lines
}
}
/// InputState to keep editing state of the [`super::TextInput`].
pub struct InputState {
pub(super) focus_handle: FocusHandle,
@ -536,8 +533,8 @@ impl InputState {
};
let line_height = last_layout.line_height;
let mut prev_lines_offset = 0;
let mut y_offset = px(0.);
let mut prev_lines_offset = last_layout.visible_start_offset;
let mut y_offset = last_layout.visible_top;
for (line_index, line) in last_layout.lines.iter().enumerate() {
let local_offset = offset.saturating_sub(prev_lines_offset);
if let Some(pos) = line.position_for_index(local_offset, line_height) {
@ -682,6 +679,17 @@ impl InputState {
/// Update the soft wrap mode for multi-line input, default is true.
pub fn set_soft_wrap(&mut self, wrap: bool, _: &mut Window, cx: &mut Context<Self>) {
self.soft_wrap = wrap;
if wrap {
let wrap_width = self
.last_layout
.as_ref()
.and_then(|b| b.wrap_width)
.unwrap_or(self.input_bounds.size.width);
self.text_wrapper.set_wrap_width(Some(wrap_width), cx);
} else {
self.text_wrapper.set_wrap_width(None, cx);
}
cx.notify();
}
@ -1715,40 +1723,55 @@ impl InputState {
// - included the scroll offset.
let inner_position = position - bounds.origin - point(line_number_width, px(0.));
let mut index = 0;
let mut y_offset = px(0.);
for (_, line) in last_layout.lines.iter().enumerate() {
let line_origin = self.line_origin_with_y_offset(&mut y_offset, &line, line_height);
let mut index = last_layout.visible_start_offset;
let mut y_offset = last_layout.visible_top;
for (ix, line) in self
.text_wrapper
.lines
.iter()
.skip(last_layout.visible_range.start)
.enumerate()
{
let line_origin = self.line_origin_with_y_offset(&mut y_offset, line, line_height);
let pos = inner_position - line_origin;
let Some(rendered_line) = last_layout.lines.get(ix) else {
if pos.y < line_origin.y + line_height {
break;
}
continue;
};
// Return offset by use closest_index_for_x if is single line mode.
if self.mode.is_single_line() {
return line.unwrapped_layout.closest_index_for_x(pos.x);
return rendered_line.unwrapped_layout.closest_index_for_x(pos.x);
}
let index_result = line.closest_index_for_position(pos, line_height);
let index_result = rendered_line.closest_index_for_position(pos, line_height);
if let Ok(v) = index_result {
index += v;
break;
} else if let Ok(_) = line.index_for_position(point(px(0.), pos.y), line_height) {
} else if let Ok(_) =
rendered_line.index_for_position(point(px(0.), pos.y), line_height)
{
// Click in the this line but not in the text, move cursor to the end of the line.
// The fallback index is saved in Err from `index_for_position` method.
index += index_result.unwrap_err();
break;
} else if line.text.trim_end_matches(|c| c == '\r').len() == 0 {
} else if rendered_line.text.trim_end_matches(|c| c == '\r').len() == 0 {
// empty line on Windows is `\r`, other is ''
let line_bounds = Bounds {
origin: line_origin,
size: gpui::size(bounds.size.width, line_height),
};
let pos = inner_position;
index += line.len();
index += rendered_line.len();
if line_bounds.contains(&pos) {
break;
}
} else {
index += line.len();
index += rendered_line.len();
}
// +1 for revert `lines` split `\n`
@ -1766,7 +1789,7 @@ impl InputState {
fn line_origin_with_y_offset(
&self,
y_offset: &mut Pixels,
line: &WrappedLine,
line: &LineWrap,
line_height: Pixels,
) -> Point<Pixels> {
// NOTE: About line.wrap_boundaries.len()
@ -1775,7 +1798,7 @@ impl InputState {
// If have 2 line, the value is 1
if self.mode.is_multi_line() {
let p = point(px(0.), *y_offset);
let height = line_height + line.wrap_boundaries.len() as f32 * line_height;
let height = line_height + line.wrap_lines as f32 * line_height;
*y_offset = *y_offset + height;
p
} else {
@ -2233,8 +2256,8 @@ impl EntityInputHandler for InputState {
let mut start_origin = None;
let mut end_origin = None;
let line_number_origin = point(line_number_width, px(0.));
let mut y_offset = px(0.);
let mut index_offset = 0;
let mut y_offset = last_layout.visible_top;
let mut index_offset = last_layout.visible_start_offset;
for line in last_layout.lines.iter() {
if start_origin.is_some() && end_origin.is_some() {
@ -2282,10 +2305,11 @@ impl EntityInputHandler for InputState {
let last_layout = self.last_layout.as_ref()?;
let line_height = last_layout.line_height;
let line_point = self.last_bounds?.localize(&point)?;
let offset = last_layout.visible_start_offset;
for line in last_layout.lines.iter() {
if let Ok(utf8_index) = line.index_for_position(line_point, line_height) {
return Some(self.offset_to_utf16(utf8_index));
return Some(self.offset_to_utf16(offset + utf8_index));
}
}

View file

@ -22,7 +22,7 @@ pub(super) struct LineWrap {
/// After use lines to calculate the scroll size of the TextArea
pub(super) struct TextWrapper {
pub(super) text: Rope,
/// The wrapped lines, value is start and end index of the line.
/// The wrapped lines (Inlucde the first line), value is start and end index of the line.
pub(super) wrapped_lines: Vec<Range<usize>>,
/// The lines by split \n
pub(super) lines: Vec<LineWrap>,
@ -45,6 +45,11 @@ impl TextWrapper {
}
}
/// Get the total number of lines including wrapped lines.
pub(super) fn len(&self) -> usize {
self.wrapped_lines.len()
}
pub(super) fn set_wrap_width(&mut self, wrap_width: Option<Pixels>, cx: &mut App) {
if wrap_width == self.wrap_width {
return;