From bd7bbdd0e68860f5230eb28a49be389b2a467bc0 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Fri, 3 Jul 2026 18:22:59 -0400 Subject: [PATCH] reorganize element implementation to be more readable --- .../src/editable_text/element.rs | 501 ++++++++++-------- .../gpui_elements/src/editable_text/layout.rs | 24 +- .../gpui_elements/src/editable_text/state.rs | 8 +- 3 files changed, 285 insertions(+), 248 deletions(-) diff --git a/crates/gpui_elements/src/editable_text/element.rs b/crates/gpui_elements/src/editable_text/element.rs index d17e6dab26..ea8637a7a0 100644 --- a/crates/gpui_elements/src/editable_text/element.rs +++ b/crates/gpui_elements/src/editable_text/element.rs @@ -1,13 +1,13 @@ use crate::editable_text::{ EditableTextState, actions::{DEFAULT_INPUT_CONTEXT, EditableTextActionElement, EditableTextActionHandler}, - layout::{TextInputLayoutData, TextLineSegment}, + layout::{EditableTextLayoutResult, EditableTextLayoutState, TextLineSegment}, }; use gpui::{ App, Bounds, CursorStyle, DispatchPhase, Display, Element, ElementId, ElementInputHandler, Entity, FocusHandle, Focusable, Hitbox, HitboxBehavior, Hsla, InteractiveElement, - Interactivity, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - PaintQuad, Pixels, Point, SharedString, Size, StatefulInteractiveElement, Style, + Interactivity, IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, PaintQuad, Pixels, Point, SharedString, Size, StatefulInteractiveElement, Style, StyleRefinement, Styled, TextAlign, TextLayout, WeakEntity, Window, WrappedLine, fill, point, px, size, }; @@ -202,6 +202,22 @@ impl EditableTextActionElement for EditableTextElement { } } +struct PrelayoutState { + state: Entity, + prev_layout_state: EditableTextLayoutState, + storage_version: u16, + show_placeholder: bool, + text: Option, + placeholder_color: Hsla, + supports_multiline: bool, + accepts_input: bool, +} + +#[doc(hidden)] +pub struct LayoutState { + state: Entity, +} + struct InteractivityPrepaint { hitbox: Option, scroll_offset: Point, @@ -218,7 +234,7 @@ pub struct PrepaintState { } impl Element for EditableTextElement { - type RequestLayoutState = Entity; + type RequestLayoutState = LayoutState; type PrepaintState = PrepaintState; fn id(&self) -> Option { @@ -236,192 +252,57 @@ impl Element for EditableTextElement { window: &mut Window, cx: &mut App, ) -> (gpui::LayoutId, Self::RequestLayoutState) { - // Fetches or initializes the internal state of the field - let state = self.state_entity.borrow().upgrade(); - let state = match state { - Some(entity) => entity, - None => match &self.interactivity.element_id { - None => unimplemented!("all input elements must be assigned an id"), - Some(element_id) => { - let state = EditableTextState::use_keyed(element_id.clone(), window, cx); - // store a reference to the entity owned by the element for access in action handlers - *self.state_entity_rc().borrow_mut() = state.downgrade(); - state - } - }, - }; + let entity = self.find_or_create_state(window, cx); // Read new state information from the underlying entity. // Block-wrapped so that the state being read is dropped before continuing. - let (show_placeholder, storage_version, next_scroll_offset) = { - let state = state.read(cx); + let (prelayout, next_scroll_offset) = { + let state = entity.read(cx); let show_placeholder = state.as_str().is_empty(); - ( + let text = match show_placeholder { + false => Some(SharedString::from(state.as_str())), + true => self.placeholder.clone(), + }; + + let prelayout = PrelayoutState { + state: entity.clone(), + prev_layout_state: state.layout_data.state, show_placeholder, - state.version(), - state.layout_data.next_scroll_offset, - ) + storage_version: state.version(), + text, + placeholder_color: self.colors.placeholder, + supports_multiline: self.supports_multiline, + accepts_input: self.accepts_input, + }; + (prelayout, state.layout_data.next_scroll_offset) }; // Update the scroll offset of the element when the user's caret goes out of scope. if let Some(scroll_offset) = next_scroll_offset { self.interactivity .set_scroll_offset(global_id, window, -scroll_offset); + + // Clear scroll_layout here in the very likely event that we wont need to + // recompute layout, in which case the layout result isnt rebuilt during `perform_text_layout`. + entity.update(cx, |state, _cx| { + state.layout_data.next_scroll_offset = None; + }); } - let placeholder = self.placeholder.clone(); - let placeholder_color = self.colors.placeholder; - let supports_multiline = self.supports_multiline; - let accepts_input = self.accepts_input; 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(), move |window| { - // 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, - color, - prev_wrap_width, - prev_size, - last_seen_storage_version, - ) = { - let state = state.read(cx); - let (text, color) = match show_placeholder { - false => (SharedString::from(state.as_str()), text_style.color), - true => { - (placeholder.clone().unwrap_or_default(), placeholder_color) - } - }; - ( - text, - color, - state.layout_data.wrap_width, - state.layout_data.size, - 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 text_len = text.len(); - - 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 = 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 mut line_len = line.len(); - if line_len < text_len { - // to offset for new-line characters that are - // omitted from WrappedLine range - line_len += 1; - } - - let segment = TextLineSegment { - text_range: line_start..line_start + line_len, - wrapped_line: Some(Arc::new(line)), - pos_y, - }; - line_start += line_len; - pos_y += segment.row_count(); - lines.push(segment); - } - - 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. - // 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 - } - }); - + let text_layout_id = prelayout.perform_text_layout(window); window.request_layout(style.clone(), Some(text_layout_id), cx) }) }, ); - (layout_id, state) + (layout_id, LayoutState { state: entity }) } fn prepaint( @@ -435,20 +316,18 @@ impl Element for EditableTextElement { ) -> Self::PrepaintState { // should reflect the text content layout size of the stored text, // so that scrolling can take it into account during prepaint. - let content_size; - let caret; - let focus_handle; - { - let state = request_layout.read(cx); - content_size = state.layout_data.size.unwrap_or_else(|| bounds.size); - caret = state.caret_entity().clone(); - focus_handle = state.focus_handle(cx); - } + let (content_size, caret_entity, focus_handle) = { + let state = request_layout.state.read(cx); + let content_size = state.layout_data.state.size.unwrap_or_else(|| bounds.size); + let caret = state.caret_entity().clone(); + let focus_handle = state.focus_handle(cx); + (content_size, caret, focus_handle) + }; let is_focused = focus_handle.is_focused(window); - let caret_visible = caret.update(cx, |caret, cx| caret.update_focus(is_focused, cx)); - + let caret_visible = caret_entity.update(cx, |caret, cx| caret.update_focus(is_focused, cx)); window.set_focus_handle(&focus_handle, cx); + let prepaint = self.interactivity.prepaint( global_id, inspector_id, @@ -470,7 +349,7 @@ impl Element for EditableTextElement { bounds.size.height -= padding.top + padding.bottom; bounds }; - request_layout.update(cx, |state, _cx| { + 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 = @@ -485,7 +364,7 @@ impl Element for EditableTextElement { }, ); - let state = request_layout.read(cx); + let state = request_layout.state.read(cx); let elements = PrepaintElements::build_elements(state, &prepaint, &self.colors, window); PrepaintState { @@ -510,69 +389,18 @@ impl Element for EditableTextElement { } let accepts_input = self.accepts_input; - let inner_bounds = prepaint.interactivity.inner_bounds; - let to_local_position = -(bounds.origin + prepaint.interactivity.scroll_offset); + let hitbox = prepaint.interactivity.hitbox.clone(); let perform_paint = |style: &Style, window: &mut Window, cx: &mut App| { if style.display == Display::None { return; } + // Register event listeners to the window for the next frame if accepts_input { - let ime_handler = ElementInputHandler::new(inner_bounds, request_layout.clone()); - window.handle_input(&prepaint.focus_handle, ime_handler, cx); + Self::process_frame_events(prepaint, bounds, &request_layout.state, window, cx); } - window.on_mouse_event({ - let focus_handle = prepaint.focus_handle.clone(); - let state = request_layout.clone(); - move |event: &MouseDownEvent, phase, window, cx| { - if phase != DispatchPhase::Bubble { - return; - } - if !bounds.contains(&event.position) { - return; - } - if event.button != MouseButton::Left { - return; - } - - window.focus(&focus_handle, cx); - - let text_position = event.position + to_local_position; - state.update(cx, |state, cx| { - state.on_mouse_down(event, text_position, window, cx); - }); - } - }); - window.on_mouse_event({ - let state = request_layout.clone(); - move |event: &MouseUpEvent, phase, window, cx| { - if phase != DispatchPhase::Bubble { - return; - } - if event.button != MouseButton::Left { - return; - } - - state.update(cx, |state, cx| { - state.on_mouse_up(event, window, cx); - }); - } - }); - window.on_mouse_event({ - let state = request_layout.clone(); - move |event: &MouseMoveEvent, phase, window, cx| { - if phase != DispatchPhase::Bubble { - return; - } - - let text_position = event.position + to_local_position; - state.update(cx, |state, cx| { - state.on_mouse_move(event, text_position, window, cx); - }); - } - }); - + // Actually draw the elements we constructed during prepaint let line_h = window.line_height(); for PrepaintLine { line, point, align } in prepaint.elements.lines.drain(..) { let _ = line.paint(point, line_h, align, Some(bounds), window, cx); @@ -587,11 +415,12 @@ impl Element for EditableTextElement { window.paint_quad(quad); } }; - self.interactivity().paint( + + self.interactivity.paint( global_id, inspector_id, bounds, - prepaint.interactivity.hitbox.as_ref(), + hitbox.as_ref(), window, cx, perform_paint, @@ -599,6 +428,208 @@ impl Element for EditableTextElement { } } +impl EditableTextElement { + fn find_or_create_state(&self, window: &mut Window, cx: &mut App) -> Entity { + if let Some(entity) = self.state_entity.borrow().upgrade() { + return entity; + } + let Some(element_id) = self.interactivity.element_id.clone() else { + unimplemented!("all input elements must be assigned an id") + }; + + let state = EditableTextState::use_keyed(element_id.clone(), window, cx); + // store a reference to the entity owned by the element for access in action handlers + *self.state_entity_rc().borrow_mut() = state.downgrade(); + state + } + + fn process_frame_events( + prepaint: &PrepaintState, + bounds: Bounds, + entity: &Entity, + window: &mut Window, + cx: &mut App, + ) { + let inner_bounds = prepaint.interactivity.inner_bounds; + let to_local_position = -(bounds.origin + prepaint.interactivity.scroll_offset); + + let ime_handler = ElementInputHandler::new(inner_bounds, entity.clone()); + window.handle_input(&prepaint.focus_handle, ime_handler, cx); + + window.on_mouse_event({ + let focus_handle = prepaint.focus_handle.clone(); + let state = entity.clone(); + move |event: &MouseDownEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + if !bounds.contains(&event.position) { + return; + } + if event.button != MouseButton::Left { + return; + } + + window.focus(&focus_handle, cx); + + let text_position = event.position + to_local_position; + state.update(cx, |state, cx| { + state.on_mouse_down(event, text_position, window, cx); + }); + } + }); + window.on_mouse_event({ + let state = entity.clone(); + move |event: &MouseUpEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + if event.button != MouseButton::Left { + return; + } + + state.update(cx, |state, cx| { + state.on_mouse_up(event, window, cx); + }); + } + }); + window.on_mouse_event({ + let state = entity.clone(); + move |event: &MouseMoveEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + + let text_position = event.position + to_local_position; + state.update(cx, |state, cx| { + state.on_mouse_move(event, text_position, window, cx); + }); + } + }); + } +} + +impl PrelayoutState { + fn perform_text_layout(self, window: &mut Window) -> LayoutId { + // NOTE: Loosely mirrors TextLayout::layout + 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()), + ); + + let color = match self.show_placeholder { + false => text_style.color, + true => self.placeholder_color, + }; + + let text = self.text.unwrap_or_default(); + + window.request_measured_layout( + Default::default(), + // This is invoked sometime in the near future (before prepaint but not immediately), + // so we avoid doing any pre-emptive work until the layout engine is ready. + move |known_dimensions, available_space, window, cx| { + 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) = self.prev_layout_state.size + && (wrap_width.is_none() || wrap_width == self.prev_layout_state.wrap_width) + && truncation.width.is_none() + && self.storage_version == self.prev_layout_state.last_seen_storage_version + { + return size; + } + + let (text, runs) = TextLayout::apply_truncation( + text.clone(), + &text_style, + font_size, + wrap_width, + &truncation, + &runs, + cx, + ); + let text_len = text.len(); + + 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 = 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 mut line_len = line.len(); + if line_len < text_len { + // to offset for new-line characters that are + // omitted from WrappedLine range + line_len += 1; + } + + let segment = TextLineSegment { + text_range: line_start..line_start + line_len, + wrapped_line: Some(Arc::new(line)), + pos_y, + }; + line_start += line_len; + pos_y += segment.row_count(); + lines.push(segment); + } + + let layout_data = EditableTextLayoutResult { + supports_multiline: self.supports_multiline, + accepts_input: self.accepts_input, + // updated during prepaint + scroll_bounds: Bounds::default(), + state: EditableTextLayoutState { + wrap_width, + size: Some(size), + last_seen_storage_version: self.storage_version, + }, + lines, + line_height, + next_scroll_offset: None, + }; + + // 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. + self.state.update(cx, move |state, _cx| { + state.layout_data = layout_data; + }); + + size + }, + ) + } +} + struct PrepaintLine { line: Arc, point: Point, diff --git a/crates/gpui_elements/src/editable_text/layout.rs b/crates/gpui_elements/src/editable_text/layout.rs index 5c10046a26..d49a01dfe6 100644 --- a/crates/gpui_elements/src/editable_text/layout.rs +++ b/crates/gpui_elements/src/editable_text/layout.rs @@ -1,21 +1,27 @@ use gpui::{Bounds, Pixels, Point, Size, WrappedLine}; use std::{ops::Range, sync::Arc}; -/// Generated / internal state tracked during and across renders of the element. -#[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, +/// Data used across successive layout requests to gauge whether layout must be recomputed. +#[derive(Default, Clone, Copy)] +pub(super) struct EditableTextLayoutState { /// The last known width at which the lines were wrapped. pub wrap_width: Option, /// The last known size of the text, as generated during layout. pub size: Option>, /// The last seen version of `storage` (for tracking when lines need to be reprocessed during layout) pub last_seen_storage_version: u16, +} + +/// Internal state/result after the element has recomputed layout. +#[derive(Default)] +pub(super) struct EditableTextLayoutResult { + /// 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, + pub state: EditableTextLayoutState, /// 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, diff --git a/crates/gpui_elements/src/editable_text/state.rs b/crates/gpui_elements/src/editable_text/state.rs index c898b9ac7c..e69abbb62c 100644 --- a/crates/gpui_elements/src/editable_text/state.rs +++ b/crates/gpui_elements/src/editable_text/state.rs @@ -3,7 +3,7 @@ use crate::editable_text::{ actions::EditableTextActionHandler, caret::{Caret, CaretNotify}, history::EditableTextHistory, - layout::TextInputLayoutData, + layout::EditableTextLayoutResult, }; use gpui::{ App, Bounds, ClipboardItem, Context, ElementId, Entity, EntityInputHandler, EventEmitter, @@ -90,7 +90,7 @@ pub struct EditableTextState { focus_handle: FocusHandle, history: Option, - pub(super) layout_data: TextInputLayoutData, + pub(super) layout_data: EditableTextLayoutResult, } impl EventEmitter for EditableTextState {} @@ -187,7 +187,7 @@ impl EditableTextState { // TODO: what is the best way to give users access to configure this via element history: Some(EditableTextHistory::default()), - layout_data: TextInputLayoutData::default(), + layout_data: EditableTextLayoutResult::default(), } } @@ -428,7 +428,7 @@ impl EditableTextState { if self.layout_data.scroll_bounds.is_empty() { return; } - let Some(content_size) = self.layout_data.size else { + let Some(content_size) = self.layout_data.state.size else { return; };