reimplement text layout wrapping using the existing gpui TextLayout logic

This commit is contained in:
temportalflux
2026-07-11 09:31:07 -04:00
parent 3539798e9c
commit 7caea04bf6
4 changed files with 161 additions and 193 deletions
@@ -85,10 +85,6 @@ impl EditableTextElement for TextInputElement {
fn placeholder(&self) -> &Option<SharedString> {
&self.placeholder
}
fn should_wrap(&self) -> bool {
false
}
}
impl Element for TextInputElement {
@@ -1,12 +1,12 @@
use crate::editable_text::{
TextInputStateBase, TextLayoutWrapping, TextLineSegment,
TextInputLayoutData, TextLineSegment,
actions::{EditableInputActionElement, EditableTextActionHandler},
};
use gpui::{
Along, App, Axis, Bounds, ContentMask, Context, CursorStyle, DispatchPhase, Display,
ElementInputHandler, Entity, FocusHandle, Focusable, Hitbox, HitboxBehavior, Hsla,
InteractiveElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
Pixels, Point, ScrollWheelEvent, SharedString, Style, TextAlign, TextStyle, Window,
Pixels, Point, ScrollWheelEvent, SharedString, Size, Style, TextAlign, TextLayout, Window,
WrappedLine, fill, point, px, size,
};
use smallvec::SmallVec;
@@ -52,7 +52,6 @@ pub struct PrepaintState {
pub trait EditableTextElement: InteractiveElement + EditableInputActionElement {
fn init_state(&self, cx: &mut Context<Self::State>) -> Self::State;
fn placeholder(&self) -> &Option<SharedString>;
fn should_wrap(&self) -> bool;
fn shared_request_layout(
&mut self,
@@ -73,21 +72,156 @@ pub trait EditableTextElement: InteractiveElement + EditableInputActionElement {
}
};
// TODO: This required a gpui api change in order to sync the focus handle between Interactivity and TextInputStateBase
self.interactivity()
.track_focus(state.read(cx).focus_handle(cx));
let focus_handle;
let show_placeholder;
let storage_version;
{
let state = state.read(cx);
focus_handle = state.focus_handle(cx);
show_placeholder = state.storage().content_utf8().is_empty();
storage_version = state.storage().version();
}
// TODO: This required a gpui api change in order to sync the focus handle between Interactivity and TextInputStateBase
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 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| {
//let text_style = window.text_style();
// NOTE: Loosely mirrors TextLayout::layout
let text_layout_id = window.request_measured_layout(Default::default(), {
let text_style = window.text_style();
let font_size = text_style.font_size.to_pixels(window.rem_size());
let line_height = window.pixel_snap(
text_style
.line_height
.to_pixels(font_size.into(), window.rem_size()),
);
move |known_dimensions, available_space, window, cx| {
let text: SharedString;
let color: Hsla;
let prev_wrap_width: Option<Pixels>;
let prev_size: Option<Size<Pixels>>;
let last_seen_storage_version: u16;
// TODO: allocate the interior text layout and provide it as a child to the interactivity layout
window.request_layout(style.clone(), None, cx)
{
let state = state.read(cx);
match show_placeholder {
false => {
text = SharedString::from(state.storage().content_utf8());
color = text_style.color;
}
true => {
text = placeholder.clone().unwrap_or_default();
color = placeholder_color;
}
}
prev_wrap_width = state.layout_data.wrap_width;
prev_size = state.layout_data.size;
last_seen_storage_version =
state.layout_data.last_seen_storage_version;
}
let runs = vec![gpui::TextRun {
len: text.len(),
font: text_style.font(),
color,
background_color: None,
underline: None,
strikethrough: None,
}];
let wrap_width = TextLayout::evaluate_wrap_width(
&text_style.white_space,
known_dimensions,
available_space,
);
let truncation = TextLayout::evaluate_overflow(
&text_style,
known_dimensions,
available_space,
);
if let Some(size) = prev_size
&& (wrap_width.is_none() || wrap_width == prev_wrap_width)
&& truncation.width.is_none()
&& storage_version == last_seen_storage_version
{
return size;
}
let (text, runs) = TextLayout::apply_truncation(
text,
&text_style,
font_size,
wrap_width,
&truncation,
&runs,
cx,
);
let wrapped_lines = window
.text_system()
.shape_text(
text,
font_size,
&runs,
wrap_width,
text_style.line_clamp,
)
.unwrap_or_default();
// Build the size of the text and convert the wrapped_lines into
// lines that will be cached in state and painted.
let mut size: Size<Pixels> = Size::default();
let mut pos_y = 0;
let mut line_start = 0;
let mut lines = Vec::with_capacity(wrapped_lines.len());
for line in wrapped_lines {
let line_size = line.size(line_height);
size.height += line_size.height;
size.width = size.width.max(line_size.width).ceil();
let num_visual_lines = line.wrap_boundaries().len() + 1;
let line_len = line.len();
lines.push(TextLineSegment {
text_range: line_start..line_start + line_len,
wrapped_line: Some(Arc::new(line)),
pos_y,
num_visual_lines,
});
line_start += line_len;
pos_y += num_visual_lines;
}
let layout_data = TextInputLayoutData {
wrap_width,
size: Some(size),
last_seen_storage_version,
lines,
lines_represent_placeholder: show_placeholder,
};
// Update the state for use in prepaint, paint, and action handlers.
// request_measured_layout caches this scope for processing later
// between layout and prepaint, so we cant just copy/move these values to the outer scope.
state.update(cx, move |state, _cx| {
state.layout_data = layout_data;
});
size
}
});
window.request_layout(style.clone(), Some(text_layout_id), cx)
})
},
);
@@ -144,48 +278,15 @@ pub trait EditableTextElement: InteractiveElement + EditableInputActionElement {
bounds
};
//let text_color = request_layout.text_style.color;
let placeholder_color = Hsla::white().opacity(0.5); // TODO: as an element param
let selection_color = Hsla::blue().opacity(0.5); // TODO: as an element param
let caret_color = Hsla::white(); // TODO: as an element param
/*
let wrap_width = self.should_wrap().then_some(inner_bounds.size.width);
let showing_placeholder = request_layout.state.update(cx, |state, _cx| {
let wrapping = TextLayoutWrapping::new(
request_layout.text_style.clone(),
wrap_width,
state.storage().version(),
);
let show_placeholder = state.storage().content_utf8().is_empty();
state.layout_data.bounds = inner_bounds;
if state.layout_wrapping.integrate(wrapping) {
let (display_text, color) = match show_placeholder {
false => (state.storage().content_utf8(), text_color),
true => {
let value = self.placeholder().as_ref();
let value = value.map(SharedString::as_str).unwrap_or_default();
(value, placeholder_color)
}
};
state.layout_data.lines = TextInputStateBase::build_wrapped_lines(
display_text,
&state.layout_wrapping,
window,
color,
);
}
show_placeholder
});
*/
let showing_placeholder = false;
let state = request_layout.state.read(cx);
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();
let focus_handle = state.focus_handle(cx);
let caret_pos = state.caret_pos();
let selection = state.selected_range();
let ime_range = state.marked_range();
// TODO: Cursor blinking
let cursor_visible = true; // input.cursor_visible();
@@ -203,7 +304,7 @@ pub trait EditableTextElement: InteractiveElement + EditableInputActionElement {
}
};
let mut carent_point = Point::default();
for segment in input.line_segments() {
for segment in &state.layout_data.lines {
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;
@@ -290,7 +391,7 @@ pub trait EditableTextElement: InteractiveElement + EditableInputActionElement {
}
let is_focused = focus_handle.is_focused(window);
if !showing_placeholder && is_focused && cursor_visible {
if !state.layout_data.lines_represent_placeholder && is_focused && cursor_visible {
const CURSOR_WIDTH: f32 = 2.0;
let quad = fill(
Bounds::new(
@@ -4,9 +4,8 @@ use crate::editable_text::{
notify::{TextChanged, TextHistoryPushed},
};
use gpui::{
App, Bounds, ClipboardItem, EntityInputHandler, FocusHandle, Focusable, Hsla,
NavigationDirection, Pixels, Point, SharedString, TextRun, TextStyle, UTF16Selection, Window,
WrappedLine, point,
App, Bounds, ClipboardItem, EntityInputHandler, FocusHandle, Focusable, NavigationDirection,
Pixels, Point, Size, UTF16Selection, Window, WrappedLine, point,
};
use std::{ops::Range, sync::Arc};
@@ -47,49 +46,21 @@ pub struct TextInputStateBase {
focus_handle: FocusHandle,
pub(super) layout_wrapping: TextLayoutWrapping,
pub(super) layout_data: TextInputLayoutData,
}
#[derive(PartialEq)]
pub(super) struct TextLayoutWrapping {
text_style: TextStyle,
wrap_width: Option<Pixels>,
last_seen_storage_version: u16,
}
impl Default for TextLayoutWrapping {
fn default() -> Self {
Self {
text_style: Default::default(),
wrap_width: Default::default(),
last_seen_storage_version: u16::MAX,
}
}
}
impl TextLayoutWrapping {
pub fn new(text_style: TextStyle, wrap_width: Option<Pixels>, storage_version: u16) -> Self {
Self {
text_style,
wrap_width,
last_seen_storage_version: storage_version,
}
}
pub fn integrate(&mut self, other: Self) -> bool {
let dirty = *self != other;
*self = other;
dirty
}
}
#[derive(Default)]
pub(super) struct TextInputLayoutData {
/// 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.
pub size: Option<Size<Pixels>>,
/// The last seen version of `storage` (for tracking when lines need to be reprocessed during layout)
pub last_seen_storage_version: u16,
/// 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>,
/// The bounds of the text area, in window coordinates.
/// Cached for IME operations.
pub bounds: Bounds<Pixels>,
pub lines_represent_placeholder: bool,
}
pub(super) struct TextLineSegment {
/// The utf8 byte range in the content string that this line covers.
@@ -125,7 +96,6 @@ impl TextInputStateBase {
focus_handle: cx.focus_handle(),
layout_wrapping: TextLayoutWrapping::default(),
layout_data: TextInputLayoutData::default(),
}
}
@@ -163,101 +133,6 @@ impl TextInputStateBase {
}
impl TextInputStateBase {
pub(super) fn line_segments(&self) -> &Vec<TextLineSegment> {
&self.layout_data.lines
}
pub(super) fn build_wrapped_lines(
content: &str,
wrapping: &TextLayoutWrapping,
window: &Window,
color: Hsla,
) -> Vec<TextLineSegment> {
let text_style = &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(),
color,
background_color: None,
underline: None,
strikethrough: None,
};
let wrapped_lines = window
.text_system()
.shape_text(
SharedString::from(line_slice.to_string()),
font_size,
&[run],
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
}
/// Returns the utf-8 character position of the start of the line that contains the provided pixel-point.
pub fn index_for_pixel_point(&self, point: Point<Pixels>, line_height: Pixels) -> usize {
let storage_len_utf8 = self.storage.content_utf8().len();
@@ -4,8 +4,8 @@ use crate::editable_text::{
shared_element::{self, EditableTextElement},
};
use gpui::{
App, Bounds, Element, ElementId, Entity, Hitbox, InteractiveElement, Interactivity,
IntoElement, Pixels, SharedString, StyleRefinement, Styled, TextStyle, WeakEntity, Window,
App, Bounds, Element, ElementId, InteractiveElement, Interactivity, IntoElement, Pixels,
SharedString, StyleRefinement, Styled, WeakEntity, Window,
};
use std::{cell::RefCell, rc::Rc};
@@ -84,10 +84,6 @@ impl EditableTextElement for TextAreaElement {
fn placeholder(&self) -> &Option<SharedString> {
&self.placeholder
}
fn should_wrap(&self) -> bool {
true
}
}
impl Element for TextAreaElement {