From 6dcc1e8677d772d0d1f44451a5c24dca3f66fa97 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 20 Jun 2026 19:50:22 -0400 Subject: [PATCH] begin merging paint implementations for multi-line runs --- .../src/editable_text/input_element.rs | 270 +++++++++++++----- .../src/editable_text/shared_state.rs | 155 +++++++++- 2 files changed, 355 insertions(+), 70 deletions(-) diff --git a/crates/gpui_elements/src/editable_text/input_element.rs b/crates/gpui_elements/src/editable_text/input_element.rs index e4709bff2c..b9ecb5a2ad 100644 --- a/crates/gpui_elements/src/editable_text/input_element.rs +++ b/crates/gpui_elements/src/editable_text/input_element.rs @@ -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, point: Point, align: TextAlign, }, Quad(PaintQuad), } +impl PrepaintElement { + fn build_quads( + offset_corners: Vec<(Point, Point)>, + origin: Point, + color: Hsla, + ) -> impl Iterator { + 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, - 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, containing_range: &Range| { + 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, + segment: &TextLineSegment, + line_y: Pixels, + line_height: Pixels, + offset_y: Pixels, +) -> Vec<(Point, Point)> { + 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 } } diff --git a/crates/gpui_elements/src/editable_text/shared_state.rs b/crates/gpui_elements/src/editable_text/shared_state.rs index a024e545d9..2cc19175c7 100644 --- a/crates/gpui_elements/src/editable_text/shared_state.rs +++ b/crates/gpui_elements/src/editable_text/shared_state.rs @@ -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, + dirty: bool, +} +impl TextLayoutWrapping { + pub fn new(text_style: TextStyle, wrap_width: Option) -> 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, + pub lines: Vec, /// The bounds of the text area, in window coordinates. /// Cached for IME operations. pub bounds: Bounds, } +pub(super) struct TextLineSegment { + /// The utf8 byte range in the content string that this line covers. + pub text_range: Range, + /// The shaped and wrapped text for this line, if available. + pub wrapped_line: Option>, + + /// 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> { + 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 { + &self.layout_data.lines + } + + fn build_wrapped_lines(&self, content: &str, window: &Window) -> Vec { + 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,