begin merging paint implementations for multi-line runs

This commit is contained in:
temportalflux
2026-07-11 09:31:07 -04:00
parent bee1f68e9c
commit 6dcc1e8677
2 changed files with 355 additions and 70 deletions
@@ -1,13 +1,16 @@
use std::{ops::Range, sync::Arc};
use crate::editable_text::{
EditableInputActionElement, EditableTextActionHandler, InitStorage, StateBackedElement,
TextInputLayoutData, TextInputState,
TextInputLayoutData, TextInputState, TextLayoutWrapping, TextLineSegment,
};
use gpui::{
Along, App, Axis, Bounds, ContentMask, CursorStyle, DispatchPhase, Display, Element, ElementId,
ElementInputHandler, Entity, FocusHandle, Focusable, Hitbox, HitboxBehavior, Hsla,
InteractiveElement, Interactivity, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, PaintQuad, Pixels, Point, ScrollWheelEvent, ShapedLine, SharedString, Style,
StyleRefinement, Styled, TextAlign, TextRun, TextStyle, Window, fill, point, size,
StyleRefinement, Styled, TextAlign, TextRun, TextStyle, Window, WrappedLine, fill, point, px,
size,
};
use smallvec::SmallVec;
@@ -75,12 +78,25 @@ impl super::StateBackedElement for TextInputElement {
enum PrepaintElement {
Line {
line: ShapedLine,
line: Arc<WrappedLine>,
point: Point<Pixels>,
align: TextAlign,
},
Quad(PaintQuad),
}
impl PrepaintElement {
fn build_quads(
offset_corners: Vec<(Point<Pixels>, Point<Pixels>)>,
origin: Point<Pixels>,
color: Hsla,
) -> impl Iterator<Item = Self> {
let iter = offset_corners.into_iter();
iter.map(move |(offset_start, offset_end)| {
let bounds = Bounds::from_corners(origin + offset_start, origin + offset_end);
PrepaintElement::Quad(fill(bounds, color))
})
}
}
pub mod element {
use smallvec::SmallVec;
@@ -99,7 +115,6 @@ pub mod element {
pub focus_handle: FocusHandle,
pub(super) elements: SmallVec<[PrepaintElement; 3]>,
pub scroll_offset: Point<Pixels>,
pub display_text: SharedString,
pub caret_visible: bool,
}
}
@@ -159,11 +174,33 @@ impl Element for TextInputElement {
window: &mut gpui::Window,
cx: &mut gpui::App,
) -> Self::PrepaintState {
// TODO: no wrapping in single-line
let wrap_width = Some(bounds.size.width);
let wrapping = TextLayoutWrapping::new(request_layout.text_style.clone(), wrap_width);
let showing_placeholder = request_layout.state.update(cx, |state, _cx| {
let text_value = state.storage().content_utf8();
let is_empty = text_value.is_empty();
let display_text = match is_empty {
false => text_value,
true => self
.placeholder
.as_ref()
.map(SharedString::as_str)
.unwrap_or_default(),
};
state.layout_data_mut().bounds = bounds;
state.apply_wrapping(wrapping, display_text, window);
is_empty
});
let input = request_layout.state.read(cx);
let focus_handle = input.focus_handle(cx);
let caret_pos = input.caret_pos();
let selection = input.selected_range();
let ime_range = input.marked_range();
// TODO: Cursor blinking
let cursor_visible = true; // input.cursor_visible();
@@ -174,18 +211,7 @@ impl Element for TextInputElement {
let mut elements = SmallVec::new();
let text_value = input.storage().content_utf8();
let is_empty = text_value.is_empty();
let (display_text, run_color) = match is_empty {
// TODO: Can the SharedString allocation be avoided?
false => (SharedString::new(text_value), text_color),
true => {
let value = self.placeholder.as_ref().cloned().unwrap_or_default();
(value, placeholder_color)
}
};
// TODO: how do we enable scrolling? overflow on interactivity?
let (hitbox, scroll_offset) = self.interactivity.prepaint(
global_id,
inspector_id,
@@ -200,55 +226,111 @@ impl Element for TextInputElement {
},
);
let style = window.text_style();
let font_size = style.font_size.to_pixels(window.rem_size());
let run = TextRun {
len: display_text.len(),
font: style.font(),
color: run_color,
background_color: None,
underline: None,
strikethrough: None,
};
let line = window
.text_system()
.shape_line(display_text.clone(), font_size, &[run], None);
let line_height = window.line_height();
let is_range_contained_by_range =
|text_range: &Range<usize>, containing_range: &Range<usize>| {
if text_range.is_empty() {
containing_range.start <= text_range.start
&& containing_range.end > text_range.start
} else {
containing_range.end > text_range.start
&& containing_range.start < text_range.end
}
};
let mut carent_point = Point::default();
for segment in input.line_segments() {
let line_distance_from_top = segment.pos_y * line_height;
let line_y = line_distance_from_top - scroll_offset.y;
let line_bottom = line_y + line_height * segment.num_visual_lines as f32;
let line_visible = line_bottom >= Pixels::ZERO && line_y <= bounds.size.height;
if !line_visible {
continue;
}
let has_selection = !selection.is_empty() && !is_empty;
if has_selection {
let start_x = line.x_for_index(selection.start);
let end_x = line.x_for_index(selection.end);
let quad = fill(
Bounds::from_corners(
point(
bounds.left() + start_x.min(end_x) - scroll_offset.x,
bounds.top(),
),
point(
bounds.left() + start_x.max(end_x) - scroll_offset.x,
bounds.bottom(),
),
),
selection_color,
);
elements.push(PrepaintElement::Quad(quad));
// TODO: First render all lines (underlines for IME), then all selections, then cursor if no selection
if let Some(wrapped) = &segment.wrapped_line {
let point = bounds.origin + point(Pixels::ZERO, line_y);
elements.push(PrepaintElement::Line {
line: wrapped.clone(),
point,
align: TextAlign::Left,
});
}
let segment_is_empty = segment.text_range.is_empty();
if is_range_contained_by_range(&segment.text_range, &selection) {
if segment_is_empty {
const EMPTY_LINE_SELECTION_WIDTH: Pixels = px(6.);
elements.push(PrepaintElement::Quad(fill(
Bounds::from_corners(
bounds.origin + point(Pixels::ZERO, line_y),
bounds.origin + point(EMPTY_LINE_SELECTION_WIDTH, line_y + line_height),
),
selection_color,
)));
} else {
let offset_corners = build_quad_over_text(
&selection,
segment,
line_y,
line_height,
Pixels::ZERO,
);
elements.extend(PrepaintElement::build_quads(
offset_corners,
bounds.origin,
selection_color,
));
}
}
if !segment_is_empty && let Some(ime_range) = &ime_range {
if !ime_range.is_empty()
&& is_range_contained_by_range(&segment.text_range, &ime_range)
{
const MARKED_TEXT_UNDERLINE_THICKNESS: f32 = 2.0;
let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS);
let underline_offset = line_height - underline_thickness;
let offset_corners = build_quad_over_text(
&ime_range,
segment,
line_y,
line_height,
underline_offset,
);
elements.extend(PrepaintElement::build_quads(
offset_corners,
bounds.origin,
selection_color,
));
}
}
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
};
if is_cursor_in_line && let Some(wrapped) = &segment.wrapped_line {
let local_offset = caret_pos.saturating_sub(segment.text_range.start);
let caret_px = wrapped
.position_for_index(local_offset, line_height)
.unwrap_or_default();
carent_point = caret_px + point(Pixels::ZERO, line_y);
}
}
let caret_x_line = line.x_for_index(caret_pos);
elements.push(PrepaintElement::Line {
line,
point: bounds.origin - point(scroll_offset.x, gpui::px(0.)),
align: TextAlign::Left,
});
let has_selection = !selection.is_empty() && !showing_placeholder;
let is_focused = focus_handle.is_focused(window);
if !has_selection && is_focused && cursor_visible {
let cursor_thickness = gpui::px(2.0);
let cursor_paint_x = bounds.left() + caret_x_line - scroll_offset.x;
const CURSOR_WIDTH: f32 = 2.0;
let quad = fill(
Bounds::new(
point(cursor_paint_x, bounds.top()),
size(cursor_thickness, bounds.bottom() - bounds.top()),
bounds.origin + carent_point - scroll_offset,
size(gpui::px(CURSOR_WIDTH), line_height),
),
caret_color,
);
@@ -260,7 +342,6 @@ impl Element for TextInputElement {
focus_handle,
elements,
scroll_offset,
display_text,
caret_visible: cursor_visible,
}
}
@@ -279,7 +360,6 @@ impl Element for TextInputElement {
window.set_cursor_style(CursorStyle::IBeam, hitbox);
}
let mut layout_data = TextInputLayoutData::default();
let perform_paint = |style: &Style, window: &mut Window, cx: &mut App| {
if style.display == Display::None {
return;
@@ -392,13 +472,13 @@ impl Element for TextInputElement {
}
});
layout_data = window.with_content_mask(Some(ContentMask { bounds }), |window| {
window.with_content_mask(Some(ContentMask { bounds }), |window| {
let line_h = window.line_height();
let mut lines = Vec::with_capacity(prepaint.elements.len());
for element in prepaint.elements.drain(..) {
match element {
PrepaintElement::Line { line, point, align } => {
let _ = line.paint(point, line_h, align, None, window, cx);
let _ = line.paint(point, line_h, align, Some(bounds), window, cx);
lines.push(line);
}
PrepaintElement::Quad(quad) => window.paint_quad(quad),
@@ -406,8 +486,6 @@ impl Element for TextInputElement {
}
// TODO: Render marked IME underlines
TextInputLayoutData { lines, bounds }
});
};
self.interactivity.paint(
@@ -419,9 +497,69 @@ impl Element for TextInputElement {
cx,
perform_paint,
);
}
}
request_layout.state.update(cx, |state, _cx| {
*state.layout_data_mut() = layout_data;
fn build_quad_over_text(
containing_range: &Range<usize>,
segment: &TextLineSegment,
line_y: Pixels,
line_height: Pixels,
offset_y: Pixels,
) -> Vec<(Point<Pixels>, Point<Pixels>)> {
let Some(wrapped) = &segment.wrapped_line else {
return vec![];
};
let line_start = segment.text_range.start;
let line_end = segment.text_range.end;
let subrange_start = containing_range.start.max(line_start) - line_start;
let subrange_end = containing_range.end.min(line_end) - line_start;
let start_pos = wrapped
.position_for_index(subrange_start, line_height)
.unwrap_or_default();
let end_pos = wrapped
.position_for_index(subrange_end, line_height)
.unwrap_or_else(|| {
let last_line_y = line_height * (segment.num_visual_lines - 1) as f32;
point(wrapped.width(), last_line_y)
});
let start_visual_line = (start_pos.y / line_height).floor() as usize;
let end_visual_line = (end_pos.y / line_height).floor() as usize;
if start_visual_line == end_visual_line {
vec![(
point(start_pos.x, line_y + start_pos.y + offset_y),
point(end_pos.x, line_y + start_pos.y + line_height),
)]
} else {
let line_width = wrapped.width();
let middle_lines = (start_visual_line + 1)..end_visual_line;
let mut quad_corners = Vec::with_capacity(middle_lines.end - middle_lines.start + 2);
quad_corners.push((
point(start_pos.x, line_y + start_pos.y + offset_y),
point(line_width, line_y + start_pos.y + line_height),
));
// Middle visual lines
for visual_line in (start_visual_line + 1)..end_visual_line {
let y = line_height * visual_line as f32;
quad_corners.push((
point(Pixels::ZERO, line_y + y + offset_y),
point(line_width, line_y + y + line_height),
));
}
// Last visual line
quad_corners.push((
point(Pixels::ZERO, line_y + end_pos.y + offset_y),
point(end_pos.x, line_y + end_pos.y + line_height),
));
quad_corners
}
}
@@ -3,10 +3,10 @@ use crate::editable_text::{
notify::{TextChanged, TextHistoryPushed},
};
use gpui::{
App, Bounds, ClipboardItem, Entity, FocusHandle, Focusable, NavigationDirection, Pixels, Point,
ShapedLine, UTF16Selection, Window,
App, Bounds, ClipboardItem, Entity, FocusHandle, Focusable, Hsla, NavigationDirection, Pixels,
Point, ShapedLine, SharedString, TextRun, TextStyle, UTF16Selection, Window, WrappedLine,
};
use std::ops::Range;
use std::{ops::Range, sync::Arc};
pub trait TextStateNotifier {
fn notify_changed(&mut self);
@@ -54,18 +54,48 @@ pub struct TextInputStateBase {
focus_handle: FocusHandle,
layout_wrapping: TextLayoutWrapping,
layout_data: TextInputLayoutData,
}
#[derive(Default)]
pub(super) struct TextLayoutWrapping {
text_style: TextStyle,
wrap_width: Option<Pixels>,
dirty: bool,
}
impl TextLayoutWrapping {
pub fn new(text_style: TextStyle, wrap_width: Option<Pixels>) -> Self {
Self {
text_style,
wrap_width,
dirty: false,
}
}
}
#[derive(Default)]
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<ShapedLine>,
pub lines: Vec<TextLineSegment>,
/// The bounds of the text area, in window coordinates.
/// Cached for IME operations.
pub bounds: Bounds<Pixels>,
}
pub(super) struct TextLineSegment {
/// The utf8 byte range in the content string that this line covers.
pub text_range: Range<usize>,
/// The shaped and wrapped text for this line, if available.
pub wrapped_line: Option<Arc<WrappedLine>>,
/// The y-coordinate of this segment which can be multiplied by the line_height
/// to get its pixel location relative to the bounds of the text area.
pub pos_y: usize,
/// The number of segments up to and including this segment in the literal line that has been wrapped.
/// There may be other segments after this one with a larger counter.
pub num_visual_lines: usize,
}
impl Focusable for TextInputStateBase {
fn focus_handle(&self, _: &App) -> FocusHandle {
@@ -87,6 +117,10 @@ impl TextInputStateBase {
focus_handle: cx.focus_handle(),
layout_wrapping: TextLayoutWrapping {
dirty: true,
..TextLayoutWrapping::default()
},
layout_data: TextInputLayoutData::default(),
}
}
@@ -118,6 +152,10 @@ impl TextInputStateBase {
self.selected_range = range;
}
pub fn marked_range(&self) -> Option<Range<usize>> {
self.marked_range.clone()
}
pub(super) fn layout_data(&self) -> &TextInputLayoutData {
&self.layout_data
}
@@ -127,6 +165,115 @@ impl TextInputStateBase {
}
}
impl TextInputStateBase {
pub fn apply_wrapping(
&mut self,
wrapping: TextLayoutWrapping,
display_text: &str,
window: &Window,
) {
let dirty = self.layout_wrapping.dirty
|| self.layout_wrapping.wrap_width != wrapping.wrap_width
|| self.layout_wrapping.text_style != wrapping.text_style;
self.layout_wrapping = wrapping;
if dirty {
self.layout_data.lines = self.build_wrapped_lines(display_text, window);
}
}
pub fn line_segments(&self) -> &Vec<TextLineSegment> {
&self.layout_data.lines
}
fn build_wrapped_lines(&self, content: &str, window: &Window) -> Vec<TextLineSegment> {
let text_style = &self.layout_wrapping.text_style;
let font_size = text_style.font_size.to_pixels(window.rem_size());
let mut lines = Vec::new();
if content.is_empty() {
lines.push(TextLineSegment {
text_range: 0..0,
wrapped_line: None,
pos_y: 0,
num_visual_lines: 1,
});
return lines;
}
let mut pos_y = 0;
let mut current_pos = 0;
while current_pos < content.len() {
let line_end = content[current_pos..]
.find('\n')
.map(|pos| current_pos + pos)
.unwrap_or(content.len());
let line_slice = &content[current_pos..line_end];
if line_slice.is_empty() {
lines.push(TextLineSegment {
text_range: current_pos..current_pos,
wrapped_line: None,
pos_y,
num_visual_lines: 1,
});
pos_y += 1;
} else {
let run = TextRun {
len: line_slice.len(),
font: text_style.font(),
// TODO: This is the actual text color that is stored in WrappedLine,
// needs to reflect the style color provided by paint
color: Hsla::default(),
background_color: None,
underline: None,
strikethrough: None,
};
let wrapped_lines = window
.text_system()
.shape_text(
SharedString::from(line_slice.to_string()),
font_size,
&[run],
self.layout_wrapping.wrap_width,
None,
)
.unwrap_or_default();
for wrapped in wrapped_lines {
let num_visual_lines = wrapped.wrap_boundaries().len() + 1;
lines.push(TextLineSegment {
text_range: current_pos..line_end,
wrapped_line: Some(Arc::new(wrapped)),
pos_y,
num_visual_lines,
});
pos_y += num_visual_lines;
}
}
current_pos = if line_end < content.len() {
line_end + 1
} else {
content.len()
};
}
if content.ends_with('\n') {
lines.push(TextLineSegment {
text_range: content.len()..content.len(),
wrapped_line: None,
pos_y,
num_visual_lines: 1,
});
}
lines
}
}
impl TextInputStateBase {
pub fn ime_text_for_range(
&self,