From b1e67f32af9aa180dd501771ed1af4245ccdb64c Mon Sep 17 00:00:00 2001 From: temportalflux Date: Mon, 22 Jun 2026 14:22:02 -0400 Subject: [PATCH 001/117] change shape_text api to support non-SharedString types as input without forcing extra allocations --- crates/gpui/src/elements/div.rs | 2 +- crates/gpui/src/text_system.rs | 21 +++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 5538d3d92a..142dc2395a 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -2278,7 +2278,7 @@ impl Interactivity { if let Some(text) = window .text_system() .shape_text( - element_id.into(), + &element_id, FONT_SIZE, &[window.text_style().to_run(str_len)], None, diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index 043d37f679..82adf0c7bb 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -504,11 +504,20 @@ impl WindowTextSystem { } /// Shape a multi line string of text, at the given font_size, for painting to the screen. - /// Subsets of the text can be styled independently with the `runs` parameter. + /// Subsets of the text can be styled independently with the `runs` parameter, + /// where each run dictates the length of utf8 characters in `text` that it styles. + /// The length (utf8 characters) of last item in `runs` is semantically ignored as it + /// represents the "rest" of the `text`. + /// /// If `wrap_width` is provided, the line breaks will be adjusted to fit within the given width. - pub fn shape_text( + /// + /// If the text provided is SharedString and does not contain new-lines, + /// it will be used as-is without additional allocations. + /// If the text provided is not a SharedString or contains new-lines, new SharedStrings + /// will be allocated for each substring between new-line characters (minimum of 1). + pub fn shape_text + Into>( &self, - text: SharedString, + text: S, font_size: Pixels, runs: &[TextRun], wrap_width: Option, @@ -598,7 +607,7 @@ impl WindowTextSystem { } }; - let mut split_lines = text.split('\n'); + let mut split_lines = text.as_ref().split('\n'); // Special case single lines to prevent allocating a sharedstring if let Some(first_line) = split_lines.next() @@ -625,8 +634,8 @@ impl WindowTextSystem { ); } } else { - let end = text.len(); - process_line(text, 0, end); + let end = text.as_ref().len(); + process_line(text.into(), 0, end); } self.font_runs_pool.lock().push(font_runs); From 1f2c11d44f88c8d8be63d34e583b2105fa27c2b6 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Mon, 22 Jun 2026 14:21:44 -0400 Subject: [PATCH 002/117] move TextLayout::layout logic into standalone functions so they can be shared between labels and editable text elements --- crates/gpui/src/elements/text.rs | 182 ++++++++++++++++++++++--------- 1 file changed, 131 insertions(+), 51 deletions(-) diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 82d23c83b7..20b933816c 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -622,7 +622,121 @@ struct TextLayoutInner { bounds: Option>, } +/// Metadata about how text should be truncated. Generated during text layout via `TextLayout::evaluate_overflow`. +pub struct TextLayoutTruncation { + /// The width that the text can occupy before it is truncated. + pub width: Option, + /// The text to affix to the displayed text if truncating (e.g. an ellipsis `...`). + pub affix: SharedString, + /// What side of the text will be truncated if it does not fit. + pub source: TruncateFrom, +} + +impl TextLayoutTruncation { + /// Creates a truncation by using the overflow as the affix, given the provided width. + fn overflow_width(text_overflow: TextOverflow, width: Option) -> Self { + match text_overflow { + TextOverflow::Truncate(s) => TextLayoutTruncation { + width, + affix: s, + source: TruncateFrom::End, + }, + TextOverflow::TruncateStart(s) => TextLayoutTruncation { + width, + affix: s, + source: TruncateFrom::Start, + }, + } + } +} + impl TextLayout { + /// Evaluates the width to wrap the text at. + pub fn evaluate_wrap_width( + white_space: &WhiteSpace, + known_dimensions: Size>, + available_space: Size, + ) -> Option { + use crate::AvailableSpace::*; + match white_space { + // Text does not wrap, no max width + WhiteSpace::Nowrap => None, + // If the text wraps, return the already calculated width. + WhiteSpace::Normal => known_dimensions.width.or(match available_space.width { + // Otherwise if the available space is a concrete value, then that is the width to wrap to. + Definite(x) => Some(x), + // If the wrapping is content-based, then there is no wrapping of text. + MaxContent | MinContent => None, + }), + } + } + + /// Evaluates how truncation should be applied if the text overflows the available space. + pub fn evaluate_overflow( + text_style: &TextStyle, + known_dimensions: Size>, + available_space: Size, + ) -> TextLayoutTruncation { + match text_style.text_overflow.clone() { + Some(text_overflow) => { + // Calculate the desired width, prioritizing the calculated dimensions, + // falling back on calculating a width from the available space and + // number of lines to clamp to via text style. + let width = known_dimensions.width.or(match available_space.width { + crate::AvailableSpace::Definite(x) => match text_style.line_clamp { + Some(max_lines) => Some(x * max_lines), + None => Some(x), + }, + _ => None, + }); + + TextLayoutTruncation::overflow_width(text_overflow, width) + } + None => TextLayoutTruncation { + width: None, + affix: SharedString::default(), + source: TruncateFrom::End, + }, + } + } + + /// Conditionally applies truncation to some text and outputs how the text should be displayed. + pub fn apply_truncation<'runs>( + text: SharedString, + text_style: &TextStyle, + font_size: Pixels, + wrap_width: Option, + truncation: &TextLayoutTruncation, + runs: &'runs [TextRun], + cx: &mut App, + ) -> (SharedString, Cow<'runs, [TextRun]>) { + let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size); + if truncation.width.is_some() { + if let Some(max_lines) = text_style.line_clamp + && let Some(wrap_width) = wrap_width + { + line_wrapper.truncate_wrapped_line( + text, + wrap_width, + max_lines, + &truncation.affix, + &runs, + truncation.source, + ) + } else { + line_wrapper.truncate_line( + text, + truncation.width.unwrap_or(Pixels::MAX), + &truncation.affix, + &runs, + truncation.source, + ) + } + } else { + (text, std::borrow::Cow::Borrowed(runs)) + } + } + fn layout( &self, text: SharedString, @@ -647,32 +761,14 @@ impl TextLayout { let element_state = self.clone(); move |known_dimensions, available_space, window, cx| { - let wrap_width = if text_style.white_space == WhiteSpace::Normal { - known_dimensions.width.or(match available_space.width { - crate::AvailableSpace::Definite(x) => Some(x), - _ => None, - }) - } else { - None - }; + let wrap_width = Self::evaluate_wrap_width( + &text_style.white_space, + known_dimensions, + available_space, + ); - let (truncate_width, truncation_affix, truncate_from) = - if let Some(text_overflow) = text_style.text_overflow.clone() { - let width = known_dimensions.width.or(match available_space.width { - crate::AvailableSpace::Definite(x) => match text_style.line_clamp { - Some(max_lines) => Some(x * max_lines), - None => Some(x), - }, - _ => None, - }); - - match text_overflow { - TextOverflow::Truncate(s) => (width, s, TruncateFrom::End), - TextOverflow::TruncateStart(s) => (width, s, TruncateFrom::Start), - } - } else { - (None, "".into(), TruncateFrom::End) - }; + let truncation = + Self::evaluate_overflow(&text_style, known_dimensions, available_space); // Only use cached layout if: // 1. We have a cached size @@ -682,36 +778,20 @@ impl TextLayout { if let Some(text_layout) = element_state.0.borrow().as_ref() && let Some(size) = text_layout.size && (wrap_width.is_none() || wrap_width == text_layout.wrap_width) - && truncate_width.is_none() + && truncation.width.is_none() { return size; } - let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size); - let (text, runs) = if truncate_width.is_some() { - if let Some(max_lines) = text_style.line_clamp - && let Some(wrap_width) = wrap_width - { - line_wrapper.truncate_wrapped_line( - text.clone(), - wrap_width, - max_lines, - &truncation_affix, - &runs, - truncate_from, - ) - } else { - line_wrapper.truncate_line( - text.clone(), - truncate_width.unwrap_or(Pixels::MAX), - &truncation_affix, - &runs, - truncate_from, - ) - } - } else { - (text.clone(), Cow::Borrowed(&*runs)) - }; + let (text, runs) = Self::apply_truncation( + text.clone(), + &text_style, + font_size, + wrap_width, + &truncation, + &runs, + cx, + ); let len = text.len(); let Some(lines) = window From f4cb5e21b785b61437a6ac1282d4ea29e48e9651 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Tue, 23 Jun 2026 15:30:27 -0400 Subject: [PATCH 003/117] add getter/setter to interface with the current scroll offset of interactivity --- crates/gpui/src/elements/div.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 142dc2395a..a0970015b5 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -1866,6 +1866,35 @@ pub struct Interactivity { pub(crate) debug_selector: Option, } +impl Interactivity { + /// Assigns the current scroll offset of an element. No-op if the element's + /// style does not have overflow enabled. + /// + /// Should only be called during the `request_layout` phase. + pub fn set_scroll_offset( + &self, + global_id: Option<&GlobalElementId>, + window: &mut Window, + point: Point, + ) { + window.with_optional_element_state::( + global_id, + |element_state, _window| { + let mut element_state = + element_state.map(|element_state| element_state.unwrap_or_default()); + let overflow = &self.base_style.overflow; + if (overflow.x == Some(Overflow::Scroll) || overflow.y == Some(Overflow::Scroll)) + && let Some(element_state) = element_state.as_mut() + { + let scroll_offset = element_state.scroll_offset.get_or_insert_with(Rc::default); + *scroll_offset.borrow_mut() = point; + } + ((), element_state) + }, + ); + } +} + impl Interactivity { /// Layout this element according to this interactivity state's configured styles pub fn request_layout( From cfc9f6fab1cfc61650ababf8dd3402a6304e1a12 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Fri, 3 Jul 2026 13:41:24 -0400 Subject: [PATCH 004/117] update documentation of 'use_keyed_transition', 'use_keyed_state', & 'with_element_state' to make it clear that the functions can be called during 'render' implementations, as 'examples/learn/transition.rs' illustrates --- crates/gpui/src/window.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index b5a2faab92..5276fb15dc 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -3357,6 +3357,8 @@ impl Window { } /// Use a piece of state that exists as long this element is being rendered in consecutive frames. + /// + /// This method should only be called during `Render::render`, `RenderOnce::render`, or the drawing functions of `Element`. pub fn use_keyed_state( &mut self, key: impl Into, @@ -3401,7 +3403,9 @@ impl Window { /// Updates or initializes state for an element with the given id that lives across multiple /// frames. If an element with this ID existed in the rendered frame, its state will be passed /// to the given closure. The state returned by the closure will be stored so it can be referenced - /// when drawing the next frame. This method should only be called as part of element drawing. + /// when drawing the next frame. + /// + /// This method should only be called during `Render::render`, `RenderOnce::render`, or the drawing functions of `Element`. pub fn with_element_state( &mut self, global_id: &GlobalElementId, @@ -3531,6 +3535,8 @@ impl Window { /// persist across renders as long as the key remains the same. This is the /// recommended method for most use cases where you want smooth, continuous /// animations. + /// + /// This method should only be called during `Render::render`, `RenderOnce::render`, or the drawing functions of `Element`. pub fn use_keyed_transition( &mut self, key: impl Into, From 6ecdda92d0639db1ae3c5b934a5e132cf1999d29 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Fri, 3 Jul 2026 17:33:39 -0400 Subject: [PATCH 005/117] add Point::is_nearly_eq --- crates/gpui/src/geometry.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index e5951a1296..6b8c19a69d 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -218,6 +218,21 @@ impl Point { pub fn magnitude(&self) -> f64 { ((self.x.0.powi(2) + self.y.0.powi(2)) as f64).sqrt() } + + /// Returns true if the difference between self and `other` is less than `epsilon` in both coordinates. + /// + /// # Examples + /// + /// ``` + /// # use gpui::{Pixels, Point}; + /// let p1 = Point { x: Pixels::from(1.0), y: Pixels::from(1.0) }; + /// let p2 = Point { x: Pixels::from(3.0), y: Pixels::from(-2.0) }; + /// assert_eq!(p1.is_nearly_eq(&p2, Pixels::from(4.0)), true); + /// ``` + pub fn is_nearly_eq(&self, other: &Self, epsilon: Pixels) -> bool { + let diff = *self - *other; + diff.x.abs() < epsilon && diff.y.abs() < epsilon + } } impl Point From eb8f90a3106f3d3122ba64531f4ebe063155479c Mon Sep 17 00:00:00 2001 From: temportalflux Date: Fri, 5 Jun 2026 15:46:53 -0400 Subject: [PATCH 006/117] extract gpuikit input implementation --- Cargo.lock | 2 + Cargo.toml | 1 + crates/gpui_elements/Cargo.toml | 2 + crates/gpui_elements/src/input.rs | 5 + crates/gpui_elements/src/input/cursor.rs | 119 + crates/gpui_elements/src/input/element.rs | 3013 +++++++++++++++++++++ crates/gpui_elements/src/lib.rs | 2 +- 7 files changed, 3143 insertions(+), 1 deletion(-) create mode 100644 crates/gpui_elements/src/input.rs create mode 100644 crates/gpui_elements/src/input/cursor.rs create mode 100644 crates/gpui_elements/src/input/element.rs diff --git a/Cargo.lock b/Cargo.lock index 5548ebcfaa..d78049525a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2425,7 +2425,9 @@ dependencies = [ name = "gpui_elements" version = "0.1.0" dependencies = [ + "async-io", "gpui", + "unicode-segmentation", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d753f97430..2cef8a084f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ accesskit_macos = "0.26.0" accesskit_unix = "0.22.0" accesskit_windows = "0.32.1" anyhow = "1.0.86" +async-io = "2.6" backtrace = "0.3" bitflags = "2.6.0" collections = { git = "https://github.com/zed-industries/zed", rev = "876ec5a8a074ba83cce2129ed4d76b59c05a37e9", version = "0.1.0" } diff --git a/crates/gpui_elements/Cargo.toml b/crates/gpui_elements/Cargo.toml index 2a53df02ef..b0c8b016a1 100644 --- a/crates/gpui_elements/Cargo.toml +++ b/crates/gpui_elements/Cargo.toml @@ -13,6 +13,8 @@ ignored = ["gpui"] [dependencies] gpui.workspace = true +async-io.workspace = true +unicode-segmentation.workspace = true [dev-dependencies] gpui = { path = "../gpui", features = ["test-support"] } diff --git a/crates/gpui_elements/src/input.rs b/crates/gpui_elements/src/input.rs new file mode 100644 index 0000000000..433ef1625e --- /dev/null +++ b/crates/gpui_elements/src/input.rs @@ -0,0 +1,5 @@ +mod element; +pub use element::*; + +mod cursor; +pub use cursor::*; diff --git a/crates/gpui_elements/src/input/cursor.rs b/crates/gpui_elements/src/input/cursor.rs new file mode 100644 index 0000000000..c3ab36e3f8 --- /dev/null +++ b/crates/gpui_elements/src/input/cursor.rs @@ -0,0 +1,119 @@ +use gpui::Context; +use std::time::Duration; + +/// Manages the blinking state of a text cursor. +/// +/// The cursor blinks at a configurable interval when enabled. Blinking can be +/// temporarily paused (e.g., during typing) to provide immediate visual feedback. +pub struct CursorBlink { + interval: Duration, + generation: usize, + visible: bool, + active: bool, + paused: bool, +} + +impl CursorBlink { + /// Creates a new cursor blink manager with the given interval. + /// + /// The cursor starts in a disabled state with visibility set to true. + pub fn new(interval: Duration, _cx: &mut Context) -> Self { + Self { + interval, + generation: 0, + visible: true, + active: false, + paused: false, + } + } + + /// Returns whether the cursor should currently be rendered. + pub fn visible(&self) -> bool { + self.visible + } + + /// Returns whether blinking is currently active. + pub fn is_active(&self) -> bool { + self.active + } + + /// Activates cursor blinking. + /// + /// When activated, the cursor will alternate between visible and hidden + /// states at the configured interval. Has no effect if already active. + pub fn enable(&mut self, cx: &mut Context) { + if self.active { + return; + } + + self.active = true; + self.visible = false; + self.paused = false; + self.tick(cx); + } + + /// Deactivates cursor blinking. + /// + /// The cursor visibility is set to false when disabled. Call + /// `pause_blinking` instead if you want to temporarily stop blinking + /// while keeping the cursor visible. + pub fn disable(&mut self, cx: &mut Context) { + self.active = false; + self.visible = false; + self.paused = false; + cx.notify(); + } + + /// Temporarily pauses blinking and shows the cursor. + /// + /// This is useful during user input to provide immediate feedback. + /// Blinking resumes automatically after the blink interval elapses. + pub fn pause_blinking(&mut self, cx: &mut Context) { + if !self.visible { + self.visible = true; + cx.notify(); + } + + self.paused = true; + self.generation = self.generation.wrapping_add(1); + + let generation = self.generation; + let interval = self.interval; + + cx.spawn(async move |this, cx| { + async_io::Timer::after(interval).await; + this.update(cx, |this, cx| { + if this.generation == generation { + this.paused = false; + this.tick(cx); + } + }) + }) + .detach(); + } + + fn tick(&mut self, cx: &mut Context) { + if !self.active || self.paused { + return; + } + + self.visible = !self.visible; + cx.notify(); + + self.generation = self.generation.wrapping_add(1); + let generation = self.generation; + let interval = self.interval; + + cx.spawn(async move |this, cx| { + async_io::Timer::after(interval).await; + if let Some(this) = this.upgrade() { + this.update(cx, |this, cx| { + if this.generation == generation { + this.tick(cx); + } + }); + } + }) + .detach(); + } +} diff --git a/crates/gpui_elements/src/input/element.rs b/crates/gpui_elements/src/input/element.rs new file mode 100644 index 0000000000..722eb2e0cb --- /dev/null +++ b/crates/gpui_elements/src/input/element.rs @@ -0,0 +1,3013 @@ +use gpui::{ + Action, App, AppContext, Bounds, ClipboardItem, ContentMask, Context, CursorStyle, + DispatchPhase, Element, ElementId, ElementInputHandler, Entity, EntityId, EntityInputHandler, + EventEmitter, FocusHandle, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla, + InspectorElementId, InteractiveElement, Interactivity, IntoElement, KeyBinding, LayoutId, + Length, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, + ScrollWheelEvent, SharedString, StyleRefinement, Styled, Subscription, TextAlign, TextRun, + TextStyle, UTF16Selection, Window, WrappedLine, actions, fill, point, px, relative, size, +}; +use std::{ + ops::Range, + sync::Arc, + time::{Duration, Instant}, +}; +use unicode_segmentation::UnicodeSegmentation; + +const CURSOR_WIDTH: f32 = 2.0; +const MARKED_TEXT_UNDERLINE_THICKNESS: f32 = 2.0; + +/// Default interval for cursor blinking. +const DEFAULT_BLINK_INTERVAL: Duration = Duration::from_millis(500); + +/// The key context used for input element keybindings. +pub const DEFAULT_INPUT_CONTEXT: &str = "Input"; + +actions!( + actions, + [ + /// Delete the character before the cursor. + Backspace, + /// Delete the character after the cursor. + Delete, + /// Blur focus from the input. + Escape, + /// Delete the word before the cursor. + DeleteWordLeft, + /// Delete the word after the cursor. + DeleteWordRight, + /// Delete from the cursor to the beginning of the line. + DeleteToBeginningOfLine, + /// Delete from the cursor to the end of the line. + DeleteToEndOfLine, + /// Insert a tab character at the cursor position. + Tab, + /// Move the cursor one character to the left. + Left, + /// Move the cursor one character to the right. + Right, + /// Move the cursor up one visual line. + Up, + /// Move the cursor down one visual line. + Down, + /// Extend selection one character to the left. + SelectLeft, + /// Extend selection one character to the right. + SelectRight, + /// Extend selection up one visual line. + SelectUp, + /// Extend selection down one visual line. + SelectDown, + /// Select all text content. + SelectAll, + /// Move cursor to the start of the current line. + Home, + /// Move cursor to the end of the current line. + End, + /// Extend selection to the beginning of the content. + SelectToBeginning, + /// Extend selection to the end of the content. + SelectToEnd, + /// Move cursor to the beginning of the content. + MoveToBeginning, + /// Move cursor to the end of the content. + MoveToEnd, + /// Paste from clipboard at the cursor position. + Paste, + /// Cut selected text to clipboard. + Cut, + /// Copy selected text to clipboard. + Copy, + /// Insert a newline at the cursor position. + Enter, + /// Move cursor one word to the left. + WordLeft, + /// Move cursor one word to the right. + WordRight, + /// Extend selection one word to the left. + SelectWordLeft, + /// Extend selection one word to the right. + SelectWordRight, + /// Undo the last edit. + Undo, + /// Redo the last undone edit. + Redo, + ] +); + +#[track_caller] +pub fn input(input_state: &Entity, cx: &App) -> Input { + Input::new(input_state, cx) +} + +pub fn input_bindings() -> gpui::ActionBindingCollection { + let mut bindings = gpui::ActionBindingCollection::default(); + + #[cfg(target_os = "macos")] + { + bindings = bindings + .with::("backspace") + .with::("delete") + .with::("alt-backspace") + .with::("alt-delete") + .with::("cmd-backspace") + .with::("ctrl-k") + .with::("tab") + .with::("enter") + .with::("left") + .with::("right") + .with::("up") + .with::("down") + .with::("shift-left") + .with::("shift-right") + .with::("shift-up") + .with::("shift-down") + .with::("cmd-a") + // Mac keyboards don't have Home/End keys, so cmd-left/right are standard + .with::("cmd-left") + .with::("cmd-right") + .with::("cmd-up") + .with::("cmd-down") + .with::("cmd-shift-up") + .with::("cmd-shift-down") + .with::("alt-left") + .with::("alt-right") + .with::("alt-shift-left") + .with::("alt-shift-right") + .with::("cmd-c") + .with::("cmd-x") + .with::("cmd-v") + .with::("cmd-z") + .with::("cmd-shift-z") + .with::("escape"); + } + + #[cfg(not(target_os = "macos"))] + { + bindings = bindings + .with::("backspace") + .with::("delete") + .with::("ctrl-backspace") + .with::("ctrl-delete") + .with::("ctrl-shift-backspace") + .with::("ctrl-shift-delete") + .with::("tab") + .with::("enter") + .with::("left") + .with::("right") + .with::("up") + .with::("down") + .with::("shift-left") + .with::("shift-right") + .with::("shift-up") + .with::("shift-down") + .with::("ctrl-a") + .with::("home") + .with::("end") + .with::("ctrl-home") + .with::("ctrl-end") + .with::("ctrl-shift-home") + .with::("ctrl-shift-end") + .with::("ctrl-left") + .with::("ctrl-right") + .with::("ctrl-shift-left") + .with::("ctrl-shift-right") + .with::("ctrl-c") + .with::("ctrl-x") + .with::("ctrl-v") + .with::("ctrl-z") + .with::("ctrl-shift-z") + .with::("escape"); + } + + bindings +} + +#[derive(Clone, Copy, Debug)] +struct PaintColors { + selection: Hsla, + cursor: Hsla, + placeholder: Hsla, +} + +impl Default for PaintColors { + fn default() -> Self { + Self { + selection: Hsla::blue().opacity(0.2), + cursor: Hsla::white().opacity(0.8), + placeholder: gpui::hsla(0.6, 0.6, 0.6, 1.0), + } + } +} + +/// A text editing element that supports both single-line and multi-line modes. +pub struct Input { + input: Entity, + interactivity: Interactivity, + placeholder: Option, + colors: PaintColors, + multiline: bool, +} + +impl Input { + #[track_caller] + fn new(input_state: &Entity, cx: &App) -> Self { + let focus_handle = input_state.focus_handle(cx); + let mut input = Input { + input: input_state.clone(), + interactivity: Interactivity::new(), + placeholder: None, + colors: PaintColors::default(), + multiline: false, + }; + input.register_actions(); + input + .key_context(DEFAULT_INPUT_CONTEXT) + .track_focus(&focus_handle) + } + + fn register_actions(&mut self) { + register_action(&mut self.interactivity, &self.input, InputState::left); + register_action(&mut self.interactivity, &self.input, InputState::right); + register_action(&mut self.interactivity, &self.input, InputState::up); + register_action(&mut self.interactivity, &self.input, InputState::down); + register_action( + &mut self.interactivity, + &self.input, + InputState::select_left, + ); + register_action( + &mut self.interactivity, + &self.input, + InputState::select_right, + ); + register_action(&mut self.interactivity, &self.input, InputState::select_up); + register_action( + &mut self.interactivity, + &self.input, + InputState::select_down, + ); + register_action(&mut self.interactivity, &self.input, InputState::select_all); + register_action(&mut self.interactivity, &self.input, InputState::home); + register_action(&mut self.interactivity, &self.input, InputState::end); + register_action( + &mut self.interactivity, + &self.input, + InputState::move_to_beginning, + ); + register_action( + &mut self.interactivity, + &self.input, + InputState::move_to_end, + ); + register_action( + &mut self.interactivity, + &self.input, + InputState::select_to_beginning, + ); + register_action( + &mut self.interactivity, + &self.input, + InputState::select_to_end, + ); + register_action(&mut self.interactivity, &self.input, InputState::word_left); + register_action(&mut self.interactivity, &self.input, InputState::word_right); + register_action( + &mut self.interactivity, + &self.input, + InputState::select_word_left, + ); + register_action( + &mut self.interactivity, + &self.input, + InputState::select_word_right, + ); + register_action(&mut self.interactivity, &self.input, InputState::backspace); + register_action(&mut self.interactivity, &self.input, InputState::delete); + register_action( + &mut self.interactivity, + &self.input, + InputState::delete_word_left, + ); + register_action( + &mut self.interactivity, + &self.input, + InputState::delete_word_right, + ); + register_action( + &mut self.interactivity, + &self.input, + InputState::delete_to_beginning_of_line, + ); + register_action( + &mut self.interactivity, + &self.input, + InputState::delete_to_end_of_line, + ); + register_action(&mut self.interactivity, &self.input, InputState::enter); + register_action(&mut self.interactivity, &self.input, InputState::tab); + register_action(&mut self.interactivity, &self.input, InputState::paste); + register_action(&mut self.interactivity, &self.input, InputState::copy); + register_action(&mut self.interactivity, &self.input, InputState::cut); + register_action(&mut self.interactivity, &self.input, InputState::undo); + register_action(&mut self.interactivity, &self.input, InputState::redo); + + self.interactivity + .on_action::(|_action, window, _cx| { + window.blur(); + }); + } + + pub fn placeholder(mut self, placeholder: impl Into) -> Self { + self.placeholder = Some(placeholder.into()); + self + } +} + +fn register_action( + interactivity: &mut Interactivity, + input: &Entity, + listener: fn(&mut InputState, &A, &mut Window, &mut Context), +) { + let input = input.clone(); + interactivity.on_action::(move |action, window, cx| { + input.update(cx, |input, cx| { + listener(input, action, window, cx); + }); + }); +} + +impl Styled for Input { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.interactivity.base_style + } +} + +impl InteractiveElement for Input { + fn interactivity(&mut self) -> &mut Interactivity { + &mut self.interactivity + } +} + +pub struct InputLayoutState { + text_style: TextStyle, +} + +pub struct InputPrepaintState { + hitbox: Option, +} + +impl Element for Input { + type RequestLayoutState = InputLayoutState; + type PrepaintState = InputPrepaintState; + + fn id(&self) -> Option { + self.interactivity.element_id.clone() + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + self.interactivity.source_location() + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut resolved_text_style = None; + let multiline = self.multiline; + + let layout_id = self.interactivity.request_layout( + global_id, + inspector_id, + window, + cx, + |element_style, window, cx| { + window.with_text_style(element_style.text_style().cloned(), |window| { + resolved_text_style = Some(window.text_style()); + + let mut layout_style = element_style.clone(); + if multiline { + if let Length::Auto = layout_style.size.width { + layout_style.size.width = relative(1.).into(); + } + if let Length::Auto = layout_style.size.height { + layout_style.size.height = relative(1.).into(); + } + } + window.request_layout(layout_style, None, cx) + }) + }, + ); + + ( + layout_id, + InputLayoutState { + text_style: resolved_text_style.unwrap_or_else(|| window.text_style()), + }, + ) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + layout_state: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let line_height = layout_state + .text_style + .line_height_in_pixels(window.rem_size()); + + let wrap_width = if self.multiline { + bounds.size.width + } else { + px(100000.) + }; + + self.input.update(cx, |input, _cx| { + input.available_height = bounds.size.height; + input.available_width = bounds.size.width; + input.update_line_layouts(wrap_width, line_height, &layout_state.text_style, window); + }); + + let hitbox = self.interactivity.prepaint( + global_id, + inspector_id, + bounds, + bounds.size, + window, + cx, + |_style, _point, hitbox, window, _cx| { + hitbox.or_else(|| Some(window.insert_hitbox(bounds, HitboxBehavior::Normal))) + }, + ); + + InputPrepaintState { hitbox } + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + layout_state: &mut Self::RequestLayoutState, + prepaint_state: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let focus_handle = self.input.focus_handle(cx); + + if let Some(hitbox) = &prepaint_state.hitbox { + window.set_cursor_style(CursorStyle::IBeam, hitbox); + } + + window.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, self.input.clone()), + cx, + ); + + let input = self.input.clone(); + let placeholder = self.placeholder.clone(); + let text_style = layout_state.text_style.clone(); + let multiline = self.multiline; + let is_focused = focus_handle.is_focused(window); + let cursor_visible = self + .input + .update(cx, |input, cx| input.cursor_visible(is_focused, cx)); + + let colors = self.colors; + self.interactivity.paint( + global_id, + inspector_id, + bounds, + prepaint_state.hitbox.as_ref(), + window, + cx, + |_style, window, cx| { + handle_mouse(&input, bounds, multiline, window, cx); + + window.with_content_mask(Some(ContentMask { bounds }), |window| { + if multiline { + paint_multiline( + &input, + &focus_handle, + bounds, + &text_style, + placeholder.as_ref(), + &colors, + cursor_visible, + window, + cx, + ); + } else { + paint_singleline( + &input, + &focus_handle, + bounds, + &text_style, + placeholder.as_ref(), + &colors, + cursor_visible, + window, + cx, + ); + } + }); + }, + ); + } +} + +/// Registers all mouse event handlers for the input. +fn handle_mouse( + input: &Entity, + bounds: Bounds, + multiline: bool, + window: &mut Window, + cx: &App, +) { + mouse_down(input.clone(), bounds, multiline, window); + mouse_up(input.clone(), window); + mouse_move(input.clone(), bounds, multiline, window); + handle_scroll(input.clone(), bounds, multiline, window, cx); +} + +fn mouse_down( + input: Entity, + bounds: Bounds, + multiline: bool, + window: &mut Window, +) { + window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + if !bounds.contains(&event.position) { + return; + } + if event.button != MouseButton::Left { + return; + } + + input.update(cx, |input, cx| { + let text_position = + screen_to_text_position(event.position, bounds, input.scroll_offset, multiline); + input.on_mouse_down( + text_position, + event.click_count, + event.modifiers.shift, + window, + cx, + ); + }); + }); +} + +fn mouse_up(input: Entity, window: &mut Window) { + window.on_mouse_event(move |event: &MouseUpEvent, phase, _window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + if event.button != MouseButton::Left { + return; + } + + input.update(cx, |input, cx| { + input.on_mouse_up(cx); + }); + }); +} + +fn mouse_move( + input: Entity, + bounds: Bounds, + multiline: bool, + window: &mut Window, +) { + window.on_mouse_event(move |event: &MouseMoveEvent, phase, _window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + + input.update(cx, |input, cx| { + let text_position = + screen_to_text_position(event.position, bounds, input.scroll_offset, multiline); + input.on_mouse_move(text_position, cx); + }); + }); +} + +fn handle_scroll( + input: Entity, + bounds: Bounds, + multiline: bool, + window: &mut Window, + cx: &App, +) { + let max_scroll = if multiline { + let total_height = input.read(cx).total_content_height(); + (total_height - bounds.size.height).max(px(0.)) + } else { + let text_width = input + .read(cx) + .line_layouts + .first() + .and_then(|l| l.wrapped_line.as_ref()) + .map(|w| w.width()) + .unwrap_or(px(0.)); + (text_width - bounds.size.width).max(px(0.)) + }; + + window.on_mouse_event(move |event: &ScrollWheelEvent, phase, _window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + if !bounds.contains(&event.position) { + return; + } + + let pixel_delta = event.delta.pixel_delta(px(20.)); + input.update(cx, |input, cx| { + if multiline { + input.scroll_offset = + (input.scroll_offset - pixel_delta.y).clamp(px(0.), max_scroll); + } else { + let delta = if pixel_delta.x.abs() > pixel_delta.y.abs() { + pixel_delta.x + } else { + pixel_delta.y + }; + input.scroll_offset = (input.scroll_offset - delta).clamp(px(0.), max_scroll); + } + cx.notify(); + }); + }); +} + +/// Converts a screen position to a position relative to the text area origin, +/// adjusted for scroll offset. +fn screen_to_text_position( + screen_position: Point, + bounds: Bounds, + scroll_offset: Pixels, + multiline: bool, +) -> Point { + if multiline { + point( + screen_position.x - bounds.origin.x, + screen_position.y - bounds.origin.y + scroll_offset, + ) + } else { + point( + screen_position.x - bounds.origin.x + scroll_offset, + screen_position.y - bounds.origin.y, + ) + } +} + +fn paint_multiline( + input: &Entity, + focus_handle: &FocusHandle, + bounds: Bounds, + text_style: &TextStyle, + placeholder: Option<&SharedString>, + colors: &PaintColors, + cursor_visible: bool, + window: &mut Window, + cx: &mut App, +) { + let input_state = input.read(cx); + let content = input_state.content().to_string(); + let selected_range = input_state.selected_range().clone(); + let marked_range = input_state.marked_range().cloned(); + let cursor_offset = input_state.cursor_offset(); + let line_layouts = input_state.line_layouts.clone(); + let scroll_offset = input_state.scroll_offset; + let line_height = input_state.line_height; + let is_focused = focus_handle.is_focused(window); + + if !selected_range.is_empty() { + paint_multiline_selection( + &line_layouts, + &selected_range, + bounds, + scroll_offset, + line_height, + colors.selection, + window, + ); + } + + if content.is_empty() { + if let Some(placeholder_str) = placeholder { + if !placeholder_str.is_empty() { + paint_placeholder( + placeholder_str, + bounds, + text_style, + colors.placeholder, + window, + cx, + false, + ); + } + } + } else { + paint_multiline_text( + &line_layouts, + bounds, + scroll_offset, + line_height, + window, + cx, + ); + } + + if let Some(marked_range) = &marked_range { + if !marked_range.is_empty() { + paint_multiline_marked_underline( + &line_layouts, + marked_range, + bounds, + scroll_offset, + line_height, + colors.cursor, + window, + ); + } + } + + if is_focused && selected_range.is_empty() && cursor_visible { + paint_multiline_cursor( + &line_layouts, + cursor_offset, + &content, + bounds, + scroll_offset, + line_height, + colors.cursor, + window, + ); + } +} + +fn is_line_visible( + line_y: Pixels, + line_height: Pixels, + visual_line_count: usize, + visible_height: Pixels, +) -> bool { + let line_bottom = line_y + line_height * visual_line_count as f32; + line_bottom >= px(0.) && line_y <= visible_height +} + +fn line_intersects_range( + text_range: &std::ops::Range, + selected_range: &std::ops::Range, +) -> bool { + if text_range.is_empty() { + selected_range.start <= text_range.start && selected_range.end > text_range.start + } else { + selected_range.end > text_range.start && selected_range.start < text_range.end + } +} + +fn compute_visual_line_index(y: Pixels, line_height: Pixels) -> usize { + (y / line_height).floor() as usize +} + +fn paint_multiline_selection( + line_layouts: &[InputLineLayout], + selected_range: &std::ops::Range, + bounds: Bounds, + scroll_offset: Pixels, + line_height: Pixels, + selection_color: Hsla, + window: &mut Window, +) { + for line in line_layouts { + let line_y = line.y_offset - scroll_offset; + + if !is_line_visible( + line_y, + line_height, + line.visual_line_count, + bounds.size.height, + ) { + continue; + } + + if !line_intersects_range(&line.text_range, selected_range) { + continue; + } + + if line.text_range.is_empty() { + let empty_line_selection_width = px(6.); + window.paint_quad(fill( + Bounds::from_corners( + point(bounds.left(), bounds.top() + line_y), + point( + bounds.left() + empty_line_selection_width, + bounds.top() + line_y + line_height, + ), + ), + selection_color, + )); + } else if let Some(wrapped) = &line.wrapped_line { + let line_start = line.text_range.start; + let line_end = line.text_range.end; + + let sel_start = selected_range.start.max(line_start) - line_start; + let sel_end = selected_range.end.min(line_end) - line_start; + + let start_pos = wrapped + .position_for_index(sel_start, line_height) + .unwrap_or(point(px(0.), px(0.))); + let end_pos = wrapped + .position_for_index(sel_end, line_height) + .unwrap_or_else(|| { + let last_line_y = line_height * (line.visual_line_count - 1) as f32; + point(wrapped.width(), last_line_y) + }); + + let start_visual_line = compute_visual_line_index(start_pos.y, line_height); + let end_visual_line = compute_visual_line_index(end_pos.y, line_height); + + if start_visual_line == end_visual_line { + window.paint_quad(fill( + Bounds::from_corners( + point( + bounds.left() + start_pos.x, + bounds.top() + line_y + start_pos.y, + ), + point( + bounds.left() + end_pos.x, + bounds.top() + line_y + start_pos.y + line_height, + ), + ), + selection_color, + )); + } else { + let line_width = wrapped.width(); + + // First visual line + window.paint_quad(fill( + Bounds::from_corners( + point( + bounds.left() + start_pos.x, + bounds.top() + line_y + start_pos.y, + ), + point( + bounds.left() + line_width, + bounds.top() + line_y + start_pos.y + line_height, + ), + ), + selection_color, + )); + + // Middle visual lines + for visual_line in (start_visual_line + 1)..end_visual_line { + let y = line_height * visual_line as f32; + window.paint_quad(fill( + Bounds::from_corners( + point(bounds.left(), bounds.top() + line_y + y), + point( + bounds.left() + line_width, + bounds.top() + line_y + y + line_height, + ), + ), + selection_color, + )); + } + + // Last visual line + window.paint_quad(fill( + Bounds::from_corners( + point(bounds.left(), bounds.top() + line_y + end_pos.y), + point( + bounds.left() + end_pos.x, + bounds.top() + line_y + end_pos.y + line_height, + ), + ), + selection_color, + )); + } + } + } +} + +fn paint_multiline_text( + line_layouts: &[InputLineLayout], + bounds: Bounds, + scroll_offset: Pixels, + line_height: Pixels, + window: &mut Window, + cx: &mut App, +) { + for line_layout in line_layouts { + let line_y = line_layout.y_offset - scroll_offset; + + if !is_line_visible( + line_y, + line_height, + line_layout.visual_line_count, + bounds.size.height, + ) { + continue; + } + + if let Some(wrapped) = &line_layout.wrapped_line { + let paint_pos = point(bounds.left(), bounds.top() + line_y); + let _ = wrapped.paint( + paint_pos, + line_height, + TextAlign::Left, + Some(bounds), + window, + cx, + ); + } + } +} + +fn paint_multiline_marked_underline( + line_layouts: &[InputLineLayout], + marked_range: &std::ops::Range, + bounds: Bounds, + scroll_offset: Pixels, + line_height: Pixels, + underline_color: Hsla, + window: &mut Window, +) { + let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); + let underline_offset = line_height - underline_thickness; + + for line in line_layouts { + let line_y = line.y_offset - scroll_offset; + + if !is_line_visible( + line_y, + line_height, + line.visual_line_count, + bounds.size.height, + ) { + continue; + } + + if !line_intersects_range(&line.text_range, marked_range) { + continue; + } + + if line.text_range.is_empty() { + continue; + } + + if let Some(wrapped) = &line.wrapped_line { + let line_start = line.text_range.start; + let line_end = line.text_range.end; + + let mark_start = marked_range.start.max(line_start) - line_start; + let mark_end = marked_range.end.min(line_end) - line_start; + + let start_pos = wrapped + .position_for_index(mark_start, line_height) + .unwrap_or(point(px(0.), px(0.))); + let end_pos = wrapped + .position_for_index(mark_end, line_height) + .unwrap_or_else(|| { + let last_line_y = line_height * (line.visual_line_count - 1) as f32; + point(wrapped.width(), last_line_y) + }); + + let start_visual_line = compute_visual_line_index(start_pos.y, line_height); + let end_visual_line = compute_visual_line_index(end_pos.y, line_height); + + if start_visual_line == end_visual_line { + window.paint_quad(fill( + Bounds::from_corners( + point( + bounds.left() + start_pos.x, + bounds.top() + line_y + start_pos.y + underline_offset, + ), + point( + bounds.left() + end_pos.x, + bounds.top() + line_y + start_pos.y + line_height, + ), + ), + underline_color, + )); + } else { + // First visual line + window.paint_quad(fill( + Bounds::from_corners( + point( + bounds.left() + start_pos.x, + bounds.top() + line_y + start_pos.y + underline_offset, + ), + point( + bounds.left() + wrapped.width(), + bounds.top() + line_y + start_pos.y + line_height, + ), + ), + underline_color, + )); + + // Middle visual lines + for visual_line in (start_visual_line + 1)..end_visual_line { + let y = line_height * visual_line as f32; + window.paint_quad(fill( + Bounds::from_corners( + point(bounds.left(), bounds.top() + line_y + y + underline_offset), + point( + bounds.left() + wrapped.width(), + bounds.top() + line_y + y + line_height, + ), + ), + underline_color, + )); + } + + // Last visual line + window.paint_quad(fill( + Bounds::from_corners( + point( + bounds.left(), + bounds.top() + line_y + end_pos.y + underline_offset, + ), + point( + bounds.left() + end_pos.x, + bounds.top() + line_y + end_pos.y + line_height, + ), + ), + underline_color, + )); + } + } + } +} + +fn paint_multiline_cursor( + line_layouts: &[InputLineLayout], + cursor_offset: usize, + _content: &str, + bounds: Bounds, + scroll_offset: Pixels, + line_height: Pixels, + cursor_color: Hsla, + window: &mut Window, +) { + for line in line_layouts.iter() { + let line_y = line.y_offset - scroll_offset; + + if !is_line_visible( + line_y, + line_height, + line.visual_line_count, + bounds.size.height, + ) { + continue; + } + + // Since range is non-inclusive of the end value we need to check for it explicitly + let is_cursor_in_line = if line.text_range.is_empty() { + cursor_offset == line.text_range.start + } else { + line.text_range.contains(&cursor_offset) || cursor_offset == line.text_range.end + }; + + if !is_cursor_in_line { + continue; + } + + let cursor_position = if let Some(wrapped) = &line.wrapped_line { + let local_offset = cursor_offset.saturating_sub(line.text_range.start); + wrapped + .position_for_index(local_offset, line_height) + .unwrap_or(point(px(0.), px(0.))) + } else { + point(px(0.), px(0.)) + }; + + window.paint_quad(fill( + Bounds::new( + point( + bounds.left() + cursor_position.x, + bounds.top() + line_y + cursor_position.y, + ), + size(px(CURSOR_WIDTH), line_height), + ), + cursor_color, + )); + break; + } +} + +/// State for single-line painting that pre-computes character positions. +struct SingleLinePaintState { + content: String, + selected_range: std::ops::Range, + marked_range: Option>, + cursor_offset: usize, + scroll_offset: Pixels, + line_height: Pixels, + text_width: Pixels, + is_focused: bool, + char_positions: Vec, + wrapped_line: Option>, +} + +impl SingleLinePaintState { + fn from_input( + input: &Entity, + focus_handle: &FocusHandle, + window: &Window, + cx: &App, + ) -> Self { + let input_state = input.read(cx); + + let mut char_positions = Vec::new(); + let mut text_width = px(0.); + + if let Some(line) = input_state.line_layouts.first() { + if let Some(wrapped) = &line.wrapped_line { + text_width = wrapped.width(); + let content = input_state.content(); + let mut idx = 0; + for ch in content.chars() { + if let Some(pos) = wrapped.position_for_index(idx, input_state.line_height) { + char_positions.push(pos.x); + } else { + char_positions.push(text_width); + } + idx += ch.len_utf8(); + } + char_positions.push(text_width); + } + } + + let wrapped_line = input_state + .line_layouts + .first() + .and_then(|l| l.wrapped_line.clone()); + + Self { + content: input_state.content().to_string(), + selected_range: input_state.selected_range().clone(), + marked_range: input_state.marked_range().cloned(), + cursor_offset: input_state.cursor_offset(), + scroll_offset: input_state.scroll_offset, + line_height: input_state.line_height, + text_width, + is_focused: focus_handle.is_focused(window), + char_positions, + wrapped_line, + } + } + + fn x_for_index(&self, index: usize) -> Pixels { + let char_index = self.content[..index.min(self.content.len())] + .chars() + .count(); + self.char_positions + .get(char_index) + .copied() + .unwrap_or(self.text_width) + } +} + +fn paint_singleline( + input: &Entity, + focus_handle: &FocusHandle, + bounds: Bounds, + text_style: &TextStyle, + placeholder: Option<&SharedString>, + colors: &PaintColors, + cursor_visible: bool, + window: &mut Window, + cx: &mut App, +) { + let state = SingleLinePaintState::from_input(input, focus_handle, window, cx); + + if !state.selected_range.is_empty() { + paint_singleline_selection(&state, bounds, colors.selection, window); + } + + if state.content.is_empty() { + if let Some(placeholder_str) = placeholder { + if !placeholder_str.is_empty() { + paint_placeholder( + placeholder_str, + bounds, + text_style, + colors.placeholder, + window, + cx, + true, + ); + } + } + } else { + paint_singleline_text(&state, bounds, window, cx); + } + + if let Some(marked_range) = &state.marked_range { + if !marked_range.is_empty() { + paint_singleline_marked_underline(&state, marked_range, bounds, colors.cursor, window); + } + } + + if state.is_focused && state.selected_range.is_empty() && cursor_visible { + paint_singleline_cursor(&state, bounds, colors.cursor, window); + } +} + +fn paint_singleline_selection( + state: &SingleLinePaintState, + bounds: Bounds, + selection_color: Hsla, + window: &mut Window, +) { + let start_x = state.x_for_index(state.selected_range.start) - state.scroll_offset; + let end_x = state.x_for_index(state.selected_range.end) - state.scroll_offset; + + let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; + + window.paint_quad(fill( + Bounds::from_corners( + point(bounds.left() + start_x, bounds.top() + y_offset), + point( + bounds.left() + end_x, + bounds.top() + y_offset + state.line_height, + ), + ), + selection_color, + )); +} + +fn paint_placeholder( + placeholder: &SharedString, + bounds: Bounds, + text_style: &TextStyle, + color: Hsla, + window: &mut Window, + cx: &mut App, + baseline: bool, +) { + let run = TextRun { + len: placeholder.len(), + font: text_style.font(), + color, + background_color: None, + underline: None, + strikethrough: None, + }; + + let font_size = text_style.font_size.to_pixels(window.rem_size()); + let shaped_line = window + .text_system() + .shape_line(placeholder.clone(), font_size, &[run], None); + let line_height = text_style.line_height_in_pixels(window.rem_size()); + + let mut paint_origin = bounds.origin; + if baseline { + let y_offset = (bounds.size.height - line_height).max(px(0.)) / 2.0; + paint_origin.y += y_offset; + } + + let _ = shaped_line.paint(paint_origin, line_height, TextAlign::Left, None, window, cx); +} + +fn paint_singleline_text( + state: &SingleLinePaintState, + bounds: Bounds, + window: &mut Window, + cx: &mut App, +) { + let Some(wrapped_line) = &state.wrapped_line else { + return; + }; + + let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; + let paint_origin = point( + bounds.origin.x - state.scroll_offset, + bounds.origin.y + y_offset, + ); + + let _ = wrapped_line.paint( + paint_origin, + state.line_height, + TextAlign::Left, + Some(bounds), + window, + cx, + ); +} + +fn paint_singleline_marked_underline( + state: &SingleLinePaintState, + marked_range: &std::ops::Range, + bounds: Bounds, + underline_color: Hsla, + window: &mut Window, +) { + let start_x = state.x_for_index(marked_range.start) - state.scroll_offset; + let end_x = state.x_for_index(marked_range.end) - state.scroll_offset; + + let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); + let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; + let underline_y = bounds.top() + y_offset + state.line_height - underline_thickness; + + window.paint_quad(fill( + Bounds::from_corners( + point(bounds.left() + start_x, underline_y), + point(bounds.left() + end_x, underline_y + underline_thickness), + ), + underline_color, + )); +} + +fn paint_singleline_cursor( + state: &SingleLinePaintState, + bounds: Bounds, + cursor_color: Hsla, + window: &mut Window, +) { + let cursor_x = state.x_for_index(state.cursor_offset) - state.scroll_offset; + + let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; + + window.paint_quad(fill( + Bounds::new( + point(bounds.left() + cursor_x, bounds.top() + y_offset), + size(px(CURSOR_WIDTH), state.line_height), + ), + cursor_color, + )); +} + +impl Focusable for Input { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.input.focus_handle(cx) + } +} + +impl IntoElement for Input { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +/// Default interval for grouping consecutive edits into a single undo entry. +const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300); + +/// Maximum number of history entries to keep. +const MAX_HISTORY_LEN: usize = 1000; + +/// Events emitted by InputState when significant changes occur. +#[derive(Clone, Debug)] +pub enum InputStateEvent { + /// Emitted when the input gains focus. + Focus, + /// Emitted when the input loses focus. + Blur, + /// Emitted when the text content changes. + TextChanged, + /// Emitted when an undo operation is performed. + Undo, + /// Emitted when a redo operation is performed. + Redo, +} + +impl EventEmitter for InputState {} + +/// A patch-based history entry for memory-efficient undo/redo operations. +/// Instead of storing the full content, we store only the change needed to reverse the edit. +#[derive(Clone, Debug)] +struct HistoryEntry { + /// The byte range that was modified (after the edit, for undo; before the edit, for redo). + range: Range, + /// The text that was replaced (to restore on undo). + old_text: String, + /// The length of the new text that replaced old_text (to know how much to remove on undo). + new_text_len: usize, + /// The selection range before the edit. + selected_range: Range, + /// Whether the selection was reversed before the edit. + selection_reversed: bool, + /// Timestamp for grouping consecutive edits. + timestamp: Instant, +} + +impl HistoryEntry { + /// Apply this patch to undo an edit, returning the reverse patch for redo. + fn apply_undo(&self, content: &mut String) -> HistoryEntry { + let undo_start = self.range.start; + let undo_end = (self.range.start + self.new_text_len).min(content.len()); + + // Capture what we're about to remove (the "new" text that was inserted) + let removed_text = content[undo_start..undo_end].to_string(); + + // Replace with the old text + content.replace_range(undo_start..undo_end, &self.old_text); + + // Return reverse patch for redo + HistoryEntry { + range: undo_start..undo_start + self.old_text.len(), + old_text: removed_text, + new_text_len: self.old_text.len(), + selected_range: self.selected_range.clone(), + selection_reversed: self.selection_reversed, + timestamp: self.timestamp, + } + } + + /// Apply this patch to redo an edit, returning the reverse patch for undo. + fn apply_redo(&self, content: &mut String) -> HistoryEntry { + // Redo is the same operation as undo - we're reversing the undo + self.apply_undo(content) + } +} + +/// `Input` is the state model for text input components. It handles: +/// - Text content storage and manipulation +/// - Selection and cursor management +/// - Keyboard navigation and editing actions +/// - IME (Input Method Editor) support via `EntityInputHandler` +/// ``` +pub struct InputState { + entity_id: EntityId, + focus_handle: FocusHandle, + content: String, + placeholder: SharedString, + selected_range: Range, + selection_reversed: bool, + marked_range: Option>, + pub(crate) line_height: Pixels, + pub(crate) line_layouts: Vec, + pub(crate) wrap_width: Option, + pub(crate) text_style: Option, + pub(crate) needs_layout: bool, + is_selecting: bool, + last_click_position: Option>, + click_count: usize, + /// Scroll offset - vertical for multiline, horizontal for single-line + pub(crate) scroll_offset: Pixels, + pub(crate) available_height: Pixels, + pub(crate) available_width: Pixels, + multiline: bool, + /// Stack of previous states for undo. + undo_stack: Vec, + /// Stack of undone states for redo. + redo_stack: Vec, + /// Optional entity and subscription tracking the blinking of the text cursor. + cursor_blink: Option<(Entity, Subscription)>, + /// Tracks whether we were focused on the last update. + was_focused: bool, + /// Cached UTF-16 length of content for faster IME operations. + /// Lazily computed when None. + cached_utf16_len: Option, +} + +/// Layout information for a single logical line of text in an input. +/// +/// A logical line corresponds to content between newlines in the input text. +/// When text wrapping is enabled, a logical line may span multiple visual lines. +#[derive(Clone, Debug)] +pub struct InputLineLayout { + /// The 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 vertical offset from the top of the text area in pixels. + pub y_offset: Pixels, + /// The number of visual lines this logical line spans (due to wrapping). + pub visual_line_count: usize, +} + +pub enum CursorBlinkType<'app> { + Disabled, + Enabled { + app: &'app mut App, + interval: Option, + }, +} + +impl InputState { + /// Creates a new `Input` with the specified multiline setting. + /// Cursor blinking is enabled by default. + pub fn new(cx: &mut Context) -> Self { + let mut this = Self { + entity_id: cx.entity_id(), + focus_handle: cx.focus_handle(), + content: String::new(), + placeholder: SharedString::default(), + selected_range: 0..0, + selection_reversed: false, + marked_range: None, + line_height: px(0.), + line_layouts: Vec::new(), + wrap_width: None, + text_style: None, + needs_layout: true, + is_selecting: false, + last_click_position: None, + click_count: 0, + scroll_offset: px(0.), + available_height: px(0.), + available_width: px(0.), + multiline: false, + undo_stack: Vec::new(), + cached_utf16_len: None, + redo_stack: Vec::new(), + cursor_blink: None, + was_focused: false, + }; + this = this.cursor_blink(CursorBlinkType::Enabled { + app: cx, + interval: None, + }); + this + } + + pub fn cursor_blink<'app>(mut self, args: CursorBlinkType<'app>) -> Self { + self.cursor_blink = match args { + CursorBlinkType::Disabled => None, + CursorBlinkType::Enabled { app: cx, interval } => { + let interval = interval.unwrap_or(DEFAULT_BLINK_INTERVAL); + let cursor_blink = cx.new(|cx| super::CursorBlink::new(interval, cx)); + let entity_id = self.entity_id; + let subscription = cx.observe(&cursor_blink, move |_, cx| cx.notify(entity_id)); + Some((cursor_blink, subscription)) + } + }; + self + } + + /// Returns whether the cursor should be visible (for blinking). + /// + /// If blinking is not enabled, always returns `true`. + /// This method also updates the blink manager's enabled state based on focus. + pub fn cursor_visible(&mut self, is_focused: bool, cx: &mut Context) -> bool { + // Update cursor blink based on focus changes + if let Some((cursor_blink, _)) = &self.cursor_blink { + if is_focused && !self.was_focused { + cursor_blink.update(cx, |cb, cx| cb.enable(cx)); + cx.emit(InputStateEvent::Focus); + } else if !is_focused && self.was_focused { + cursor_blink.update(cx, |cb, cx| cb.disable(cx)); + cx.emit(InputStateEvent::Blur); + } + } + self.was_focused = is_focused; + + self.cursor_blink + .as_ref() + .map(|(cb, _)| cb.read(cx).visible()) + .unwrap_or(true) + } + + /// Pauses cursor blinking temporarily (e.g., during typing). + fn pause_cursor_blink(&self, cx: &mut Context) { + if let Some((cursor_blink, _)) = &self.cursor_blink { + cursor_blink.update(cx, |cb, cx| cb.pause_blinking(cx)); + } + } + + /// Sets the text style used for layout. Marks layout as dirty if the style changed. + pub(crate) fn set_text_style(&mut self, style: &TextStyle) { + let changed = self + .text_style + .as_ref() + .map_or(true, |current| current != style); + + if changed { + self.text_style = Some(style.clone()); + self.needs_layout = true; + } + } + + /// Returns the current text content. + pub fn content(&self) -> &str { + &self.content + } + + /// Sets the text content, resetting selection to the beginning. + /// This clears the undo/redo history. + pub fn set_content(&mut self, content: impl Into, cx: &mut Context) { + let content = content.into(); + self.content = if self.multiline { + content + } else { + // Strip newlines for single-line input + content.replace('\n', " ").replace('\r', "") + }; + self.selected_range = 0..0; + self.selection_reversed = false; + self.marked_range = None; + self.needs_layout = true; + self.undo_stack.clear(); + self.redo_stack.clear(); + self.cached_utf16_len = None; + self.pause_cursor_blink(cx); + cx.emit(InputStateEvent::TextChanged); + cx.notify(); + } + + /// Returns whether undo is available. + pub fn can_undo(&self) -> bool { + !self.undo_stack.is_empty() + } + + /// Returns whether redo is available. + pub fn can_redo(&self) -> bool { + !self.redo_stack.is_empty() + } + + /// Records a patch for undo. Called before making changes to content. + /// Returns true if a new entry was created, false if grouped with previous. + fn push_undo_patch(&mut self, range: Range, new_text_len: usize) { + // Don't record during IME composition + if self.marked_range.is_some() { + return; + } + + let now = Instant::now(); + + // Check if we should group with the last entry + if let Some(last) = self.undo_stack.last() { + if now.duration_since(last.timestamp) < DEFAULT_GROUP_INTERVAL { + // Within group interval - extend the existing patch + // We need to merge this edit with the previous one + return; + } + } + + // Capture the text that will be replaced + let old_text = self.content[range.clone()].to_string(); + + self.undo_stack.push(HistoryEntry { + range: range.start..range.start + new_text_len, + old_text, + new_text_len, + selected_range: self.selected_range.clone(), + selection_reversed: self.selection_reversed, + timestamp: now, + }); + + // Limit history size + if self.undo_stack.len() > MAX_HISTORY_LEN { + self.undo_stack.remove(0); + } + + // New edit invalidates redo stack + self.redo_stack.clear(); + } + + /// Undoes the last edit by applying the reverse patch. + pub(crate) fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context) { + if let Some(entry) = self.undo_stack.pop() { + // Remember selection to restore + let selected_range = entry.selected_range.clone(); + let selection_reversed = entry.selection_reversed; + + // Apply the undo patch and get the redo patch + let redo_entry = entry.apply_undo(&mut self.content); + self.redo_stack.push(redo_entry); + + // Restore selection state + self.selected_range = selected_range; + self.selection_reversed = selection_reversed; + self.needs_layout = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Undo); + cx.notify(); + } + } + + /// Redoes the last undone edit by applying the forward patch. + pub(crate) fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context) { + if let Some(entry) = self.redo_stack.pop() { + // Apply the redo patch and get the undo patch + let undo_entry = entry.apply_redo(&mut self.content); + + // The undo entry contains the selection state after the original edit + // We need to restore cursor to end of inserted text + let cursor_pos = undo_entry.range.start; + self.selected_range = cursor_pos..cursor_pos; + self.selection_reversed = false; + + self.undo_stack.push(undo_entry); + self.needs_layout = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Redo); + cx.notify(); + } + } + + /// Returns the placeholder text shown when content is empty. + pub fn placeholder(&self) -> &SharedString { + &self.placeholder + } + + /// Sets the placeholder text. + pub fn set_placeholder( + &mut self, + placeholder: impl Into, + cx: &mut Context, + ) { + self.placeholder = placeholder.into(); + cx.notify(); + } + + /// Returns the current selection range. + pub fn selected_range(&self) -> &Range { + &self.selected_range + } + + /// Returns true if the selection is reversed (cursor at start). + pub fn selection_reversed(&self) -> bool { + self.selection_reversed + } + + /// Returns the current cursor offset. + pub fn cursor_offset(&self) -> usize { + if self.selection_reversed { + self.selected_range.start + } else { + self.selected_range.end + } + } + + /// Returns the marked text range (for IME composition). + pub fn marked_range(&self) -> Option<&Range> { + self.marked_range.as_ref() + } + + /// Sets the selection range directly. + pub fn set_selected_range(&mut self, range: Range) { + let range = range.start.min(self.content.len())..range.end.min(self.content.len()); + self.selected_range = range; + self.selection_reversed = false; + } + + /// Returns the selected text range in UTF-16 offsets (for IME). + pub fn selected_text_range_utf16(&self) -> Range { + self.range_to_utf16(&self.selected_range) + } + + /// Inserts text at the current cursor position, replacing any selection. + pub fn insert_text(&mut self, text: &str, cx: &mut Context) { + let range = self + .marked_range + .clone() + .unwrap_or(self.selected_range.clone()); + let range = range.start.min(self.content.len())..range.end.min(self.content.len()); + + let sanitized_text; + let text_to_insert = if self.multiline { + text + } else { + sanitized_text = text.replace('\n', " ").replace('\r', ""); + &sanitized_text + }; + + // Record patch for undo before modifying content + self.push_undo_patch(range.clone(), text_to_insert.len()); + + // Update cached UTF-16 length incrementally if available + if let Some(cached_len) = self.cached_utf16_len { + let removed_utf16_len: usize = self.content[range.clone()] + .chars() + .map(|c| c.len_utf16()) + .sum(); + let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum(); + self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); + } + + self.content.replace_range(range.clone(), text_to_insert); + self.selected_range = + range.start + text_to_insert.len()..range.start + text_to_insert.len(); + self.marked_range.take(); + self.needs_layout = true; + self.pause_cursor_blink(cx); + cx.emit(InputStateEvent::TextChanged); + cx.notify(); + } + + /// Deletes the character before the cursor (convenience method for benchmarks). + pub fn delete_backward(&mut self, cx: &mut Context) { + if self.selected_range.is_empty() { + self.select_to(self.previous_boundary(self.cursor_offset()), cx); + } + self.insert_text("", cx); + } + + /// Undoes the last edit (convenience method without Window). + pub fn undo_action(&mut self, cx: &mut Context) { + if let Some(entry) = self.undo_stack.pop() { + let selected_range = entry.selected_range.clone(); + let selection_reversed = entry.selection_reversed; + + let redo_entry = entry.apply_undo(&mut self.content); + self.redo_stack.push(redo_entry); + + self.selected_range = selected_range; + self.selection_reversed = selection_reversed; + self.needs_layout = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Undo); + cx.notify(); + } + } + + /// Redoes the last undone edit (convenience method without Window). + pub fn redo_action(&mut self, cx: &mut Context) { + if let Some(entry) = self.redo_stack.pop() { + let undo_entry = entry.apply_redo(&mut self.content); + + let cursor_pos = undo_entry.range.start; + self.selected_range = cursor_pos..cursor_pos; + self.selection_reversed = false; + + self.undo_stack.push(undo_entry); + self.needs_layout = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Redo); + cx.notify(); + } + } + + /// Selects all text. + pub fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { + self.selected_range = 0..self.content.len(); + self.selection_reversed = false; + cx.notify(); + } + + pub(crate) fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + let new_pos = self.previous_boundary(self.cursor_offset()); + self.move_to(new_pos, cx); + } else { + self.move_to(self.selected_range.start, cx); + } + } + + pub(crate) fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + let new_pos = self.next_boundary(self.cursor_offset()); + self.move_to(new_pos, cx); + } else { + self.move_to(self.selected_range.end, cx); + } + } + + pub(crate) fn up(&mut self, _: &Up, _window: &mut Window, cx: &mut Context) { + self.pause_cursor_blink(cx); + if !self.multiline { + // In single-line mode, up moves to start + self.selected_range = 0..0; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + return; + } + if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) { + self.selected_range = new_offset..new_offset; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + } + + pub(crate) fn down(&mut self, _: &Down, _window: &mut Window, cx: &mut Context) { + self.pause_cursor_blink(cx); + if !self.multiline { + // In single-line mode, down moves to end + let end = self.content.len(); + self.selected_range = end..end; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + return; + } + if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) { + self.selected_range = new_offset..new_offset; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + } + + pub(crate) fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { + self.select_to(self.previous_boundary(self.cursor_offset()), cx); + } + + pub(crate) fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { + self.select_to(self.next_boundary(self.cursor_offset()), cx); + } + + pub(crate) fn select_up(&mut self, _: &SelectUp, _window: &mut Window, cx: &mut Context) { + self.pause_cursor_blink(cx); + if !self.multiline { + // In single-line mode, select_up selects to start + self.select_to(0, cx); + return; + } + if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) { + if self.selection_reversed { + self.selected_range.start = new_offset; + } else { + self.selected_range.end = new_offset; + } + if self.selected_range.end < self.selected_range.start { + self.selection_reversed = !self.selection_reversed; + self.selected_range = self.selected_range.end..self.selected_range.start; + } + self.scroll_to_cursor(); + cx.notify(); + } + } + + pub(crate) fn select_down( + &mut self, + _: &SelectDown, + _window: &mut Window, + cx: &mut Context, + ) { + self.pause_cursor_blink(cx); + if !self.multiline { + // In single-line mode, select_down selects to end + self.select_to(self.content.len(), cx); + return; + } + if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) { + if self.selection_reversed { + self.selected_range.start = new_offset; + } else { + self.selected_range.end = new_offset; + } + if self.selected_range.end < self.selected_range.start { + self.selection_reversed = !self.selection_reversed; + self.selected_range = self.selected_range.end..self.selected_range.start; + } + self.scroll_to_cursor(); + cx.notify(); + } + } + + pub(crate) fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context) { + let line_start = self.find_line_start(self.cursor_offset()); + self.move_to(line_start, cx); + } + + pub(crate) fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context) { + let line_end = self.find_line_end(self.cursor_offset()); + self.move_to(line_end, cx); + } + + pub(crate) fn move_to_beginning( + &mut self, + _: &MoveToBeginning, + _: &mut Window, + cx: &mut Context, + ) { + self.move_to(0, cx); + } + + pub(crate) fn move_to_end(&mut self, _: &MoveToEnd, _: &mut Window, cx: &mut Context) { + self.move_to(self.content.len(), cx); + } + + pub(crate) fn select_to_beginning( + &mut self, + _: &SelectToBeginning, + _: &mut Window, + cx: &mut Context, + ) { + self.select_to(0, cx); + } + + pub(crate) fn select_to_end( + &mut self, + _: &SelectToEnd, + _: &mut Window, + cx: &mut Context, + ) { + self.select_to(self.content.len(), cx); + } + + pub(crate) fn word_left(&mut self, _: &WordLeft, _: &mut Window, cx: &mut Context) { + let new_pos = self.previous_word_boundary(self.cursor_offset()); + self.move_to(new_pos, cx); + } + + pub(crate) fn word_right(&mut self, _: &WordRight, _: &mut Window, cx: &mut Context) { + let new_pos = self.next_word_boundary(self.cursor_offset()); + self.move_to(new_pos, cx); + } + + pub(crate) fn select_word_left( + &mut self, + _: &SelectWordLeft, + _: &mut Window, + cx: &mut Context, + ) { + let new_pos = self.previous_word_boundary(self.cursor_offset()); + self.select_to(new_pos, cx); + } + + pub(crate) fn select_word_right( + &mut self, + _: &SelectWordRight, + _: &mut Window, + cx: &mut Context, + ) { + let new_pos = self.next_word_boundary(self.cursor_offset()); + self.select_to(new_pos, cx); + } + + pub(crate) fn enter(&mut self, _: &Enter, window: &mut Window, cx: &mut Context) { + if self.multiline { + self.replace_text_in_range(None, "\n", window, cx); + } + } + + pub(crate) fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { + self.replace_text_in_range(None, "\t", window, cx); + } + + pub(crate) fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + self.select_to(self.previous_boundary(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + self.select_to(self.next_boundary(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn delete_word_left( + &mut self, + _: &DeleteWordLeft, + window: &mut Window, + cx: &mut Context, + ) { + if self.selected_range.is_empty() { + self.select_to(self.previous_word_boundary(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn delete_word_right( + &mut self, + _: &DeleteWordRight, + window: &mut Window, + cx: &mut Context, + ) { + if self.selected_range.is_empty() { + self.select_to(self.next_word_boundary(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn delete_to_beginning_of_line( + &mut self, + _: &DeleteToBeginningOfLine, + window: &mut Window, + cx: &mut Context, + ) { + if self.selected_range.is_empty() { + self.select_to(self.find_line_start(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn delete_to_end_of_line( + &mut self, + _: &DeleteToEndOfLine, + window: &mut Window, + cx: &mut Context, + ) { + if self.selected_range.is_empty() { + self.select_to(self.find_line_end(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { + if self.multiline { + self.replace_text_in_range(None, &text, window, cx); + } else { + // Strip newlines for single-line input + let text = text.replace('\n', " ").replace('\r', ""); + self.replace_text_in_range(None, &text, window, cx); + } + } + } + + pub(crate) fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { + if !self.selected_range.is_empty() { + cx.write_to_clipboard(ClipboardItem::new_string( + self.content[self.selected_range.clone()].to_string(), + )); + } + } + + pub(crate) fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { + if !self.selected_range.is_empty() { + // Cut selected text + cx.write_to_clipboard(ClipboardItem::new_string( + self.content[self.selected_range.clone()].to_string(), + )); + self.replace_text_in_range(None, "", window, cx); + } else { + // No selection: cut the entire current line (including newline) + let cursor = self.cursor_offset(); + let line_start = self.find_line_start(cursor); + let line_end = self.find_line_end(cursor); + + // Include the newline character if there is one after the line + let cut_end = if line_end < self.content.len() { + line_end + 1 // Include the newline + } else if line_start > 0 { + // Last line with no trailing newline - include preceding newline instead + line_end + } else { + line_end + }; + + // For last line, also remove the preceding newline if it exists + let cut_start = if line_end >= self.content.len() && line_start > 0 { + line_start - 1 // Include preceding newline for last line + } else { + line_start + }; + + let line_text = self.content[cut_start..cut_end].to_string(); + cx.write_to_clipboard(ClipboardItem::new_string(line_text)); + + self.selected_range = cut_start..cut_end; + self.replace_text_in_range(None, "", window, cx); + } + } + + pub(crate) fn on_mouse_down( + &mut self, + position: Point, + click_count: usize, + shift: bool, + window: &mut Window, + cx: &mut Context, + ) { + window.focus(&self.focus_handle, cx); + self.is_selecting = true; + + let is_same_position = self + .last_click_position + .map(|last| { + let threshold = px(4.); + (position.x - last.x).abs() < threshold && (position.y - last.y).abs() < threshold + }) + .unwrap_or(false); + + if is_same_position && click_count > 1 { + self.click_count = click_count; + } else { + self.click_count = 1; + } + self.last_click_position = Some(position); + + let clicked_offset = self.index_for_position(position); + + match self.click_count { + 2 => { + let (word_start, word_end) = self.word_range_at(clicked_offset); + self.selected_range = word_start..word_end; + self.selection_reversed = false; + cx.notify(); + } + 3 => { + let line_start = self.find_line_start(clicked_offset); + let line_end = self.find_line_end(clicked_offset); + let line_end_with_newline = if line_end < self.content.len() { + line_end + 1 + } else { + line_end + }; + self.selected_range = line_start..line_end_with_newline; + self.selection_reversed = false; + cx.notify(); + } + _ => { + if shift { + self.select_to(clicked_offset, cx); + } else { + self.move_to(clicked_offset, cx); + } + } + } + } + + pub(crate) fn on_mouse_up(&mut self, _cx: &mut Context) { + self.is_selecting = false; + } + + pub(crate) fn on_mouse_move(&mut self, position: Point, cx: &mut Context) { + if self.is_selecting && self.click_count == 1 { + self.select_to(self.index_for_position(position), cx); + } + } + + fn move_to(&mut self, offset: usize, cx: &mut Context) { + self.pause_cursor_blink(cx); + let offset = offset.min(self.content.len()); + self.selected_range = offset..offset; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + + fn select_to(&mut self, offset: usize, cx: &mut Context) { + self.pause_cursor_blink(cx); + let offset = offset.min(self.content.len()); + if self.selection_reversed { + self.selected_range.start = offset; + } else { + self.selected_range.end = offset; + } + if self.selected_range.end < self.selected_range.start { + self.selection_reversed = !self.selection_reversed; + self.selected_range = self.selected_range.end..self.selected_range.start; + } + self.scroll_to_cursor(); + cx.notify(); + } + + pub(crate) fn find_line_start(&self, offset: usize) -> usize { + self.content[..offset.min(self.content.len())] + .rfind('\n') + .map(|pos| pos + 1) + .unwrap_or(0) + } + + pub(crate) fn find_line_end(&self, offset: usize) -> usize { + self.content[offset.min(self.content.len())..] + .find('\n') + .map(|pos| offset + pos) + .unwrap_or(self.content.len()) + } + + fn move_vertically(&self, offset: usize, direction: i32) -> Option { + let (visual_line_idx, x_pixels) = self.find_visual_line_and_x_offset(offset); + let target_visual_line_idx = (visual_line_idx as i32 + direction).max(0) as usize; + + let mut current_visual_line = 0; + for layout in self.line_layouts.iter() { + let visual_lines_in_layout = layout.visual_line_count; + + if target_visual_line_idx < current_visual_line + visual_lines_in_layout { + let visual_line_within_layout = target_visual_line_idx - current_visual_line; + + if layout.text_range.is_empty() { + return Some(layout.text_range.start); + } + + if let Some(wrapped) = &layout.wrapped_line { + let y_within_wrapped = self.line_height * visual_line_within_layout as f32; + let target_point = point(px(x_pixels), y_within_wrapped); + + let closest_result = + wrapped.closest_index_for_position(target_point, self.line_height); + + let closest_idx = closest_result.unwrap_or_else(|closest| closest); + let clamped = closest_idx.min(wrapped.text.len()); + let result = layout.text_range.start + clamped; + + return Some(result); + } + + return Some(layout.text_range.start); + } + + current_visual_line += visual_lines_in_layout; + } + + if direction > 0 { + Some(self.content.len()) + } else { + None + } + } + + fn find_visual_line_and_x_offset(&self, offset: usize) -> (usize, f32) { + if self.line_layouts.is_empty() { + return (0, 0.0); + } + + let mut visual_line_idx = 0; + + for line in &self.line_layouts { + if line.text_range.is_empty() { + if offset == line.text_range.start { + return (visual_line_idx, 0.0); + } + } else if offset >= line.text_range.start && offset <= line.text_range.end { + if let Some(wrapped) = &line.wrapped_line { + let local_offset = (offset - line.text_range.start).min(wrapped.text.len()); + if let Some(position) = + wrapped.position_for_index(local_offset, self.line_height) + { + let visual_line_within = (position.y / self.line_height).floor() as usize; + return (visual_line_idx + visual_line_within, position.x.into()); + } + } + return (visual_line_idx, 0.0); + } + visual_line_idx += line.visual_line_count; + } + + (visual_line_idx.saturating_sub(1), 0.0) + } + + pub(crate) fn index_for_position(&self, position: Point) -> usize { + if self.content.is_empty() { + return 0; + } + + for line in self.line_layouts.iter() { + let line_height_total = self.line_height * line.visual_line_count as f32; + + if position.y >= line.y_offset && position.y < line.y_offset + line_height_total { + if line.text_range.is_empty() { + return line.text_range.start; + } + + if let Some(wrapped) = &line.wrapped_line { + let relative_y = position.y - line.y_offset; + let relative_point = point(position.x, relative_y); + + let closest_result = + wrapped.closest_index_for_position(relative_point, self.line_height); + + let local_idx = closest_result.unwrap_or_else(|closest| closest); + let clamped = local_idx.min(wrapped.text.len()); + return line.text_range.start + clamped; + } + return line.text_range.start; + } + } + + self.content.len() + } + + pub(crate) fn scroll_to_cursor(&mut self) { + if self.line_layouts.is_empty() { + return; + } + + let cursor_offset = self.cursor_offset(); + + if self.multiline { + self.scroll_to_cursor_vertical(cursor_offset); + } else { + self.scroll_to_cursor_horizontal(cursor_offset); + } + } + + fn scroll_to_cursor_vertical(&mut self, cursor_offset: usize) { + if self.available_height <= px(0.) { + return; + } + + let line_height = self.line_height; + + for line in &self.line_layouts { + let is_cursor_in_line = if line.text_range.is_empty() { + cursor_offset == line.text_range.start + } else { + line.text_range.contains(&cursor_offset) + || (cursor_offset == line.text_range.end && cursor_offset == self.content.len()) + }; + + if is_cursor_in_line { + let cursor_visual_y = if let Some(wrapped) = &line.wrapped_line { + let local_offset = cursor_offset.saturating_sub(line.text_range.start); + if let Some(position) = + wrapped.position_for_index(local_offset, self.line_height) + { + line.y_offset + position.y + } else { + line.y_offset + } + } else { + line.y_offset + }; + + let visible_top = self.scroll_offset; + let visible_bottom = self.scroll_offset + self.available_height; + + if cursor_visual_y < visible_top { + self.scroll_offset = cursor_visual_y; + } else if cursor_visual_y + line_height > visible_bottom { + self.scroll_offset = (cursor_visual_y + line_height) - self.available_height; + } + + self.scroll_offset = self.scroll_offset.max(px(0.)); + break; + } + } + } + + fn scroll_to_cursor_horizontal(&mut self, cursor_offset: usize) { + if self.available_width <= px(0.) { + return; + } + + // For single-line input, get cursor x position from the first (only) line + let Some(line) = self.line_layouts.first() else { + return; + }; + + let cursor_x = if let Some(wrapped) = &line.wrapped_line { + let local_offset = cursor_offset.saturating_sub(line.text_range.start); + wrapped + .position_for_index(local_offset, self.line_height) + .map(|p| p.x) + .unwrap_or(px(0.)) + } else { + px(0.) + }; + + let visible_left = self.scroll_offset; + let visible_right = self.scroll_offset + self.available_width; + + // Add some padding so cursor isn't right at the edge + let padding = px(2.0); + + if cursor_x < visible_left + padding { + self.scroll_offset = (cursor_x - padding).max(px(0.)); + } else if cursor_x > visible_right - padding { + self.scroll_offset = cursor_x - self.available_width + padding; + } + + self.scroll_offset = self.scroll_offset.max(px(0.)); + } + + pub(crate) fn update_line_layouts( + &mut self, + width: Pixels, + line_height: Pixels, + text_style: &TextStyle, + window: &mut Window, + ) { + self.line_height = line_height; + self.set_text_style(text_style); + + if !self.needs_layout && self.wrap_width == Some(width) { + return; + } + + self.line_layouts.clear(); + self.wrap_width = Some(width); + + let text_color = text_style.color; + let font_size = text_style.font_size.to_pixels(window.rem_size()); + + if self.content.is_empty() { + self.line_layouts.push(InputLineLayout { + text_range: 0..0, + wrapped_line: None, + y_offset: px(0.), + visual_line_count: 1, + }); + self.needs_layout = false; + return; + } + + let mut y_offset = px(0.); + let mut current_pos = 0; + + while current_pos < self.content.len() { + let line_end = self.content[current_pos..] + .find('\n') + .map(|pos| current_pos + pos) + .unwrap_or(self.content.len()); + + let line_text = &self.content[current_pos..line_end]; + + if line_text.is_empty() { + self.line_layouts.push(InputLineLayout { + text_range: current_pos..current_pos, + wrapped_line: None, + y_offset, + visual_line_count: 1, + }); + y_offset += line_height; + } else { + let run = TextRun { + len: line_text.len(), + font: text_style.font(), + color: text_color, + background_color: None, + underline: None, + strikethrough: None, + }; + + let wrapped_lines = window + .text_system() + .shape_text( + SharedString::from(line_text.to_string()), + font_size, + &[run], + Some(width), + None, + ) + .unwrap_or_default(); + + for wrapped in wrapped_lines { + let visual_line_count = wrapped.wrap_boundaries().len() + 1; + let line_height_total = line_height * visual_line_count as f32; + + self.line_layouts.push(InputLineLayout { + text_range: current_pos..line_end, + wrapped_line: Some(Arc::new(wrapped)), + y_offset, + visual_line_count, + }); + + y_offset += line_height_total; + } + } + + current_pos = if line_end < self.content.len() { + line_end + 1 + } else { + self.content.len() + }; + } + + if self.content.ends_with('\n') { + self.line_layouts.push(InputLineLayout { + text_range: self.content.len()..self.content.len(), + wrapped_line: None, + y_offset, + visual_line_count: 1, + }); + } + + self.needs_layout = false; + self.scroll_to_cursor(); + } + + pub(crate) fn total_content_height(&self) -> Pixels { + self.line_layouts + .last() + .map(|last| last.y_offset + self.line_height * last.visual_line_count as f32) + .unwrap_or(px(0.)) + } + + /// Returns true if the scroll position is at the top. + pub fn at_top(&self) -> bool { + self.scroll_offset <= px(0.) + } + + /// Returns true if the scroll position is at the bottom. + pub fn at_bottom(&self) -> bool { + let content_height = self.total_content_height(); + let visible_height = self.available_height; + + if content_height <= visible_height { + return true; + } + + self.scroll_offset + visible_height >= content_height + } + + /// Returns the scroll progress as a value from 0.0 (top) to 1.0 (bottom). + pub fn scroll_progress(&self) -> f32 { + let content_height = self.total_content_height(); + let visible_height = self.available_height; + let max_scroll = content_height - visible_height; + + if max_scroll <= px(0.) { + return 0.0; + } + + (self.scroll_offset / max_scroll).clamp(0.0, 1.0) + } + + /// Returns how far the content is scrolled from the top in pixels. + pub fn distance_from_top(&self) -> Pixels { + self.scroll_offset.max(px(0.)) + } + + /// Returns how far the content is from the bottom in pixels. + pub fn distance_from_bottom(&self) -> Pixels { + let content_height = self.total_content_height(); + let visible_height = self.available_height; + let max_scroll = content_height - visible_height; + + if max_scroll <= px(0.) { + return px(0.); + } + + (max_scroll - self.scroll_offset).max(px(0.)) + } + + fn offset_from_utf16(&self, offset: usize) -> usize { + // Fast path: if offset is 0, return 0 + if offset == 0 { + return 0; + } + + // Fast path: if we have cached length and offset is at or past end + if let Some(utf16_len) = self.cached_utf16_len { + if offset >= utf16_len { + return self.content.len(); + } + } + + let mut utf8_offset = 0; + let mut utf16_count = 0; + + for character in self.content.chars() { + if utf16_count >= offset { + break; + } + utf16_count += character.len_utf16(); + utf8_offset += character.len_utf8(); + } + + utf8_offset.min(self.content.len()) + } + + fn offset_to_utf16(&self, offset: usize) -> usize { + // Fast path: if offset is 0, return 0 + if offset == 0 { + return 0; + } + + // Fast path: if offset is at or past end, return cached length + if offset >= self.content.len() { + return self.utf16_len(); + } + + let mut utf16_offset = 0; + let mut utf8_count = 0; + + for character in self.content.chars() { + if utf8_count >= offset { + break; + } + utf8_count += character.len_utf8(); + utf16_offset += character.len_utf16(); + } + + utf16_offset + } + + /// Returns the UTF-16 length of the content, computing and caching if necessary. + fn utf16_len(&self) -> usize { + if let Some(len) = self.cached_utf16_len { + return len; + } + self.content.chars().map(|c| c.len_utf16()).sum() + } + + fn range_to_utf16(&self, range: &Range) -> Range { + self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end) + } + + fn range_from_utf16(&self, range_utf16: &Range) -> Range { + self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end) + } + + fn previous_boundary(&self, offset: usize) -> usize { + if offset == 0 { + return 0; + } + + let text_before = &self.content[..offset.min(self.content.len())]; + text_before + .grapheme_indices(true) + .map(|(i, _)| i) + .next_back() + .unwrap_or(0) + } + + fn next_boundary(&self, offset: usize) -> usize { + if offset >= self.content.len() { + return self.content.len(); + } + + let text_after = &self.content[offset..]; + text_after + .grapheme_indices(true) + .nth(1) + .map(|(i, _)| offset + i) + .unwrap_or(self.content.len()) + } + + fn previous_word_boundary(&self, offset: usize) -> usize { + if offset == 0 { + return 0; + } + + let text_before = &self.content[..offset.min(self.content.len())]; + + let mut last_word_start = 0; + for (idx, _) in text_before.unicode_word_indices() { + if idx < offset { + last_word_start = idx; + } + } + + if last_word_start == 0 && offset > 0 { + let trimmed = text_before.trim_end(); + if trimmed.is_empty() { + return 0; + } + for (idx, _) in trimmed.unicode_word_indices() { + last_word_start = idx; + } + } + + last_word_start + } + + fn next_word_boundary(&self, offset: usize) -> usize { + if offset >= self.content.len() { + return self.content.len(); + } + + let text_after = &self.content[offset..]; + + for (idx, word) in text_after.unicode_word_indices() { + let word_end = offset + idx + word.len(); + if word_end > offset { + return word_end; + } + } + + self.content.len() + } + + fn word_range_at(&self, offset: usize) -> (usize, usize) { + let offset = offset.min(self.content.len()); + + for (idx, word) in self.content.unicode_word_indices() { + let word_end = idx + word.len(); + if offset >= idx && offset <= word_end { + return (idx, word_end); + } + } + + (offset, offset) + } +} + +impl EntityInputHandler for InputState { + fn text_for_range( + &mut self, + range_utf16: Range, + adjusted_range: &mut Option>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + let range = self.range_from_utf16(&range_utf16); + let clamped_range = range.start.min(self.content.len())..range.end.min(self.content.len()); + adjusted_range.replace(self.range_to_utf16(&clamped_range)); + Some(self.content[clamped_range].to_string()) + } + + fn selected_text_range( + &mut self, + _ignore_disabled_input: bool, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + Some(UTF16Selection { + range: self.range_to_utf16(&self.selected_range), + reversed: self.selection_reversed, + }) + } + + fn marked_text_range( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + self.marked_range + .as_ref() + .map(|range| self.range_to_utf16(range)) + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { + self.marked_range = None; + } + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + _window: &mut Window, + cx: &mut Context, + ) { + let range = range_utf16 + .as_ref() + .map(|range_utf16| self.range_from_utf16(range_utf16)) + .or(self.marked_range.clone()) + .unwrap_or(self.selected_range.clone()); + + let range = range.start.min(self.content.len())..range.end.min(self.content.len()); + + // Strip newlines for single-line input + let sanitized_text; + let text_to_insert = if self.multiline { + new_text + } else { + sanitized_text = new_text.replace('\n', " ").replace('\r', ""); + &sanitized_text + }; + + // Record patch for undo before modifying content + self.push_undo_patch(range.clone(), text_to_insert.len()); + + // Update cached UTF-16 length incrementally if available + if let Some(cached_len) = self.cached_utf16_len { + let removed_utf16_len: usize = self.content[range.clone()] + .chars() + .map(|c| c.len_utf16()) + .sum(); + let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum(); + self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); + } + + self.content.replace_range(range.clone(), text_to_insert); + self.selected_range = + range.start + text_to_insert.len()..range.start + text_to_insert.len(); + self.marked_range.take(); + self.needs_layout = true; + self.pause_cursor_blink(cx); + cx.emit(InputStateEvent::TextChanged); + cx.notify(); + } + + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + new_selected_range_utf16: Option>, + _window: &mut Window, + cx: &mut Context, + ) { + let range = range_utf16 + .as_ref() + .map(|range_utf16| self.range_from_utf16(range_utf16)) + .or(self.marked_range.clone()) + .unwrap_or(self.selected_range.clone()); + + let range = range.start.min(self.content.len())..range.end.min(self.content.len()); + + // Strip newlines for single-line input + let sanitized_text; + let text_to_insert = if self.multiline { + new_text + } else { + sanitized_text = new_text.replace('\n', " ").replace('\r', ""); + &sanitized_text + }; + + // Update cached UTF-16 length incrementally if available + if let Some(cached_len) = self.cached_utf16_len { + let removed_utf16_len: usize = self.content[range.clone()] + .chars() + .map(|c| c.len_utf16()) + .sum(); + let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum(); + self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); + } + + self.content.replace_range(range.clone(), text_to_insert); + + if !text_to_insert.is_empty() { + self.marked_range = Some(range.start..range.start + text_to_insert.len()); + } else { + self.marked_range = None; + } + + self.selected_range = new_selected_range_utf16 + .as_ref() + .map(|range_utf16| self.range_from_utf16(range_utf16)) + .map(|new_range| new_range.start + range.start..new_range.end + range.start) + .unwrap_or_else(|| { + range.start + text_to_insert.len()..range.start + text_to_insert.len() + }); + + self.needs_layout = true; + cx.emit(InputStateEvent::TextChanged); + cx.notify(); + } + + fn bounds_for_range( + &mut self, + range_utf16: Range, + bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + let range = self.range_from_utf16(&range_utf16); + + for line in &self.line_layouts { + if line.text_range.is_empty() { + if range.start == line.text_range.start { + return Some(Bounds::from_corners( + point(bounds.left(), bounds.top() + line.y_offset), + point( + bounds.left() + px(4.), + bounds.top() + line.y_offset + self.line_height, + ), + )); + } + } else if line.text_range.contains(&range.start) { + if let Some(wrapped) = &line.wrapped_line { + let local_start = range.start - line.text_range.start; + let local_end = (range.end - line.text_range.start).min(wrapped.text.len()); + + let start_pos = wrapped + .position_for_index(local_start, self.line_height) + .unwrap_or(point(px(0.), px(0.))); + let end_pos = wrapped + .position_for_index(local_end, self.line_height) + .unwrap_or_else(|| { + let last_line_y = + self.line_height * (line.visual_line_count - 1) as f32; + point(wrapped.width(), last_line_y) + }); + + let start_visual_line = (start_pos.y / self.line_height).floor() as usize; + let end_visual_line = (end_pos.y / self.line_height).floor() as usize; + + if start_visual_line == end_visual_line { + return Some(Bounds::from_corners( + point( + bounds.left() + start_pos.x, + bounds.top() + line.y_offset + start_pos.y, + ), + point( + bounds.left() + end_pos.x, + bounds.top() + line.y_offset + start_pos.y + self.line_height, + ), + )); + } else { + return Some(Bounds::from_corners( + point( + bounds.left() + start_pos.x, + bounds.top() + line.y_offset + start_pos.y, + ), + point( + bounds.left() + wrapped.width(), + bounds.top() + line.y_offset + start_pos.y + self.line_height, + ), + )); + } + } + } + } + None + } + + fn character_index_for_point( + &mut self, + point: Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + let index = self.index_for_position(point); + Some(self.offset_to_utf16(index)) + } +} + +impl Focusable for InputState { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} diff --git a/crates/gpui_elements/src/lib.rs b/crates/gpui_elements/src/lib.rs index 8b13789179..7839bc5393 100644 --- a/crates/gpui_elements/src/lib.rs +++ b/crates/gpui_elements/src/lib.rs @@ -1 +1 @@ - +pub mod input; From 13676cf6498704f0d1e1c1f2b8f87ca4921193f7 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Fri, 5 Jun 2026 16:09:07 -0400 Subject: [PATCH 007/117] organize input element types and implementation into dedicated files --- crates/gpui_elements/src/input.rs | 18 +- crates/gpui_elements/src/input/actions.rs | 157 + crates/gpui_elements/src/input/colors.rs | 18 + crates/gpui_elements/src/input/cursor.rs | 8 +- crates/gpui_elements/src/input/element.rs | 2860 +---------------- crates/gpui_elements/src/input/history.rs | 58 + crates/gpui_elements/src/input/paint.rs | 1014 ++++++ crates/gpui_elements/src/input/state.rs | 1319 ++++++++ .../src/input/state_input_handler.rs | 229 ++ crates/gpui_elements/src/input/unicode.rs | 85 + 10 files changed, 2907 insertions(+), 2859 deletions(-) create mode 100644 crates/gpui_elements/src/input/actions.rs create mode 100644 crates/gpui_elements/src/input/colors.rs create mode 100644 crates/gpui_elements/src/input/history.rs create mode 100644 crates/gpui_elements/src/input/paint.rs create mode 100644 crates/gpui_elements/src/input/state.rs create mode 100644 crates/gpui_elements/src/input/state_input_handler.rs create mode 100644 crates/gpui_elements/src/input/unicode.rs diff --git a/crates/gpui_elements/src/input.rs b/crates/gpui_elements/src/input.rs index 433ef1625e..fef44ff5e8 100644 --- a/crates/gpui_elements/src/input.rs +++ b/crates/gpui_elements/src/input.rs @@ -1,5 +1,15 @@ -mod element; -pub use element::*; - +pub mod actions; +mod colors; mod cursor; -pub use cursor::*; +mod element; +mod history; +mod paint; +mod state; +mod state_input_handler; +pub(self) mod unicode; + +pub use colors::*; +pub(self) use cursor::*; +pub use element::*; +pub(self) use history::*; +pub use state::*; diff --git a/crates/gpui_elements/src/input/actions.rs b/crates/gpui_elements/src/input/actions.rs new file mode 100644 index 0000000000..c23acbe73b --- /dev/null +++ b/crates/gpui_elements/src/input/actions.rs @@ -0,0 +1,157 @@ +/// The key context used for input element keybindings. +pub const DEFAULT_INPUT_CONTEXT: &str = "Input"; + +gpui::actions!( + actions, + [ + /// Delete the character before the cursor. + Backspace, + /// Delete the character after the cursor. + Delete, + /// Blur focus from the input. + Escape, + /// Delete the word before the cursor. + DeleteWordLeft, + /// Delete the word after the cursor. + DeleteWordRight, + /// Delete from the cursor to the beginning of the line. + DeleteToBeginningOfLine, + /// Delete from the cursor to the end of the line. + DeleteToEndOfLine, + /// Insert a tab character at the cursor position. + Tab, + /// Move the cursor one character to the left. + Left, + /// Move the cursor one character to the right. + Right, + /// Move the cursor up one visual line. + Up, + /// Move the cursor down one visual line. + Down, + /// Extend selection one character to the left. + SelectLeft, + /// Extend selection one character to the right. + SelectRight, + /// Extend selection up one visual line. + SelectUp, + /// Extend selection down one visual line. + SelectDown, + /// Select all text content. + SelectAll, + /// Move cursor to the start of the current line. + Home, + /// Move cursor to the end of the current line. + End, + /// Extend selection to the beginning of the content. + SelectToBeginning, + /// Extend selection to the end of the content. + SelectToEnd, + /// Move cursor to the beginning of the content. + MoveToBeginning, + /// Move cursor to the end of the content. + MoveToEnd, + /// Paste from clipboard at the cursor position. + Paste, + /// Cut selected text to clipboard. + Cut, + /// Copy selected text to clipboard. + Copy, + /// Insert a newline at the cursor position. + Enter, + /// Move cursor one word to the left. + WordLeft, + /// Move cursor one word to the right. + WordRight, + /// Extend selection one word to the left. + SelectWordLeft, + /// Extend selection one word to the right. + SelectWordRight, + /// Undo the last edit. + Undo, + /// Redo the last undone edit. + Redo, + ] +); + +pub fn default_bindings() -> gpui::ActionBindingCollection { + let mut bindings = gpui::ActionBindingCollection::default(); + + #[cfg(target_os = "macos")] + { + bindings = bindings + .with::("backspace") + .with::("delete") + .with::("alt-backspace") + .with::("alt-delete") + .with::("cmd-backspace") + .with::("ctrl-k") + .with::("tab") + .with::("enter") + .with::("left") + .with::("right") + .with::("up") + .with::("down") + .with::("shift-left") + .with::("shift-right") + .with::("shift-up") + .with::("shift-down") + .with::("cmd-a") + // Mac keyboards don't have Home/End keys, so cmd-left/right are standard + .with::("cmd-left") + .with::("cmd-right") + .with::("cmd-up") + .with::("cmd-down") + .with::("cmd-shift-up") + .with::("cmd-shift-down") + .with::("alt-left") + .with::("alt-right") + .with::("alt-shift-left") + .with::("alt-shift-right") + .with::("cmd-c") + .with::("cmd-x") + .with::("cmd-v") + .with::("cmd-z") + .with::("cmd-shift-z") + .with::("escape"); + } + + #[cfg(not(target_os = "macos"))] + { + bindings = bindings + .with::("backspace") + .with::("delete") + .with::("ctrl-backspace") + .with::("ctrl-delete") + .with::("ctrl-shift-backspace") + .with::("ctrl-shift-delete") + .with::("tab") + .with::("enter") + .with::("left") + .with::("right") + .with::("up") + .with::("down") + .with::("shift-left") + .with::("shift-right") + .with::("shift-up") + .with::("shift-down") + .with::("ctrl-a") + .with::("home") + .with::("end") + .with::("ctrl-home") + .with::("ctrl-end") + .with::("ctrl-shift-home") + .with::("ctrl-shift-end") + .with::("ctrl-left") + .with::("ctrl-right") + .with::("ctrl-shift-left") + .with::("ctrl-shift-right") + .with::("ctrl-c") + .with::("ctrl-x") + .with::("ctrl-v") + .with::("ctrl-z") + .with::("ctrl-shift-z") + .with::("escape"); + } + + bindings +} diff --git a/crates/gpui_elements/src/input/colors.rs b/crates/gpui_elements/src/input/colors.rs new file mode 100644 index 0000000000..f21737034c --- /dev/null +++ b/crates/gpui_elements/src/input/colors.rs @@ -0,0 +1,18 @@ +use gpui::Hsla; + +#[derive(Clone, Copy, Debug)] +pub struct PaintColors { + pub selection: Hsla, + pub cursor: Hsla, + pub placeholder: Hsla, +} + +impl Default for PaintColors { + fn default() -> Self { + Self { + selection: Hsla::blue().opacity(0.2), + cursor: Hsla::white().opacity(0.8), + placeholder: gpui::hsla(0.6, 0.6, 0.6, 1.0), + } + } +} diff --git a/crates/gpui_elements/src/input/cursor.rs b/crates/gpui_elements/src/input/cursor.rs index c3ab36e3f8..8017ed9842 100644 --- a/crates/gpui_elements/src/input/cursor.rs +++ b/crates/gpui_elements/src/input/cursor.rs @@ -1,6 +1,9 @@ use gpui::Context; use std::time::Duration; +/// Default interval for cursor blinking. +pub const DEFAULT_BLINK_INTERVAL: Duration = Duration::from_millis(500); + /// Manages the blinking state of a text cursor. /// /// The cursor blinks at a configurable interval when enabled. Blinking can be @@ -32,11 +35,6 @@ impl CursorBlink { self.visible } - /// Returns whether blinking is currently active. - pub fn is_active(&self) -> bool { - self.active - } - /// Activates cursor blinking. /// /// When activated, the cursor will alternate between visible and hidden diff --git a/crates/gpui_elements/src/input/element.rs b/crates/gpui_elements/src/input/element.rs index 722eb2e0cb..f77539479b 100644 --- a/crates/gpui_elements/src/input/element.rs +++ b/crates/gpui_elements/src/input/element.rs @@ -1,212 +1,21 @@ +use crate::input::{InputState, PaintColors}; use gpui::{ - Action, App, AppContext, Bounds, ClipboardItem, ContentMask, Context, CursorStyle, - DispatchPhase, Element, ElementId, ElementInputHandler, Entity, EntityId, EntityInputHandler, - EventEmitter, FocusHandle, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla, - InspectorElementId, InteractiveElement, Interactivity, IntoElement, KeyBinding, LayoutId, - Length, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, - ScrollWheelEvent, SharedString, StyleRefinement, Styled, Subscription, TextAlign, TextRun, - TextStyle, UTF16Selection, Window, WrappedLine, actions, fill, point, px, relative, size, + Action, App, Context, Entity, FocusHandle, Focusable, InteractiveElement, Interactivity, + IntoElement, SharedString, StyleRefinement, Styled, Window, }; -use std::{ - ops::Range, - sync::Arc, - time::{Duration, Instant}, -}; -use unicode_segmentation::UnicodeSegmentation; - -const CURSOR_WIDTH: f32 = 2.0; -const MARKED_TEXT_UNDERLINE_THICKNESS: f32 = 2.0; - -/// Default interval for cursor blinking. -const DEFAULT_BLINK_INTERVAL: Duration = Duration::from_millis(500); - -/// The key context used for input element keybindings. -pub const DEFAULT_INPUT_CONTEXT: &str = "Input"; - -actions!( - actions, - [ - /// Delete the character before the cursor. - Backspace, - /// Delete the character after the cursor. - Delete, - /// Blur focus from the input. - Escape, - /// Delete the word before the cursor. - DeleteWordLeft, - /// Delete the word after the cursor. - DeleteWordRight, - /// Delete from the cursor to the beginning of the line. - DeleteToBeginningOfLine, - /// Delete from the cursor to the end of the line. - DeleteToEndOfLine, - /// Insert a tab character at the cursor position. - Tab, - /// Move the cursor one character to the left. - Left, - /// Move the cursor one character to the right. - Right, - /// Move the cursor up one visual line. - Up, - /// Move the cursor down one visual line. - Down, - /// Extend selection one character to the left. - SelectLeft, - /// Extend selection one character to the right. - SelectRight, - /// Extend selection up one visual line. - SelectUp, - /// Extend selection down one visual line. - SelectDown, - /// Select all text content. - SelectAll, - /// Move cursor to the start of the current line. - Home, - /// Move cursor to the end of the current line. - End, - /// Extend selection to the beginning of the content. - SelectToBeginning, - /// Extend selection to the end of the content. - SelectToEnd, - /// Move cursor to the beginning of the content. - MoveToBeginning, - /// Move cursor to the end of the content. - MoveToEnd, - /// Paste from clipboard at the cursor position. - Paste, - /// Cut selected text to clipboard. - Cut, - /// Copy selected text to clipboard. - Copy, - /// Insert a newline at the cursor position. - Enter, - /// Move cursor one word to the left. - WordLeft, - /// Move cursor one word to the right. - WordRight, - /// Extend selection one word to the left. - SelectWordLeft, - /// Extend selection one word to the right. - SelectWordRight, - /// Undo the last edit. - Undo, - /// Redo the last undone edit. - Redo, - ] -); #[track_caller] pub fn input(input_state: &Entity, cx: &App) -> Input { Input::new(input_state, cx) } -pub fn input_bindings() -> gpui::ActionBindingCollection { - let mut bindings = gpui::ActionBindingCollection::default(); - - #[cfg(target_os = "macos")] - { - bindings = bindings - .with::("backspace") - .with::("delete") - .with::("alt-backspace") - .with::("alt-delete") - .with::("cmd-backspace") - .with::("ctrl-k") - .with::("tab") - .with::("enter") - .with::("left") - .with::("right") - .with::("up") - .with::("down") - .with::("shift-left") - .with::("shift-right") - .with::("shift-up") - .with::("shift-down") - .with::("cmd-a") - // Mac keyboards don't have Home/End keys, so cmd-left/right are standard - .with::("cmd-left") - .with::("cmd-right") - .with::("cmd-up") - .with::("cmd-down") - .with::("cmd-shift-up") - .with::("cmd-shift-down") - .with::("alt-left") - .with::("alt-right") - .with::("alt-shift-left") - .with::("alt-shift-right") - .with::("cmd-c") - .with::("cmd-x") - .with::("cmd-v") - .with::("cmd-z") - .with::("cmd-shift-z") - .with::("escape"); - } - - #[cfg(not(target_os = "macos"))] - { - bindings = bindings - .with::("backspace") - .with::("delete") - .with::("ctrl-backspace") - .with::("ctrl-delete") - .with::("ctrl-shift-backspace") - .with::("ctrl-shift-delete") - .with::("tab") - .with::("enter") - .with::("left") - .with::("right") - .with::("up") - .with::("down") - .with::("shift-left") - .with::("shift-right") - .with::("shift-up") - .with::("shift-down") - .with::("ctrl-a") - .with::("home") - .with::("end") - .with::("ctrl-home") - .with::("ctrl-end") - .with::("ctrl-shift-home") - .with::("ctrl-shift-end") - .with::("ctrl-left") - .with::("ctrl-right") - .with::("ctrl-shift-left") - .with::("ctrl-shift-right") - .with::("ctrl-c") - .with::("ctrl-x") - .with::("ctrl-v") - .with::("ctrl-z") - .with::("ctrl-shift-z") - .with::("escape"); - } - - bindings -} - -#[derive(Clone, Copy, Debug)] -struct PaintColors { - selection: Hsla, - cursor: Hsla, - placeholder: Hsla, -} - -impl Default for PaintColors { - fn default() -> Self { - Self { - selection: Hsla::blue().opacity(0.2), - cursor: Hsla::white().opacity(0.8), - placeholder: gpui::hsla(0.6, 0.6, 0.6, 1.0), - } - } -} - /// A text editing element that supports both single-line and multi-line modes. pub struct Input { - input: Entity, - interactivity: Interactivity, - placeholder: Option, - colors: PaintColors, - multiline: bool, + pub(super) input: Entity, + pub(super) interactivity: Interactivity, + pub(super) placeholder: Option, + pub(super) colors: PaintColors, + pub(super) multiline: bool, } impl Input { @@ -222,7 +31,7 @@ impl Input { }; input.register_actions(); input - .key_context(DEFAULT_INPUT_CONTEXT) + .key_context(super::actions::DEFAULT_INPUT_CONTEXT) .track_focus(&focus_handle) } @@ -313,7 +122,7 @@ impl Input { register_action(&mut self.interactivity, &self.input, InputState::redo); self.interactivity - .on_action::(|_action, window, _cx| { + .on_action::(|_action, window, _cx| { window.blur(); }); } @@ -349,1008 +158,6 @@ impl InteractiveElement for Input { } } -pub struct InputLayoutState { - text_style: TextStyle, -} - -pub struct InputPrepaintState { - hitbox: Option, -} - -impl Element for Input { - type RequestLayoutState = InputLayoutState; - type PrepaintState = InputPrepaintState; - - fn id(&self) -> Option { - self.interactivity.element_id.clone() - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - self.interactivity.source_location() - } - - fn request_layout( - &mut self, - global_id: Option<&GlobalElementId>, - inspector_id: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let mut resolved_text_style = None; - let multiline = self.multiline; - - let layout_id = self.interactivity.request_layout( - global_id, - inspector_id, - window, - cx, - |element_style, window, cx| { - window.with_text_style(element_style.text_style().cloned(), |window| { - resolved_text_style = Some(window.text_style()); - - let mut layout_style = element_style.clone(); - if multiline { - if let Length::Auto = layout_style.size.width { - layout_style.size.width = relative(1.).into(); - } - if let Length::Auto = layout_style.size.height { - layout_style.size.height = relative(1.).into(); - } - } - window.request_layout(layout_style, None, cx) - }) - }, - ); - - ( - layout_id, - InputLayoutState { - text_style: resolved_text_style.unwrap_or_else(|| window.text_style()), - }, - ) - } - - fn prepaint( - &mut self, - global_id: Option<&GlobalElementId>, - inspector_id: Option<&InspectorElementId>, - bounds: Bounds, - layout_state: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - let line_height = layout_state - .text_style - .line_height_in_pixels(window.rem_size()); - - let wrap_width = if self.multiline { - bounds.size.width - } else { - px(100000.) - }; - - self.input.update(cx, |input, _cx| { - input.available_height = bounds.size.height; - input.available_width = bounds.size.width; - input.update_line_layouts(wrap_width, line_height, &layout_state.text_style, window); - }); - - let hitbox = self.interactivity.prepaint( - global_id, - inspector_id, - bounds, - bounds.size, - window, - cx, - |_style, _point, hitbox, window, _cx| { - hitbox.or_else(|| Some(window.insert_hitbox(bounds, HitboxBehavior::Normal))) - }, - ); - - InputPrepaintState { hitbox } - } - - fn paint( - &mut self, - global_id: Option<&GlobalElementId>, - inspector_id: Option<&InspectorElementId>, - bounds: Bounds, - layout_state: &mut Self::RequestLayoutState, - prepaint_state: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - let focus_handle = self.input.focus_handle(cx); - - if let Some(hitbox) = &prepaint_state.hitbox { - window.set_cursor_style(CursorStyle::IBeam, hitbox); - } - - window.handle_input( - &focus_handle, - ElementInputHandler::new(bounds, self.input.clone()), - cx, - ); - - let input = self.input.clone(); - let placeholder = self.placeholder.clone(); - let text_style = layout_state.text_style.clone(); - let multiline = self.multiline; - let is_focused = focus_handle.is_focused(window); - let cursor_visible = self - .input - .update(cx, |input, cx| input.cursor_visible(is_focused, cx)); - - let colors = self.colors; - self.interactivity.paint( - global_id, - inspector_id, - bounds, - prepaint_state.hitbox.as_ref(), - window, - cx, - |_style, window, cx| { - handle_mouse(&input, bounds, multiline, window, cx); - - window.with_content_mask(Some(ContentMask { bounds }), |window| { - if multiline { - paint_multiline( - &input, - &focus_handle, - bounds, - &text_style, - placeholder.as_ref(), - &colors, - cursor_visible, - window, - cx, - ); - } else { - paint_singleline( - &input, - &focus_handle, - bounds, - &text_style, - placeholder.as_ref(), - &colors, - cursor_visible, - window, - cx, - ); - } - }); - }, - ); - } -} - -/// Registers all mouse event handlers for the input. -fn handle_mouse( - input: &Entity, - bounds: Bounds, - multiline: bool, - window: &mut Window, - cx: &App, -) { - mouse_down(input.clone(), bounds, multiline, window); - mouse_up(input.clone(), window); - mouse_move(input.clone(), bounds, multiline, window); - handle_scroll(input.clone(), bounds, multiline, window, cx); -} - -fn mouse_down( - input: Entity, - bounds: Bounds, - multiline: bool, - window: &mut Window, -) { - window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { - if phase != DispatchPhase::Bubble { - return; - } - if !bounds.contains(&event.position) { - return; - } - if event.button != MouseButton::Left { - return; - } - - input.update(cx, |input, cx| { - let text_position = - screen_to_text_position(event.position, bounds, input.scroll_offset, multiline); - input.on_mouse_down( - text_position, - event.click_count, - event.modifiers.shift, - window, - cx, - ); - }); - }); -} - -fn mouse_up(input: Entity, window: &mut Window) { - window.on_mouse_event(move |event: &MouseUpEvent, phase, _window, cx| { - if phase != DispatchPhase::Bubble { - return; - } - if event.button != MouseButton::Left { - return; - } - - input.update(cx, |input, cx| { - input.on_mouse_up(cx); - }); - }); -} - -fn mouse_move( - input: Entity, - bounds: Bounds, - multiline: bool, - window: &mut Window, -) { - window.on_mouse_event(move |event: &MouseMoveEvent, phase, _window, cx| { - if phase != DispatchPhase::Bubble { - return; - } - - input.update(cx, |input, cx| { - let text_position = - screen_to_text_position(event.position, bounds, input.scroll_offset, multiline); - input.on_mouse_move(text_position, cx); - }); - }); -} - -fn handle_scroll( - input: Entity, - bounds: Bounds, - multiline: bool, - window: &mut Window, - cx: &App, -) { - let max_scroll = if multiline { - let total_height = input.read(cx).total_content_height(); - (total_height - bounds.size.height).max(px(0.)) - } else { - let text_width = input - .read(cx) - .line_layouts - .first() - .and_then(|l| l.wrapped_line.as_ref()) - .map(|w| w.width()) - .unwrap_or(px(0.)); - (text_width - bounds.size.width).max(px(0.)) - }; - - window.on_mouse_event(move |event: &ScrollWheelEvent, phase, _window, cx| { - if phase != DispatchPhase::Bubble { - return; - } - if !bounds.contains(&event.position) { - return; - } - - let pixel_delta = event.delta.pixel_delta(px(20.)); - input.update(cx, |input, cx| { - if multiline { - input.scroll_offset = - (input.scroll_offset - pixel_delta.y).clamp(px(0.), max_scroll); - } else { - let delta = if pixel_delta.x.abs() > pixel_delta.y.abs() { - pixel_delta.x - } else { - pixel_delta.y - }; - input.scroll_offset = (input.scroll_offset - delta).clamp(px(0.), max_scroll); - } - cx.notify(); - }); - }); -} - -/// Converts a screen position to a position relative to the text area origin, -/// adjusted for scroll offset. -fn screen_to_text_position( - screen_position: Point, - bounds: Bounds, - scroll_offset: Pixels, - multiline: bool, -) -> Point { - if multiline { - point( - screen_position.x - bounds.origin.x, - screen_position.y - bounds.origin.y + scroll_offset, - ) - } else { - point( - screen_position.x - bounds.origin.x + scroll_offset, - screen_position.y - bounds.origin.y, - ) - } -} - -fn paint_multiline( - input: &Entity, - focus_handle: &FocusHandle, - bounds: Bounds, - text_style: &TextStyle, - placeholder: Option<&SharedString>, - colors: &PaintColors, - cursor_visible: bool, - window: &mut Window, - cx: &mut App, -) { - let input_state = input.read(cx); - let content = input_state.content().to_string(); - let selected_range = input_state.selected_range().clone(); - let marked_range = input_state.marked_range().cloned(); - let cursor_offset = input_state.cursor_offset(); - let line_layouts = input_state.line_layouts.clone(); - let scroll_offset = input_state.scroll_offset; - let line_height = input_state.line_height; - let is_focused = focus_handle.is_focused(window); - - if !selected_range.is_empty() { - paint_multiline_selection( - &line_layouts, - &selected_range, - bounds, - scroll_offset, - line_height, - colors.selection, - window, - ); - } - - if content.is_empty() { - if let Some(placeholder_str) = placeholder { - if !placeholder_str.is_empty() { - paint_placeholder( - placeholder_str, - bounds, - text_style, - colors.placeholder, - window, - cx, - false, - ); - } - } - } else { - paint_multiline_text( - &line_layouts, - bounds, - scroll_offset, - line_height, - window, - cx, - ); - } - - if let Some(marked_range) = &marked_range { - if !marked_range.is_empty() { - paint_multiline_marked_underline( - &line_layouts, - marked_range, - bounds, - scroll_offset, - line_height, - colors.cursor, - window, - ); - } - } - - if is_focused && selected_range.is_empty() && cursor_visible { - paint_multiline_cursor( - &line_layouts, - cursor_offset, - &content, - bounds, - scroll_offset, - line_height, - colors.cursor, - window, - ); - } -} - -fn is_line_visible( - line_y: Pixels, - line_height: Pixels, - visual_line_count: usize, - visible_height: Pixels, -) -> bool { - let line_bottom = line_y + line_height * visual_line_count as f32; - line_bottom >= px(0.) && line_y <= visible_height -} - -fn line_intersects_range( - text_range: &std::ops::Range, - selected_range: &std::ops::Range, -) -> bool { - if text_range.is_empty() { - selected_range.start <= text_range.start && selected_range.end > text_range.start - } else { - selected_range.end > text_range.start && selected_range.start < text_range.end - } -} - -fn compute_visual_line_index(y: Pixels, line_height: Pixels) -> usize { - (y / line_height).floor() as usize -} - -fn paint_multiline_selection( - line_layouts: &[InputLineLayout], - selected_range: &std::ops::Range, - bounds: Bounds, - scroll_offset: Pixels, - line_height: Pixels, - selection_color: Hsla, - window: &mut Window, -) { - for line in line_layouts { - let line_y = line.y_offset - scroll_offset; - - if !is_line_visible( - line_y, - line_height, - line.visual_line_count, - bounds.size.height, - ) { - continue; - } - - if !line_intersects_range(&line.text_range, selected_range) { - continue; - } - - if line.text_range.is_empty() { - let empty_line_selection_width = px(6.); - window.paint_quad(fill( - Bounds::from_corners( - point(bounds.left(), bounds.top() + line_y), - point( - bounds.left() + empty_line_selection_width, - bounds.top() + line_y + line_height, - ), - ), - selection_color, - )); - } else if let Some(wrapped) = &line.wrapped_line { - let line_start = line.text_range.start; - let line_end = line.text_range.end; - - let sel_start = selected_range.start.max(line_start) - line_start; - let sel_end = selected_range.end.min(line_end) - line_start; - - let start_pos = wrapped - .position_for_index(sel_start, line_height) - .unwrap_or(point(px(0.), px(0.))); - let end_pos = wrapped - .position_for_index(sel_end, line_height) - .unwrap_or_else(|| { - let last_line_y = line_height * (line.visual_line_count - 1) as f32; - point(wrapped.width(), last_line_y) - }); - - let start_visual_line = compute_visual_line_index(start_pos.y, line_height); - let end_visual_line = compute_visual_line_index(end_pos.y, line_height); - - if start_visual_line == end_visual_line { - window.paint_quad(fill( - Bounds::from_corners( - point( - bounds.left() + start_pos.x, - bounds.top() + line_y + start_pos.y, - ), - point( - bounds.left() + end_pos.x, - bounds.top() + line_y + start_pos.y + line_height, - ), - ), - selection_color, - )); - } else { - let line_width = wrapped.width(); - - // First visual line - window.paint_quad(fill( - Bounds::from_corners( - point( - bounds.left() + start_pos.x, - bounds.top() + line_y + start_pos.y, - ), - point( - bounds.left() + line_width, - bounds.top() + line_y + start_pos.y + line_height, - ), - ), - selection_color, - )); - - // Middle visual lines - for visual_line in (start_visual_line + 1)..end_visual_line { - let y = line_height * visual_line as f32; - window.paint_quad(fill( - Bounds::from_corners( - point(bounds.left(), bounds.top() + line_y + y), - point( - bounds.left() + line_width, - bounds.top() + line_y + y + line_height, - ), - ), - selection_color, - )); - } - - // Last visual line - window.paint_quad(fill( - Bounds::from_corners( - point(bounds.left(), bounds.top() + line_y + end_pos.y), - point( - bounds.left() + end_pos.x, - bounds.top() + line_y + end_pos.y + line_height, - ), - ), - selection_color, - )); - } - } - } -} - -fn paint_multiline_text( - line_layouts: &[InputLineLayout], - bounds: Bounds, - scroll_offset: Pixels, - line_height: Pixels, - window: &mut Window, - cx: &mut App, -) { - for line_layout in line_layouts { - let line_y = line_layout.y_offset - scroll_offset; - - if !is_line_visible( - line_y, - line_height, - line_layout.visual_line_count, - bounds.size.height, - ) { - continue; - } - - if let Some(wrapped) = &line_layout.wrapped_line { - let paint_pos = point(bounds.left(), bounds.top() + line_y); - let _ = wrapped.paint( - paint_pos, - line_height, - TextAlign::Left, - Some(bounds), - window, - cx, - ); - } - } -} - -fn paint_multiline_marked_underline( - line_layouts: &[InputLineLayout], - marked_range: &std::ops::Range, - bounds: Bounds, - scroll_offset: Pixels, - line_height: Pixels, - underline_color: Hsla, - window: &mut Window, -) { - let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); - let underline_offset = line_height - underline_thickness; - - for line in line_layouts { - let line_y = line.y_offset - scroll_offset; - - if !is_line_visible( - line_y, - line_height, - line.visual_line_count, - bounds.size.height, - ) { - continue; - } - - if !line_intersects_range(&line.text_range, marked_range) { - continue; - } - - if line.text_range.is_empty() { - continue; - } - - if let Some(wrapped) = &line.wrapped_line { - let line_start = line.text_range.start; - let line_end = line.text_range.end; - - let mark_start = marked_range.start.max(line_start) - line_start; - let mark_end = marked_range.end.min(line_end) - line_start; - - let start_pos = wrapped - .position_for_index(mark_start, line_height) - .unwrap_or(point(px(0.), px(0.))); - let end_pos = wrapped - .position_for_index(mark_end, line_height) - .unwrap_or_else(|| { - let last_line_y = line_height * (line.visual_line_count - 1) as f32; - point(wrapped.width(), last_line_y) - }); - - let start_visual_line = compute_visual_line_index(start_pos.y, line_height); - let end_visual_line = compute_visual_line_index(end_pos.y, line_height); - - if start_visual_line == end_visual_line { - window.paint_quad(fill( - Bounds::from_corners( - point( - bounds.left() + start_pos.x, - bounds.top() + line_y + start_pos.y + underline_offset, - ), - point( - bounds.left() + end_pos.x, - bounds.top() + line_y + start_pos.y + line_height, - ), - ), - underline_color, - )); - } else { - // First visual line - window.paint_quad(fill( - Bounds::from_corners( - point( - bounds.left() + start_pos.x, - bounds.top() + line_y + start_pos.y + underline_offset, - ), - point( - bounds.left() + wrapped.width(), - bounds.top() + line_y + start_pos.y + line_height, - ), - ), - underline_color, - )); - - // Middle visual lines - for visual_line in (start_visual_line + 1)..end_visual_line { - let y = line_height * visual_line as f32; - window.paint_quad(fill( - Bounds::from_corners( - point(bounds.left(), bounds.top() + line_y + y + underline_offset), - point( - bounds.left() + wrapped.width(), - bounds.top() + line_y + y + line_height, - ), - ), - underline_color, - )); - } - - // Last visual line - window.paint_quad(fill( - Bounds::from_corners( - point( - bounds.left(), - bounds.top() + line_y + end_pos.y + underline_offset, - ), - point( - bounds.left() + end_pos.x, - bounds.top() + line_y + end_pos.y + line_height, - ), - ), - underline_color, - )); - } - } - } -} - -fn paint_multiline_cursor( - line_layouts: &[InputLineLayout], - cursor_offset: usize, - _content: &str, - bounds: Bounds, - scroll_offset: Pixels, - line_height: Pixels, - cursor_color: Hsla, - window: &mut Window, -) { - for line in line_layouts.iter() { - let line_y = line.y_offset - scroll_offset; - - if !is_line_visible( - line_y, - line_height, - line.visual_line_count, - bounds.size.height, - ) { - continue; - } - - // Since range is non-inclusive of the end value we need to check for it explicitly - let is_cursor_in_line = if line.text_range.is_empty() { - cursor_offset == line.text_range.start - } else { - line.text_range.contains(&cursor_offset) || cursor_offset == line.text_range.end - }; - - if !is_cursor_in_line { - continue; - } - - let cursor_position = if let Some(wrapped) = &line.wrapped_line { - let local_offset = cursor_offset.saturating_sub(line.text_range.start); - wrapped - .position_for_index(local_offset, line_height) - .unwrap_or(point(px(0.), px(0.))) - } else { - point(px(0.), px(0.)) - }; - - window.paint_quad(fill( - Bounds::new( - point( - bounds.left() + cursor_position.x, - bounds.top() + line_y + cursor_position.y, - ), - size(px(CURSOR_WIDTH), line_height), - ), - cursor_color, - )); - break; - } -} - -/// State for single-line painting that pre-computes character positions. -struct SingleLinePaintState { - content: String, - selected_range: std::ops::Range, - marked_range: Option>, - cursor_offset: usize, - scroll_offset: Pixels, - line_height: Pixels, - text_width: Pixels, - is_focused: bool, - char_positions: Vec, - wrapped_line: Option>, -} - -impl SingleLinePaintState { - fn from_input( - input: &Entity, - focus_handle: &FocusHandle, - window: &Window, - cx: &App, - ) -> Self { - let input_state = input.read(cx); - - let mut char_positions = Vec::new(); - let mut text_width = px(0.); - - if let Some(line) = input_state.line_layouts.first() { - if let Some(wrapped) = &line.wrapped_line { - text_width = wrapped.width(); - let content = input_state.content(); - let mut idx = 0; - for ch in content.chars() { - if let Some(pos) = wrapped.position_for_index(idx, input_state.line_height) { - char_positions.push(pos.x); - } else { - char_positions.push(text_width); - } - idx += ch.len_utf8(); - } - char_positions.push(text_width); - } - } - - let wrapped_line = input_state - .line_layouts - .first() - .and_then(|l| l.wrapped_line.clone()); - - Self { - content: input_state.content().to_string(), - selected_range: input_state.selected_range().clone(), - marked_range: input_state.marked_range().cloned(), - cursor_offset: input_state.cursor_offset(), - scroll_offset: input_state.scroll_offset, - line_height: input_state.line_height, - text_width, - is_focused: focus_handle.is_focused(window), - char_positions, - wrapped_line, - } - } - - fn x_for_index(&self, index: usize) -> Pixels { - let char_index = self.content[..index.min(self.content.len())] - .chars() - .count(); - self.char_positions - .get(char_index) - .copied() - .unwrap_or(self.text_width) - } -} - -fn paint_singleline( - input: &Entity, - focus_handle: &FocusHandle, - bounds: Bounds, - text_style: &TextStyle, - placeholder: Option<&SharedString>, - colors: &PaintColors, - cursor_visible: bool, - window: &mut Window, - cx: &mut App, -) { - let state = SingleLinePaintState::from_input(input, focus_handle, window, cx); - - if !state.selected_range.is_empty() { - paint_singleline_selection(&state, bounds, colors.selection, window); - } - - if state.content.is_empty() { - if let Some(placeholder_str) = placeholder { - if !placeholder_str.is_empty() { - paint_placeholder( - placeholder_str, - bounds, - text_style, - colors.placeholder, - window, - cx, - true, - ); - } - } - } else { - paint_singleline_text(&state, bounds, window, cx); - } - - if let Some(marked_range) = &state.marked_range { - if !marked_range.is_empty() { - paint_singleline_marked_underline(&state, marked_range, bounds, colors.cursor, window); - } - } - - if state.is_focused && state.selected_range.is_empty() && cursor_visible { - paint_singleline_cursor(&state, bounds, colors.cursor, window); - } -} - -fn paint_singleline_selection( - state: &SingleLinePaintState, - bounds: Bounds, - selection_color: Hsla, - window: &mut Window, -) { - let start_x = state.x_for_index(state.selected_range.start) - state.scroll_offset; - let end_x = state.x_for_index(state.selected_range.end) - state.scroll_offset; - - let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; - - window.paint_quad(fill( - Bounds::from_corners( - point(bounds.left() + start_x, bounds.top() + y_offset), - point( - bounds.left() + end_x, - bounds.top() + y_offset + state.line_height, - ), - ), - selection_color, - )); -} - -fn paint_placeholder( - placeholder: &SharedString, - bounds: Bounds, - text_style: &TextStyle, - color: Hsla, - window: &mut Window, - cx: &mut App, - baseline: bool, -) { - let run = TextRun { - len: placeholder.len(), - font: text_style.font(), - color, - background_color: None, - underline: None, - strikethrough: None, - }; - - let font_size = text_style.font_size.to_pixels(window.rem_size()); - let shaped_line = window - .text_system() - .shape_line(placeholder.clone(), font_size, &[run], None); - let line_height = text_style.line_height_in_pixels(window.rem_size()); - - let mut paint_origin = bounds.origin; - if baseline { - let y_offset = (bounds.size.height - line_height).max(px(0.)) / 2.0; - paint_origin.y += y_offset; - } - - let _ = shaped_line.paint(paint_origin, line_height, TextAlign::Left, None, window, cx); -} - -fn paint_singleline_text( - state: &SingleLinePaintState, - bounds: Bounds, - window: &mut Window, - cx: &mut App, -) { - let Some(wrapped_line) = &state.wrapped_line else { - return; - }; - - let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; - let paint_origin = point( - bounds.origin.x - state.scroll_offset, - bounds.origin.y + y_offset, - ); - - let _ = wrapped_line.paint( - paint_origin, - state.line_height, - TextAlign::Left, - Some(bounds), - window, - cx, - ); -} - -fn paint_singleline_marked_underline( - state: &SingleLinePaintState, - marked_range: &std::ops::Range, - bounds: Bounds, - underline_color: Hsla, - window: &mut Window, -) { - let start_x = state.x_for_index(marked_range.start) - state.scroll_offset; - let end_x = state.x_for_index(marked_range.end) - state.scroll_offset; - - let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); - let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; - let underline_y = bounds.top() + y_offset + state.line_height - underline_thickness; - - window.paint_quad(fill( - Bounds::from_corners( - point(bounds.left() + start_x, underline_y), - point(bounds.left() + end_x, underline_y + underline_thickness), - ), - underline_color, - )); -} - -fn paint_singleline_cursor( - state: &SingleLinePaintState, - bounds: Bounds, - cursor_color: Hsla, - window: &mut Window, -) { - let cursor_x = state.x_for_index(state.cursor_offset) - state.scroll_offset; - - let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; - - window.paint_quad(fill( - Bounds::new( - point(bounds.left() + cursor_x, bounds.top() + y_offset), - size(px(CURSOR_WIDTH), state.line_height), - ), - cursor_color, - )); -} - impl Focusable for Input { fn focus_handle(&self, cx: &App) -> FocusHandle { self.input.focus_handle(cx) @@ -1364,1650 +171,3 @@ impl IntoElement for Input { self } } - -/// Default interval for grouping consecutive edits into a single undo entry. -const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300); - -/// Maximum number of history entries to keep. -const MAX_HISTORY_LEN: usize = 1000; - -/// Events emitted by InputState when significant changes occur. -#[derive(Clone, Debug)] -pub enum InputStateEvent { - /// Emitted when the input gains focus. - Focus, - /// Emitted when the input loses focus. - Blur, - /// Emitted when the text content changes. - TextChanged, - /// Emitted when an undo operation is performed. - Undo, - /// Emitted when a redo operation is performed. - Redo, -} - -impl EventEmitter for InputState {} - -/// A patch-based history entry for memory-efficient undo/redo operations. -/// Instead of storing the full content, we store only the change needed to reverse the edit. -#[derive(Clone, Debug)] -struct HistoryEntry { - /// The byte range that was modified (after the edit, for undo; before the edit, for redo). - range: Range, - /// The text that was replaced (to restore on undo). - old_text: String, - /// The length of the new text that replaced old_text (to know how much to remove on undo). - new_text_len: usize, - /// The selection range before the edit. - selected_range: Range, - /// Whether the selection was reversed before the edit. - selection_reversed: bool, - /// Timestamp for grouping consecutive edits. - timestamp: Instant, -} - -impl HistoryEntry { - /// Apply this patch to undo an edit, returning the reverse patch for redo. - fn apply_undo(&self, content: &mut String) -> HistoryEntry { - let undo_start = self.range.start; - let undo_end = (self.range.start + self.new_text_len).min(content.len()); - - // Capture what we're about to remove (the "new" text that was inserted) - let removed_text = content[undo_start..undo_end].to_string(); - - // Replace with the old text - content.replace_range(undo_start..undo_end, &self.old_text); - - // Return reverse patch for redo - HistoryEntry { - range: undo_start..undo_start + self.old_text.len(), - old_text: removed_text, - new_text_len: self.old_text.len(), - selected_range: self.selected_range.clone(), - selection_reversed: self.selection_reversed, - timestamp: self.timestamp, - } - } - - /// Apply this patch to redo an edit, returning the reverse patch for undo. - fn apply_redo(&self, content: &mut String) -> HistoryEntry { - // Redo is the same operation as undo - we're reversing the undo - self.apply_undo(content) - } -} - -/// `Input` is the state model for text input components. It handles: -/// - Text content storage and manipulation -/// - Selection and cursor management -/// - Keyboard navigation and editing actions -/// - IME (Input Method Editor) support via `EntityInputHandler` -/// ``` -pub struct InputState { - entity_id: EntityId, - focus_handle: FocusHandle, - content: String, - placeholder: SharedString, - selected_range: Range, - selection_reversed: bool, - marked_range: Option>, - pub(crate) line_height: Pixels, - pub(crate) line_layouts: Vec, - pub(crate) wrap_width: Option, - pub(crate) text_style: Option, - pub(crate) needs_layout: bool, - is_selecting: bool, - last_click_position: Option>, - click_count: usize, - /// Scroll offset - vertical for multiline, horizontal for single-line - pub(crate) scroll_offset: Pixels, - pub(crate) available_height: Pixels, - pub(crate) available_width: Pixels, - multiline: bool, - /// Stack of previous states for undo. - undo_stack: Vec, - /// Stack of undone states for redo. - redo_stack: Vec, - /// Optional entity and subscription tracking the blinking of the text cursor. - cursor_blink: Option<(Entity, Subscription)>, - /// Tracks whether we were focused on the last update. - was_focused: bool, - /// Cached UTF-16 length of content for faster IME operations. - /// Lazily computed when None. - cached_utf16_len: Option, -} - -/// Layout information for a single logical line of text in an input. -/// -/// A logical line corresponds to content between newlines in the input text. -/// When text wrapping is enabled, a logical line may span multiple visual lines. -#[derive(Clone, Debug)] -pub struct InputLineLayout { - /// The 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 vertical offset from the top of the text area in pixels. - pub y_offset: Pixels, - /// The number of visual lines this logical line spans (due to wrapping). - pub visual_line_count: usize, -} - -pub enum CursorBlinkType<'app> { - Disabled, - Enabled { - app: &'app mut App, - interval: Option, - }, -} - -impl InputState { - /// Creates a new `Input` with the specified multiline setting. - /// Cursor blinking is enabled by default. - pub fn new(cx: &mut Context) -> Self { - let mut this = Self { - entity_id: cx.entity_id(), - focus_handle: cx.focus_handle(), - content: String::new(), - placeholder: SharedString::default(), - selected_range: 0..0, - selection_reversed: false, - marked_range: None, - line_height: px(0.), - line_layouts: Vec::new(), - wrap_width: None, - text_style: None, - needs_layout: true, - is_selecting: false, - last_click_position: None, - click_count: 0, - scroll_offset: px(0.), - available_height: px(0.), - available_width: px(0.), - multiline: false, - undo_stack: Vec::new(), - cached_utf16_len: None, - redo_stack: Vec::new(), - cursor_blink: None, - was_focused: false, - }; - this = this.cursor_blink(CursorBlinkType::Enabled { - app: cx, - interval: None, - }); - this - } - - pub fn cursor_blink<'app>(mut self, args: CursorBlinkType<'app>) -> Self { - self.cursor_blink = match args { - CursorBlinkType::Disabled => None, - CursorBlinkType::Enabled { app: cx, interval } => { - let interval = interval.unwrap_or(DEFAULT_BLINK_INTERVAL); - let cursor_blink = cx.new(|cx| super::CursorBlink::new(interval, cx)); - let entity_id = self.entity_id; - let subscription = cx.observe(&cursor_blink, move |_, cx| cx.notify(entity_id)); - Some((cursor_blink, subscription)) - } - }; - self - } - - /// Returns whether the cursor should be visible (for blinking). - /// - /// If blinking is not enabled, always returns `true`. - /// This method also updates the blink manager's enabled state based on focus. - pub fn cursor_visible(&mut self, is_focused: bool, cx: &mut Context) -> bool { - // Update cursor blink based on focus changes - if let Some((cursor_blink, _)) = &self.cursor_blink { - if is_focused && !self.was_focused { - cursor_blink.update(cx, |cb, cx| cb.enable(cx)); - cx.emit(InputStateEvent::Focus); - } else if !is_focused && self.was_focused { - cursor_blink.update(cx, |cb, cx| cb.disable(cx)); - cx.emit(InputStateEvent::Blur); - } - } - self.was_focused = is_focused; - - self.cursor_blink - .as_ref() - .map(|(cb, _)| cb.read(cx).visible()) - .unwrap_or(true) - } - - /// Pauses cursor blinking temporarily (e.g., during typing). - fn pause_cursor_blink(&self, cx: &mut Context) { - if let Some((cursor_blink, _)) = &self.cursor_blink { - cursor_blink.update(cx, |cb, cx| cb.pause_blinking(cx)); - } - } - - /// Sets the text style used for layout. Marks layout as dirty if the style changed. - pub(crate) fn set_text_style(&mut self, style: &TextStyle) { - let changed = self - .text_style - .as_ref() - .map_or(true, |current| current != style); - - if changed { - self.text_style = Some(style.clone()); - self.needs_layout = true; - } - } - - /// Returns the current text content. - pub fn content(&self) -> &str { - &self.content - } - - /// Sets the text content, resetting selection to the beginning. - /// This clears the undo/redo history. - pub fn set_content(&mut self, content: impl Into, cx: &mut Context) { - let content = content.into(); - self.content = if self.multiline { - content - } else { - // Strip newlines for single-line input - content.replace('\n', " ").replace('\r', "") - }; - self.selected_range = 0..0; - self.selection_reversed = false; - self.marked_range = None; - self.needs_layout = true; - self.undo_stack.clear(); - self.redo_stack.clear(); - self.cached_utf16_len = None; - self.pause_cursor_blink(cx); - cx.emit(InputStateEvent::TextChanged); - cx.notify(); - } - - /// Returns whether undo is available. - pub fn can_undo(&self) -> bool { - !self.undo_stack.is_empty() - } - - /// Returns whether redo is available. - pub fn can_redo(&self) -> bool { - !self.redo_stack.is_empty() - } - - /// Records a patch for undo. Called before making changes to content. - /// Returns true if a new entry was created, false if grouped with previous. - fn push_undo_patch(&mut self, range: Range, new_text_len: usize) { - // Don't record during IME composition - if self.marked_range.is_some() { - return; - } - - let now = Instant::now(); - - // Check if we should group with the last entry - if let Some(last) = self.undo_stack.last() { - if now.duration_since(last.timestamp) < DEFAULT_GROUP_INTERVAL { - // Within group interval - extend the existing patch - // We need to merge this edit with the previous one - return; - } - } - - // Capture the text that will be replaced - let old_text = self.content[range.clone()].to_string(); - - self.undo_stack.push(HistoryEntry { - range: range.start..range.start + new_text_len, - old_text, - new_text_len, - selected_range: self.selected_range.clone(), - selection_reversed: self.selection_reversed, - timestamp: now, - }); - - // Limit history size - if self.undo_stack.len() > MAX_HISTORY_LEN { - self.undo_stack.remove(0); - } - - // New edit invalidates redo stack - self.redo_stack.clear(); - } - - /// Undoes the last edit by applying the reverse patch. - pub(crate) fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context) { - if let Some(entry) = self.undo_stack.pop() { - // Remember selection to restore - let selected_range = entry.selected_range.clone(); - let selection_reversed = entry.selection_reversed; - - // Apply the undo patch and get the redo patch - let redo_entry = entry.apply_undo(&mut self.content); - self.redo_stack.push(redo_entry); - - // Restore selection state - self.selected_range = selected_range; - self.selection_reversed = selection_reversed; - self.needs_layout = true; - self.cached_utf16_len = None; - self.scroll_to_cursor(); - cx.emit(InputStateEvent::Undo); - cx.notify(); - } - } - - /// Redoes the last undone edit by applying the forward patch. - pub(crate) fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context) { - if let Some(entry) = self.redo_stack.pop() { - // Apply the redo patch and get the undo patch - let undo_entry = entry.apply_redo(&mut self.content); - - // The undo entry contains the selection state after the original edit - // We need to restore cursor to end of inserted text - let cursor_pos = undo_entry.range.start; - self.selected_range = cursor_pos..cursor_pos; - self.selection_reversed = false; - - self.undo_stack.push(undo_entry); - self.needs_layout = true; - self.cached_utf16_len = None; - self.scroll_to_cursor(); - cx.emit(InputStateEvent::Redo); - cx.notify(); - } - } - - /// Returns the placeholder text shown when content is empty. - pub fn placeholder(&self) -> &SharedString { - &self.placeholder - } - - /// Sets the placeholder text. - pub fn set_placeholder( - &mut self, - placeholder: impl Into, - cx: &mut Context, - ) { - self.placeholder = placeholder.into(); - cx.notify(); - } - - /// Returns the current selection range. - pub fn selected_range(&self) -> &Range { - &self.selected_range - } - - /// Returns true if the selection is reversed (cursor at start). - pub fn selection_reversed(&self) -> bool { - self.selection_reversed - } - - /// Returns the current cursor offset. - pub fn cursor_offset(&self) -> usize { - if self.selection_reversed { - self.selected_range.start - } else { - self.selected_range.end - } - } - - /// Returns the marked text range (for IME composition). - pub fn marked_range(&self) -> Option<&Range> { - self.marked_range.as_ref() - } - - /// Sets the selection range directly. - pub fn set_selected_range(&mut self, range: Range) { - let range = range.start.min(self.content.len())..range.end.min(self.content.len()); - self.selected_range = range; - self.selection_reversed = false; - } - - /// Returns the selected text range in UTF-16 offsets (for IME). - pub fn selected_text_range_utf16(&self) -> Range { - self.range_to_utf16(&self.selected_range) - } - - /// Inserts text at the current cursor position, replacing any selection. - pub fn insert_text(&mut self, text: &str, cx: &mut Context) { - let range = self - .marked_range - .clone() - .unwrap_or(self.selected_range.clone()); - let range = range.start.min(self.content.len())..range.end.min(self.content.len()); - - let sanitized_text; - let text_to_insert = if self.multiline { - text - } else { - sanitized_text = text.replace('\n', " ").replace('\r', ""); - &sanitized_text - }; - - // Record patch for undo before modifying content - self.push_undo_patch(range.clone(), text_to_insert.len()); - - // Update cached UTF-16 length incrementally if available - if let Some(cached_len) = self.cached_utf16_len { - let removed_utf16_len: usize = self.content[range.clone()] - .chars() - .map(|c| c.len_utf16()) - .sum(); - let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum(); - self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); - } - - self.content.replace_range(range.clone(), text_to_insert); - self.selected_range = - range.start + text_to_insert.len()..range.start + text_to_insert.len(); - self.marked_range.take(); - self.needs_layout = true; - self.pause_cursor_blink(cx); - cx.emit(InputStateEvent::TextChanged); - cx.notify(); - } - - /// Deletes the character before the cursor (convenience method for benchmarks). - pub fn delete_backward(&mut self, cx: &mut Context) { - if self.selected_range.is_empty() { - self.select_to(self.previous_boundary(self.cursor_offset()), cx); - } - self.insert_text("", cx); - } - - /// Undoes the last edit (convenience method without Window). - pub fn undo_action(&mut self, cx: &mut Context) { - if let Some(entry) = self.undo_stack.pop() { - let selected_range = entry.selected_range.clone(); - let selection_reversed = entry.selection_reversed; - - let redo_entry = entry.apply_undo(&mut self.content); - self.redo_stack.push(redo_entry); - - self.selected_range = selected_range; - self.selection_reversed = selection_reversed; - self.needs_layout = true; - self.cached_utf16_len = None; - self.scroll_to_cursor(); - cx.emit(InputStateEvent::Undo); - cx.notify(); - } - } - - /// Redoes the last undone edit (convenience method without Window). - pub fn redo_action(&mut self, cx: &mut Context) { - if let Some(entry) = self.redo_stack.pop() { - let undo_entry = entry.apply_redo(&mut self.content); - - let cursor_pos = undo_entry.range.start; - self.selected_range = cursor_pos..cursor_pos; - self.selection_reversed = false; - - self.undo_stack.push(undo_entry); - self.needs_layout = true; - self.cached_utf16_len = None; - self.scroll_to_cursor(); - cx.emit(InputStateEvent::Redo); - cx.notify(); - } - } - - /// Selects all text. - pub fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { - self.selected_range = 0..self.content.len(); - self.selection_reversed = false; - cx.notify(); - } - - pub(crate) fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context) { - if self.selected_range.is_empty() { - let new_pos = self.previous_boundary(self.cursor_offset()); - self.move_to(new_pos, cx); - } else { - self.move_to(self.selected_range.start, cx); - } - } - - pub(crate) fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context) { - if self.selected_range.is_empty() { - let new_pos = self.next_boundary(self.cursor_offset()); - self.move_to(new_pos, cx); - } else { - self.move_to(self.selected_range.end, cx); - } - } - - pub(crate) fn up(&mut self, _: &Up, _window: &mut Window, cx: &mut Context) { - self.pause_cursor_blink(cx); - if !self.multiline { - // In single-line mode, up moves to start - self.selected_range = 0..0; - self.selection_reversed = false; - self.scroll_to_cursor(); - cx.notify(); - return; - } - if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) { - self.selected_range = new_offset..new_offset; - self.selection_reversed = false; - self.scroll_to_cursor(); - cx.notify(); - } - } - - pub(crate) fn down(&mut self, _: &Down, _window: &mut Window, cx: &mut Context) { - self.pause_cursor_blink(cx); - if !self.multiline { - // In single-line mode, down moves to end - let end = self.content.len(); - self.selected_range = end..end; - self.selection_reversed = false; - self.scroll_to_cursor(); - cx.notify(); - return; - } - if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) { - self.selected_range = new_offset..new_offset; - self.selection_reversed = false; - self.scroll_to_cursor(); - cx.notify(); - } - } - - pub(crate) fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { - self.select_to(self.previous_boundary(self.cursor_offset()), cx); - } - - pub(crate) fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { - self.select_to(self.next_boundary(self.cursor_offset()), cx); - } - - pub(crate) fn select_up(&mut self, _: &SelectUp, _window: &mut Window, cx: &mut Context) { - self.pause_cursor_blink(cx); - if !self.multiline { - // In single-line mode, select_up selects to start - self.select_to(0, cx); - return; - } - if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) { - if self.selection_reversed { - self.selected_range.start = new_offset; - } else { - self.selected_range.end = new_offset; - } - if self.selected_range.end < self.selected_range.start { - self.selection_reversed = !self.selection_reversed; - self.selected_range = self.selected_range.end..self.selected_range.start; - } - self.scroll_to_cursor(); - cx.notify(); - } - } - - pub(crate) fn select_down( - &mut self, - _: &SelectDown, - _window: &mut Window, - cx: &mut Context, - ) { - self.pause_cursor_blink(cx); - if !self.multiline { - // In single-line mode, select_down selects to end - self.select_to(self.content.len(), cx); - return; - } - if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) { - if self.selection_reversed { - self.selected_range.start = new_offset; - } else { - self.selected_range.end = new_offset; - } - if self.selected_range.end < self.selected_range.start { - self.selection_reversed = !self.selection_reversed; - self.selected_range = self.selected_range.end..self.selected_range.start; - } - self.scroll_to_cursor(); - cx.notify(); - } - } - - pub(crate) fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context) { - let line_start = self.find_line_start(self.cursor_offset()); - self.move_to(line_start, cx); - } - - pub(crate) fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context) { - let line_end = self.find_line_end(self.cursor_offset()); - self.move_to(line_end, cx); - } - - pub(crate) fn move_to_beginning( - &mut self, - _: &MoveToBeginning, - _: &mut Window, - cx: &mut Context, - ) { - self.move_to(0, cx); - } - - pub(crate) fn move_to_end(&mut self, _: &MoveToEnd, _: &mut Window, cx: &mut Context) { - self.move_to(self.content.len(), cx); - } - - pub(crate) fn select_to_beginning( - &mut self, - _: &SelectToBeginning, - _: &mut Window, - cx: &mut Context, - ) { - self.select_to(0, cx); - } - - pub(crate) fn select_to_end( - &mut self, - _: &SelectToEnd, - _: &mut Window, - cx: &mut Context, - ) { - self.select_to(self.content.len(), cx); - } - - pub(crate) fn word_left(&mut self, _: &WordLeft, _: &mut Window, cx: &mut Context) { - let new_pos = self.previous_word_boundary(self.cursor_offset()); - self.move_to(new_pos, cx); - } - - pub(crate) fn word_right(&mut self, _: &WordRight, _: &mut Window, cx: &mut Context) { - let new_pos = self.next_word_boundary(self.cursor_offset()); - self.move_to(new_pos, cx); - } - - pub(crate) fn select_word_left( - &mut self, - _: &SelectWordLeft, - _: &mut Window, - cx: &mut Context, - ) { - let new_pos = self.previous_word_boundary(self.cursor_offset()); - self.select_to(new_pos, cx); - } - - pub(crate) fn select_word_right( - &mut self, - _: &SelectWordRight, - _: &mut Window, - cx: &mut Context, - ) { - let new_pos = self.next_word_boundary(self.cursor_offset()); - self.select_to(new_pos, cx); - } - - pub(crate) fn enter(&mut self, _: &Enter, window: &mut Window, cx: &mut Context) { - if self.multiline { - self.replace_text_in_range(None, "\n", window, cx); - } - } - - pub(crate) fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { - self.replace_text_in_range(None, "\t", window, cx); - } - - pub(crate) fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { - if self.selected_range.is_empty() { - self.select_to(self.previous_boundary(self.cursor_offset()), cx); - } - self.replace_text_in_range(None, "", window, cx); - } - - pub(crate) fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { - if self.selected_range.is_empty() { - self.select_to(self.next_boundary(self.cursor_offset()), cx); - } - self.replace_text_in_range(None, "", window, cx); - } - - pub(crate) fn delete_word_left( - &mut self, - _: &DeleteWordLeft, - window: &mut Window, - cx: &mut Context, - ) { - if self.selected_range.is_empty() { - self.select_to(self.previous_word_boundary(self.cursor_offset()), cx); - } - self.replace_text_in_range(None, "", window, cx); - } - - pub(crate) fn delete_word_right( - &mut self, - _: &DeleteWordRight, - window: &mut Window, - cx: &mut Context, - ) { - if self.selected_range.is_empty() { - self.select_to(self.next_word_boundary(self.cursor_offset()), cx); - } - self.replace_text_in_range(None, "", window, cx); - } - - pub(crate) fn delete_to_beginning_of_line( - &mut self, - _: &DeleteToBeginningOfLine, - window: &mut Window, - cx: &mut Context, - ) { - if self.selected_range.is_empty() { - self.select_to(self.find_line_start(self.cursor_offset()), cx); - } - self.replace_text_in_range(None, "", window, cx); - } - - pub(crate) fn delete_to_end_of_line( - &mut self, - _: &DeleteToEndOfLine, - window: &mut Window, - cx: &mut Context, - ) { - if self.selected_range.is_empty() { - self.select_to(self.find_line_end(self.cursor_offset()), cx); - } - self.replace_text_in_range(None, "", window, cx); - } - - pub(crate) fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { - if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { - if self.multiline { - self.replace_text_in_range(None, &text, window, cx); - } else { - // Strip newlines for single-line input - let text = text.replace('\n', " ").replace('\r', ""); - self.replace_text_in_range(None, &text, window, cx); - } - } - } - - pub(crate) fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { - if !self.selected_range.is_empty() { - cx.write_to_clipboard(ClipboardItem::new_string( - self.content[self.selected_range.clone()].to_string(), - )); - } - } - - pub(crate) fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { - if !self.selected_range.is_empty() { - // Cut selected text - cx.write_to_clipboard(ClipboardItem::new_string( - self.content[self.selected_range.clone()].to_string(), - )); - self.replace_text_in_range(None, "", window, cx); - } else { - // No selection: cut the entire current line (including newline) - let cursor = self.cursor_offset(); - let line_start = self.find_line_start(cursor); - let line_end = self.find_line_end(cursor); - - // Include the newline character if there is one after the line - let cut_end = if line_end < self.content.len() { - line_end + 1 // Include the newline - } else if line_start > 0 { - // Last line with no trailing newline - include preceding newline instead - line_end - } else { - line_end - }; - - // For last line, also remove the preceding newline if it exists - let cut_start = if line_end >= self.content.len() && line_start > 0 { - line_start - 1 // Include preceding newline for last line - } else { - line_start - }; - - let line_text = self.content[cut_start..cut_end].to_string(); - cx.write_to_clipboard(ClipboardItem::new_string(line_text)); - - self.selected_range = cut_start..cut_end; - self.replace_text_in_range(None, "", window, cx); - } - } - - pub(crate) fn on_mouse_down( - &mut self, - position: Point, - click_count: usize, - shift: bool, - window: &mut Window, - cx: &mut Context, - ) { - window.focus(&self.focus_handle, cx); - self.is_selecting = true; - - let is_same_position = self - .last_click_position - .map(|last| { - let threshold = px(4.); - (position.x - last.x).abs() < threshold && (position.y - last.y).abs() < threshold - }) - .unwrap_or(false); - - if is_same_position && click_count > 1 { - self.click_count = click_count; - } else { - self.click_count = 1; - } - self.last_click_position = Some(position); - - let clicked_offset = self.index_for_position(position); - - match self.click_count { - 2 => { - let (word_start, word_end) = self.word_range_at(clicked_offset); - self.selected_range = word_start..word_end; - self.selection_reversed = false; - cx.notify(); - } - 3 => { - let line_start = self.find_line_start(clicked_offset); - let line_end = self.find_line_end(clicked_offset); - let line_end_with_newline = if line_end < self.content.len() { - line_end + 1 - } else { - line_end - }; - self.selected_range = line_start..line_end_with_newline; - self.selection_reversed = false; - cx.notify(); - } - _ => { - if shift { - self.select_to(clicked_offset, cx); - } else { - self.move_to(clicked_offset, cx); - } - } - } - } - - pub(crate) fn on_mouse_up(&mut self, _cx: &mut Context) { - self.is_selecting = false; - } - - pub(crate) fn on_mouse_move(&mut self, position: Point, cx: &mut Context) { - if self.is_selecting && self.click_count == 1 { - self.select_to(self.index_for_position(position), cx); - } - } - - fn move_to(&mut self, offset: usize, cx: &mut Context) { - self.pause_cursor_blink(cx); - let offset = offset.min(self.content.len()); - self.selected_range = offset..offset; - self.selection_reversed = false; - self.scroll_to_cursor(); - cx.notify(); - } - - fn select_to(&mut self, offset: usize, cx: &mut Context) { - self.pause_cursor_blink(cx); - let offset = offset.min(self.content.len()); - if self.selection_reversed { - self.selected_range.start = offset; - } else { - self.selected_range.end = offset; - } - if self.selected_range.end < self.selected_range.start { - self.selection_reversed = !self.selection_reversed; - self.selected_range = self.selected_range.end..self.selected_range.start; - } - self.scroll_to_cursor(); - cx.notify(); - } - - pub(crate) fn find_line_start(&self, offset: usize) -> usize { - self.content[..offset.min(self.content.len())] - .rfind('\n') - .map(|pos| pos + 1) - .unwrap_or(0) - } - - pub(crate) fn find_line_end(&self, offset: usize) -> usize { - self.content[offset.min(self.content.len())..] - .find('\n') - .map(|pos| offset + pos) - .unwrap_or(self.content.len()) - } - - fn move_vertically(&self, offset: usize, direction: i32) -> Option { - let (visual_line_idx, x_pixels) = self.find_visual_line_and_x_offset(offset); - let target_visual_line_idx = (visual_line_idx as i32 + direction).max(0) as usize; - - let mut current_visual_line = 0; - for layout in self.line_layouts.iter() { - let visual_lines_in_layout = layout.visual_line_count; - - if target_visual_line_idx < current_visual_line + visual_lines_in_layout { - let visual_line_within_layout = target_visual_line_idx - current_visual_line; - - if layout.text_range.is_empty() { - return Some(layout.text_range.start); - } - - if let Some(wrapped) = &layout.wrapped_line { - let y_within_wrapped = self.line_height * visual_line_within_layout as f32; - let target_point = point(px(x_pixels), y_within_wrapped); - - let closest_result = - wrapped.closest_index_for_position(target_point, self.line_height); - - let closest_idx = closest_result.unwrap_or_else(|closest| closest); - let clamped = closest_idx.min(wrapped.text.len()); - let result = layout.text_range.start + clamped; - - return Some(result); - } - - return Some(layout.text_range.start); - } - - current_visual_line += visual_lines_in_layout; - } - - if direction > 0 { - Some(self.content.len()) - } else { - None - } - } - - fn find_visual_line_and_x_offset(&self, offset: usize) -> (usize, f32) { - if self.line_layouts.is_empty() { - return (0, 0.0); - } - - let mut visual_line_idx = 0; - - for line in &self.line_layouts { - if line.text_range.is_empty() { - if offset == line.text_range.start { - return (visual_line_idx, 0.0); - } - } else if offset >= line.text_range.start && offset <= line.text_range.end { - if let Some(wrapped) = &line.wrapped_line { - let local_offset = (offset - line.text_range.start).min(wrapped.text.len()); - if let Some(position) = - wrapped.position_for_index(local_offset, self.line_height) - { - let visual_line_within = (position.y / self.line_height).floor() as usize; - return (visual_line_idx + visual_line_within, position.x.into()); - } - } - return (visual_line_idx, 0.0); - } - visual_line_idx += line.visual_line_count; - } - - (visual_line_idx.saturating_sub(1), 0.0) - } - - pub(crate) fn index_for_position(&self, position: Point) -> usize { - if self.content.is_empty() { - return 0; - } - - for line in self.line_layouts.iter() { - let line_height_total = self.line_height * line.visual_line_count as f32; - - if position.y >= line.y_offset && position.y < line.y_offset + line_height_total { - if line.text_range.is_empty() { - return line.text_range.start; - } - - if let Some(wrapped) = &line.wrapped_line { - let relative_y = position.y - line.y_offset; - let relative_point = point(position.x, relative_y); - - let closest_result = - wrapped.closest_index_for_position(relative_point, self.line_height); - - let local_idx = closest_result.unwrap_or_else(|closest| closest); - let clamped = local_idx.min(wrapped.text.len()); - return line.text_range.start + clamped; - } - return line.text_range.start; - } - } - - self.content.len() - } - - pub(crate) fn scroll_to_cursor(&mut self) { - if self.line_layouts.is_empty() { - return; - } - - let cursor_offset = self.cursor_offset(); - - if self.multiline { - self.scroll_to_cursor_vertical(cursor_offset); - } else { - self.scroll_to_cursor_horizontal(cursor_offset); - } - } - - fn scroll_to_cursor_vertical(&mut self, cursor_offset: usize) { - if self.available_height <= px(0.) { - return; - } - - let line_height = self.line_height; - - for line in &self.line_layouts { - let is_cursor_in_line = if line.text_range.is_empty() { - cursor_offset == line.text_range.start - } else { - line.text_range.contains(&cursor_offset) - || (cursor_offset == line.text_range.end && cursor_offset == self.content.len()) - }; - - if is_cursor_in_line { - let cursor_visual_y = if let Some(wrapped) = &line.wrapped_line { - let local_offset = cursor_offset.saturating_sub(line.text_range.start); - if let Some(position) = - wrapped.position_for_index(local_offset, self.line_height) - { - line.y_offset + position.y - } else { - line.y_offset - } - } else { - line.y_offset - }; - - let visible_top = self.scroll_offset; - let visible_bottom = self.scroll_offset + self.available_height; - - if cursor_visual_y < visible_top { - self.scroll_offset = cursor_visual_y; - } else if cursor_visual_y + line_height > visible_bottom { - self.scroll_offset = (cursor_visual_y + line_height) - self.available_height; - } - - self.scroll_offset = self.scroll_offset.max(px(0.)); - break; - } - } - } - - fn scroll_to_cursor_horizontal(&mut self, cursor_offset: usize) { - if self.available_width <= px(0.) { - return; - } - - // For single-line input, get cursor x position from the first (only) line - let Some(line) = self.line_layouts.first() else { - return; - }; - - let cursor_x = if let Some(wrapped) = &line.wrapped_line { - let local_offset = cursor_offset.saturating_sub(line.text_range.start); - wrapped - .position_for_index(local_offset, self.line_height) - .map(|p| p.x) - .unwrap_or(px(0.)) - } else { - px(0.) - }; - - let visible_left = self.scroll_offset; - let visible_right = self.scroll_offset + self.available_width; - - // Add some padding so cursor isn't right at the edge - let padding = px(2.0); - - if cursor_x < visible_left + padding { - self.scroll_offset = (cursor_x - padding).max(px(0.)); - } else if cursor_x > visible_right - padding { - self.scroll_offset = cursor_x - self.available_width + padding; - } - - self.scroll_offset = self.scroll_offset.max(px(0.)); - } - - pub(crate) fn update_line_layouts( - &mut self, - width: Pixels, - line_height: Pixels, - text_style: &TextStyle, - window: &mut Window, - ) { - self.line_height = line_height; - self.set_text_style(text_style); - - if !self.needs_layout && self.wrap_width == Some(width) { - return; - } - - self.line_layouts.clear(); - self.wrap_width = Some(width); - - let text_color = text_style.color; - let font_size = text_style.font_size.to_pixels(window.rem_size()); - - if self.content.is_empty() { - self.line_layouts.push(InputLineLayout { - text_range: 0..0, - wrapped_line: None, - y_offset: px(0.), - visual_line_count: 1, - }); - self.needs_layout = false; - return; - } - - let mut y_offset = px(0.); - let mut current_pos = 0; - - while current_pos < self.content.len() { - let line_end = self.content[current_pos..] - .find('\n') - .map(|pos| current_pos + pos) - .unwrap_or(self.content.len()); - - let line_text = &self.content[current_pos..line_end]; - - if line_text.is_empty() { - self.line_layouts.push(InputLineLayout { - text_range: current_pos..current_pos, - wrapped_line: None, - y_offset, - visual_line_count: 1, - }); - y_offset += line_height; - } else { - let run = TextRun { - len: line_text.len(), - font: text_style.font(), - color: text_color, - background_color: None, - underline: None, - strikethrough: None, - }; - - let wrapped_lines = window - .text_system() - .shape_text( - SharedString::from(line_text.to_string()), - font_size, - &[run], - Some(width), - None, - ) - .unwrap_or_default(); - - for wrapped in wrapped_lines { - let visual_line_count = wrapped.wrap_boundaries().len() + 1; - let line_height_total = line_height * visual_line_count as f32; - - self.line_layouts.push(InputLineLayout { - text_range: current_pos..line_end, - wrapped_line: Some(Arc::new(wrapped)), - y_offset, - visual_line_count, - }); - - y_offset += line_height_total; - } - } - - current_pos = if line_end < self.content.len() { - line_end + 1 - } else { - self.content.len() - }; - } - - if self.content.ends_with('\n') { - self.line_layouts.push(InputLineLayout { - text_range: self.content.len()..self.content.len(), - wrapped_line: None, - y_offset, - visual_line_count: 1, - }); - } - - self.needs_layout = false; - self.scroll_to_cursor(); - } - - pub(crate) fn total_content_height(&self) -> Pixels { - self.line_layouts - .last() - .map(|last| last.y_offset + self.line_height * last.visual_line_count as f32) - .unwrap_or(px(0.)) - } - - /// Returns true if the scroll position is at the top. - pub fn at_top(&self) -> bool { - self.scroll_offset <= px(0.) - } - - /// Returns true if the scroll position is at the bottom. - pub fn at_bottom(&self) -> bool { - let content_height = self.total_content_height(); - let visible_height = self.available_height; - - if content_height <= visible_height { - return true; - } - - self.scroll_offset + visible_height >= content_height - } - - /// Returns the scroll progress as a value from 0.0 (top) to 1.0 (bottom). - pub fn scroll_progress(&self) -> f32 { - let content_height = self.total_content_height(); - let visible_height = self.available_height; - let max_scroll = content_height - visible_height; - - if max_scroll <= px(0.) { - return 0.0; - } - - (self.scroll_offset / max_scroll).clamp(0.0, 1.0) - } - - /// Returns how far the content is scrolled from the top in pixels. - pub fn distance_from_top(&self) -> Pixels { - self.scroll_offset.max(px(0.)) - } - - /// Returns how far the content is from the bottom in pixels. - pub fn distance_from_bottom(&self) -> Pixels { - let content_height = self.total_content_height(); - let visible_height = self.available_height; - let max_scroll = content_height - visible_height; - - if max_scroll <= px(0.) { - return px(0.); - } - - (max_scroll - self.scroll_offset).max(px(0.)) - } - - fn offset_from_utf16(&self, offset: usize) -> usize { - // Fast path: if offset is 0, return 0 - if offset == 0 { - return 0; - } - - // Fast path: if we have cached length and offset is at or past end - if let Some(utf16_len) = self.cached_utf16_len { - if offset >= utf16_len { - return self.content.len(); - } - } - - let mut utf8_offset = 0; - let mut utf16_count = 0; - - for character in self.content.chars() { - if utf16_count >= offset { - break; - } - utf16_count += character.len_utf16(); - utf8_offset += character.len_utf8(); - } - - utf8_offset.min(self.content.len()) - } - - fn offset_to_utf16(&self, offset: usize) -> usize { - // Fast path: if offset is 0, return 0 - if offset == 0 { - return 0; - } - - // Fast path: if offset is at or past end, return cached length - if offset >= self.content.len() { - return self.utf16_len(); - } - - let mut utf16_offset = 0; - let mut utf8_count = 0; - - for character in self.content.chars() { - if utf8_count >= offset { - break; - } - utf8_count += character.len_utf8(); - utf16_offset += character.len_utf16(); - } - - utf16_offset - } - - /// Returns the UTF-16 length of the content, computing and caching if necessary. - fn utf16_len(&self) -> usize { - if let Some(len) = self.cached_utf16_len { - return len; - } - self.content.chars().map(|c| c.len_utf16()).sum() - } - - fn range_to_utf16(&self, range: &Range) -> Range { - self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end) - } - - fn range_from_utf16(&self, range_utf16: &Range) -> Range { - self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end) - } - - fn previous_boundary(&self, offset: usize) -> usize { - if offset == 0 { - return 0; - } - - let text_before = &self.content[..offset.min(self.content.len())]; - text_before - .grapheme_indices(true) - .map(|(i, _)| i) - .next_back() - .unwrap_or(0) - } - - fn next_boundary(&self, offset: usize) -> usize { - if offset >= self.content.len() { - return self.content.len(); - } - - let text_after = &self.content[offset..]; - text_after - .grapheme_indices(true) - .nth(1) - .map(|(i, _)| offset + i) - .unwrap_or(self.content.len()) - } - - fn previous_word_boundary(&self, offset: usize) -> usize { - if offset == 0 { - return 0; - } - - let text_before = &self.content[..offset.min(self.content.len())]; - - let mut last_word_start = 0; - for (idx, _) in text_before.unicode_word_indices() { - if idx < offset { - last_word_start = idx; - } - } - - if last_word_start == 0 && offset > 0 { - let trimmed = text_before.trim_end(); - if trimmed.is_empty() { - return 0; - } - for (idx, _) in trimmed.unicode_word_indices() { - last_word_start = idx; - } - } - - last_word_start - } - - fn next_word_boundary(&self, offset: usize) -> usize { - if offset >= self.content.len() { - return self.content.len(); - } - - let text_after = &self.content[offset..]; - - for (idx, word) in text_after.unicode_word_indices() { - let word_end = offset + idx + word.len(); - if word_end > offset { - return word_end; - } - } - - self.content.len() - } - - fn word_range_at(&self, offset: usize) -> (usize, usize) { - let offset = offset.min(self.content.len()); - - for (idx, word) in self.content.unicode_word_indices() { - let word_end = idx + word.len(); - if offset >= idx && offset <= word_end { - return (idx, word_end); - } - } - - (offset, offset) - } -} - -impl EntityInputHandler for InputState { - fn text_for_range( - &mut self, - range_utf16: Range, - adjusted_range: &mut Option>, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - let range = self.range_from_utf16(&range_utf16); - let clamped_range = range.start.min(self.content.len())..range.end.min(self.content.len()); - adjusted_range.replace(self.range_to_utf16(&clamped_range)); - Some(self.content[clamped_range].to_string()) - } - - fn selected_text_range( - &mut self, - _ignore_disabled_input: bool, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - Some(UTF16Selection { - range: self.range_to_utf16(&self.selected_range), - reversed: self.selection_reversed, - }) - } - - fn marked_text_range( - &self, - _window: &mut Window, - _cx: &mut Context, - ) -> Option> { - self.marked_range - .as_ref() - .map(|range| self.range_to_utf16(range)) - } - - fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { - self.marked_range = None; - } - - fn replace_text_in_range( - &mut self, - range_utf16: Option>, - new_text: &str, - _window: &mut Window, - cx: &mut Context, - ) { - let range = range_utf16 - .as_ref() - .map(|range_utf16| self.range_from_utf16(range_utf16)) - .or(self.marked_range.clone()) - .unwrap_or(self.selected_range.clone()); - - let range = range.start.min(self.content.len())..range.end.min(self.content.len()); - - // Strip newlines for single-line input - let sanitized_text; - let text_to_insert = if self.multiline { - new_text - } else { - sanitized_text = new_text.replace('\n', " ").replace('\r', ""); - &sanitized_text - }; - - // Record patch for undo before modifying content - self.push_undo_patch(range.clone(), text_to_insert.len()); - - // Update cached UTF-16 length incrementally if available - if let Some(cached_len) = self.cached_utf16_len { - let removed_utf16_len: usize = self.content[range.clone()] - .chars() - .map(|c| c.len_utf16()) - .sum(); - let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum(); - self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); - } - - self.content.replace_range(range.clone(), text_to_insert); - self.selected_range = - range.start + text_to_insert.len()..range.start + text_to_insert.len(); - self.marked_range.take(); - self.needs_layout = true; - self.pause_cursor_blink(cx); - cx.emit(InputStateEvent::TextChanged); - cx.notify(); - } - - fn replace_and_mark_text_in_range( - &mut self, - range_utf16: Option>, - new_text: &str, - new_selected_range_utf16: Option>, - _window: &mut Window, - cx: &mut Context, - ) { - let range = range_utf16 - .as_ref() - .map(|range_utf16| self.range_from_utf16(range_utf16)) - .or(self.marked_range.clone()) - .unwrap_or(self.selected_range.clone()); - - let range = range.start.min(self.content.len())..range.end.min(self.content.len()); - - // Strip newlines for single-line input - let sanitized_text; - let text_to_insert = if self.multiline { - new_text - } else { - sanitized_text = new_text.replace('\n', " ").replace('\r', ""); - &sanitized_text - }; - - // Update cached UTF-16 length incrementally if available - if let Some(cached_len) = self.cached_utf16_len { - let removed_utf16_len: usize = self.content[range.clone()] - .chars() - .map(|c| c.len_utf16()) - .sum(); - let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum(); - self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); - } - - self.content.replace_range(range.clone(), text_to_insert); - - if !text_to_insert.is_empty() { - self.marked_range = Some(range.start..range.start + text_to_insert.len()); - } else { - self.marked_range = None; - } - - self.selected_range = new_selected_range_utf16 - .as_ref() - .map(|range_utf16| self.range_from_utf16(range_utf16)) - .map(|new_range| new_range.start + range.start..new_range.end + range.start) - .unwrap_or_else(|| { - range.start + text_to_insert.len()..range.start + text_to_insert.len() - }); - - self.needs_layout = true; - cx.emit(InputStateEvent::TextChanged); - cx.notify(); - } - - fn bounds_for_range( - &mut self, - range_utf16: Range, - bounds: Bounds, - _window: &mut Window, - _cx: &mut Context, - ) -> Option> { - let range = self.range_from_utf16(&range_utf16); - - for line in &self.line_layouts { - if line.text_range.is_empty() { - if range.start == line.text_range.start { - return Some(Bounds::from_corners( - point(bounds.left(), bounds.top() + line.y_offset), - point( - bounds.left() + px(4.), - bounds.top() + line.y_offset + self.line_height, - ), - )); - } - } else if line.text_range.contains(&range.start) { - if let Some(wrapped) = &line.wrapped_line { - let local_start = range.start - line.text_range.start; - let local_end = (range.end - line.text_range.start).min(wrapped.text.len()); - - let start_pos = wrapped - .position_for_index(local_start, self.line_height) - .unwrap_or(point(px(0.), px(0.))); - let end_pos = wrapped - .position_for_index(local_end, self.line_height) - .unwrap_or_else(|| { - let last_line_y = - self.line_height * (line.visual_line_count - 1) as f32; - point(wrapped.width(), last_line_y) - }); - - let start_visual_line = (start_pos.y / self.line_height).floor() as usize; - let end_visual_line = (end_pos.y / self.line_height).floor() as usize; - - if start_visual_line == end_visual_line { - return Some(Bounds::from_corners( - point( - bounds.left() + start_pos.x, - bounds.top() + line.y_offset + start_pos.y, - ), - point( - bounds.left() + end_pos.x, - bounds.top() + line.y_offset + start_pos.y + self.line_height, - ), - )); - } else { - return Some(Bounds::from_corners( - point( - bounds.left() + start_pos.x, - bounds.top() + line.y_offset + start_pos.y, - ), - point( - bounds.left() + wrapped.width(), - bounds.top() + line.y_offset + start_pos.y + self.line_height, - ), - )); - } - } - } - } - None - } - - fn character_index_for_point( - &mut self, - point: Point, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - let index = self.index_for_position(point); - Some(self.offset_to_utf16(index)) - } -} - -impl Focusable for InputState { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} diff --git a/crates/gpui_elements/src/input/history.rs b/crates/gpui_elements/src/input/history.rs new file mode 100644 index 0000000000..bc4add269e --- /dev/null +++ b/crates/gpui_elements/src/input/history.rs @@ -0,0 +1,58 @@ +use std::{ + ops::Range, + time::{Duration, Instant}, +}; + +/// Maximum number of history entries to keep. +pub const MAX_HISTORY_LEN: usize = 1000; + +/// Default interval for grouping consecutive edits into a single undo entry. +pub const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300); + +/// A patch-based history entry for memory-efficient undo/redo operations. +/// Instead of storing the full content, we store only the change needed to reverse the edit. +#[derive(Clone, Debug)] +pub struct HistoryEntry { + /// The byte range that was modified (after the edit, for undo; before the edit, for redo). + pub range: Range, + /// The text that was replaced (to restore on undo). + pub old_text: String, + /// The length of the new text that replaced old_text (to know how much to remove on undo). + pub new_text_len: usize, + /// The selection range before the edit. + pub selected_range: Range, + /// Whether the selection was reversed before the edit. + pub selection_reversed: bool, + /// Timestamp for grouping consecutive edits. + pub timestamp: Instant, +} + +impl HistoryEntry { + /// Apply this patch to undo an edit, returning the reverse patch for redo. + pub fn apply_undo(&self, content: &mut String) -> HistoryEntry { + let undo_start = self.range.start; + let undo_end = (self.range.start + self.new_text_len).min(content.len()); + + // Capture what we're about to remove (the "new" text that was inserted) + let removed_text = content[undo_start..undo_end].to_string(); + + // Replace with the old text + content.replace_range(undo_start..undo_end, &self.old_text); + + // Return reverse patch for redo + HistoryEntry { + range: undo_start..undo_start + self.old_text.len(), + old_text: removed_text, + new_text_len: self.old_text.len(), + selected_range: self.selected_range.clone(), + selection_reversed: self.selection_reversed, + timestamp: self.timestamp, + } + } + + /// Apply this patch to redo an edit, returning the reverse patch for undo. + pub fn apply_redo(&self, content: &mut String) -> HistoryEntry { + // Redo is the same operation as undo - we're reversing the undo + self.apply_undo(content) + } +} diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs new file mode 100644 index 0000000000..8472a017fa --- /dev/null +++ b/crates/gpui_elements/src/input/paint.rs @@ -0,0 +1,1014 @@ +use crate::input::{Input, InputLineLayout, InputState, PaintColors}; +use gpui::{ + App, Bounds, ContentMask, CursorStyle, DispatchPhase, Element, ElementId, ElementInputHandler, + Entity, FocusHandle, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla, + InspectorElementId, LayoutId, Length, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, Pixels, Point, ScrollWheelEvent, SharedString, TextAlign, TextRun, TextStyle, + Window, WrappedLine, fill, point, px, relative, size, +}; +use std::sync::Arc; + +const CURSOR_WIDTH: f32 = 2.0; +const MARKED_TEXT_UNDERLINE_THICKNESS: f32 = 2.0; + +pub struct InputLayoutState { + text_style: TextStyle, +} + +pub struct InputPrepaintState { + hitbox: Option, +} + +impl Element for Input { + type RequestLayoutState = InputLayoutState; + type PrepaintState = InputPrepaintState; + + fn id(&self) -> Option { + self.interactivity.element_id.clone() + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + self.interactivity.source_location() + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut resolved_text_style = None; + let multiline = self.multiline; + + let layout_id = self.interactivity.request_layout( + global_id, + inspector_id, + window, + cx, + |element_style, window, cx| { + window.with_text_style(element_style.text_style().cloned(), |window| { + resolved_text_style = Some(window.text_style()); + + let mut layout_style = element_style.clone(); + if multiline { + if let Length::Auto = layout_style.size.width { + layout_style.size.width = relative(1.).into(); + } + if let Length::Auto = layout_style.size.height { + layout_style.size.height = relative(1.).into(); + } + } + window.request_layout(layout_style, None, cx) + }) + }, + ); + + ( + layout_id, + InputLayoutState { + text_style: resolved_text_style.unwrap_or_else(|| window.text_style()), + }, + ) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + layout_state: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let line_height = layout_state + .text_style + .line_height_in_pixels(window.rem_size()); + + let wrap_width = if self.multiline { + bounds.size.width + } else { + px(100000.) + }; + + self.input.update(cx, |input, _cx| { + input.available_height = bounds.size.height; + input.available_width = bounds.size.width; + input.update_line_layouts(wrap_width, line_height, &layout_state.text_style, window); + }); + + let hitbox = self.interactivity.prepaint( + global_id, + inspector_id, + bounds, + bounds.size, + window, + cx, + |_style, _point, hitbox, window, _cx| { + hitbox.or_else(|| Some(window.insert_hitbox(bounds, HitboxBehavior::Normal))) + }, + ); + + InputPrepaintState { hitbox } + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + layout_state: &mut Self::RequestLayoutState, + prepaint_state: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let focus_handle = self.input.focus_handle(cx); + + if let Some(hitbox) = &prepaint_state.hitbox { + window.set_cursor_style(CursorStyle::IBeam, hitbox); + } + + window.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, self.input.clone()), + cx, + ); + + let input = self.input.clone(); + let placeholder = self.placeholder.clone(); + let text_style = layout_state.text_style.clone(); + let multiline = self.multiline; + let is_focused = focus_handle.is_focused(window); + let cursor_visible = self + .input + .update(cx, |input, cx| input.cursor_visible(is_focused, cx)); + + let colors = self.colors; + self.interactivity.paint( + global_id, + inspector_id, + bounds, + prepaint_state.hitbox.as_ref(), + window, + cx, + |_style, window, cx| { + handle_mouse(&input, bounds, multiline, window, cx); + + window.with_content_mask(Some(ContentMask { bounds }), |window| { + if multiline { + paint_multiline( + &input, + &focus_handle, + bounds, + &text_style, + placeholder.as_ref(), + &colors, + cursor_visible, + window, + cx, + ); + } else { + paint_singleline( + &input, + &focus_handle, + bounds, + &text_style, + placeholder.as_ref(), + &colors, + cursor_visible, + window, + cx, + ); + } + }); + }, + ); + } +} + +/// Registers all mouse event handlers for the input. +fn handle_mouse( + input: &Entity, + bounds: Bounds, + multiline: bool, + window: &mut Window, + cx: &App, +) { + mouse_down(input.clone(), bounds, multiline, window); + mouse_up(input.clone(), window); + mouse_move(input.clone(), bounds, multiline, window); + handle_scroll(input.clone(), bounds, multiline, window, cx); +} + +fn mouse_down( + input: Entity, + bounds: Bounds, + multiline: bool, + window: &mut Window, +) { + window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + if !bounds.contains(&event.position) { + return; + } + if event.button != MouseButton::Left { + return; + } + + input.update(cx, |input, cx| { + let text_position = + screen_to_text_position(event.position, bounds, input.scroll_offset, multiline); + input.on_mouse_down( + text_position, + event.click_count, + event.modifiers.shift, + window, + cx, + ); + }); + }); +} + +fn mouse_up(input: Entity, window: &mut Window) { + window.on_mouse_event(move |event: &MouseUpEvent, phase, _window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + if event.button != MouseButton::Left { + return; + } + + input.update(cx, |input, cx| { + input.on_mouse_up(cx); + }); + }); +} + +fn mouse_move( + input: Entity, + bounds: Bounds, + multiline: bool, + window: &mut Window, +) { + window.on_mouse_event(move |event: &MouseMoveEvent, phase, _window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + + input.update(cx, |input, cx| { + let text_position = + screen_to_text_position(event.position, bounds, input.scroll_offset, multiline); + input.on_mouse_move(text_position, cx); + }); + }); +} + +fn handle_scroll( + input: Entity, + bounds: Bounds, + multiline: bool, + window: &mut Window, + cx: &App, +) { + let max_scroll = if multiline { + let total_height = input.read(cx).total_content_height(); + (total_height - bounds.size.height).max(px(0.)) + } else { + let text_width = input + .read(cx) + .line_layouts + .first() + .and_then(|l| l.wrapped_line.as_ref()) + .map(|w| w.width()) + .unwrap_or(px(0.)); + (text_width - bounds.size.width).max(px(0.)) + }; + + window.on_mouse_event(move |event: &ScrollWheelEvent, phase, _window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + if !bounds.contains(&event.position) { + return; + } + + let pixel_delta = event.delta.pixel_delta(px(20.)); + input.update(cx, |input, cx| { + if multiline { + input.scroll_offset = + (input.scroll_offset - pixel_delta.y).clamp(px(0.), max_scroll); + } else { + let delta = if pixel_delta.x.abs() > pixel_delta.y.abs() { + pixel_delta.x + } else { + pixel_delta.y + }; + input.scroll_offset = (input.scroll_offset - delta).clamp(px(0.), max_scroll); + } + cx.notify(); + }); + }); +} + +/// Converts a screen position to a position relative to the text area origin, +/// adjusted for scroll offset. +fn screen_to_text_position( + screen_position: Point, + bounds: Bounds, + scroll_offset: Pixels, + multiline: bool, +) -> Point { + if multiline { + point( + screen_position.x - bounds.origin.x, + screen_position.y - bounds.origin.y + scroll_offset, + ) + } else { + point( + screen_position.x - bounds.origin.x + scroll_offset, + screen_position.y - bounds.origin.y, + ) + } +} + +fn paint_multiline( + input: &Entity, + focus_handle: &FocusHandle, + bounds: Bounds, + text_style: &TextStyle, + placeholder: Option<&SharedString>, + colors: &PaintColors, + cursor_visible: bool, + window: &mut Window, + cx: &mut App, +) { + let input_state = input.read(cx); + let content = input_state.content().to_string(); + let selected_range = input_state.selected_range().clone(); + let marked_range = input_state.marked_range().cloned(); + let cursor_offset = input_state.cursor_offset(); + let line_layouts = input_state.line_layouts.clone(); + let scroll_offset = input_state.scroll_offset; + let line_height = input_state.line_height; + let is_focused = focus_handle.is_focused(window); + + if !selected_range.is_empty() { + paint_multiline_selection( + &line_layouts, + &selected_range, + bounds, + scroll_offset, + line_height, + colors.selection, + window, + ); + } + + if content.is_empty() { + if let Some(placeholder_str) = placeholder { + if !placeholder_str.is_empty() { + paint_placeholder( + placeholder_str, + bounds, + text_style, + colors.placeholder, + window, + cx, + false, + ); + } + } + } else { + paint_multiline_text( + &line_layouts, + bounds, + scroll_offset, + line_height, + window, + cx, + ); + } + + if let Some(marked_range) = &marked_range { + if !marked_range.is_empty() { + paint_multiline_marked_underline( + &line_layouts, + marked_range, + bounds, + scroll_offset, + line_height, + colors.cursor, + window, + ); + } + } + + if is_focused && selected_range.is_empty() && cursor_visible { + paint_multiline_cursor( + &line_layouts, + cursor_offset, + &content, + bounds, + scroll_offset, + line_height, + colors.cursor, + window, + ); + } +} + +fn is_line_visible( + line_y: Pixels, + line_height: Pixels, + visual_line_count: usize, + visible_height: Pixels, +) -> bool { + let line_bottom = line_y + line_height * visual_line_count as f32; + line_bottom >= px(0.) && line_y <= visible_height +} + +fn line_intersects_range( + text_range: &std::ops::Range, + selected_range: &std::ops::Range, +) -> bool { + if text_range.is_empty() { + selected_range.start <= text_range.start && selected_range.end > text_range.start + } else { + selected_range.end > text_range.start && selected_range.start < text_range.end + } +} + +fn compute_visual_line_index(y: Pixels, line_height: Pixels) -> usize { + (y / line_height).floor() as usize +} + +fn paint_multiline_selection( + line_layouts: &[InputLineLayout], + selected_range: &std::ops::Range, + bounds: Bounds, + scroll_offset: Pixels, + line_height: Pixels, + selection_color: Hsla, + window: &mut Window, +) { + for line in line_layouts { + let line_y = line.y_offset - scroll_offset; + + if !is_line_visible( + line_y, + line_height, + line.visual_line_count, + bounds.size.height, + ) { + continue; + } + + if !line_intersects_range(&line.text_range, selected_range) { + continue; + } + + if line.text_range.is_empty() { + let empty_line_selection_width = px(6.); + window.paint_quad(fill( + Bounds::from_corners( + point(bounds.left(), bounds.top() + line_y), + point( + bounds.left() + empty_line_selection_width, + bounds.top() + line_y + line_height, + ), + ), + selection_color, + )); + } else if let Some(wrapped) = &line.wrapped_line { + let line_start = line.text_range.start; + let line_end = line.text_range.end; + + let sel_start = selected_range.start.max(line_start) - line_start; + let sel_end = selected_range.end.min(line_end) - line_start; + + let start_pos = wrapped + .position_for_index(sel_start, line_height) + .unwrap_or(point(px(0.), px(0.))); + let end_pos = wrapped + .position_for_index(sel_end, line_height) + .unwrap_or_else(|| { + let last_line_y = line_height * (line.visual_line_count - 1) as f32; + point(wrapped.width(), last_line_y) + }); + + let start_visual_line = compute_visual_line_index(start_pos.y, line_height); + let end_visual_line = compute_visual_line_index(end_pos.y, line_height); + + if start_visual_line == end_visual_line { + window.paint_quad(fill( + Bounds::from_corners( + point( + bounds.left() + start_pos.x, + bounds.top() + line_y + start_pos.y, + ), + point( + bounds.left() + end_pos.x, + bounds.top() + line_y + start_pos.y + line_height, + ), + ), + selection_color, + )); + } else { + let line_width = wrapped.width(); + + // First visual line + window.paint_quad(fill( + Bounds::from_corners( + point( + bounds.left() + start_pos.x, + bounds.top() + line_y + start_pos.y, + ), + point( + bounds.left() + line_width, + bounds.top() + line_y + start_pos.y + line_height, + ), + ), + selection_color, + )); + + // Middle visual lines + for visual_line in (start_visual_line + 1)..end_visual_line { + let y = line_height * visual_line as f32; + window.paint_quad(fill( + Bounds::from_corners( + point(bounds.left(), bounds.top() + line_y + y), + point( + bounds.left() + line_width, + bounds.top() + line_y + y + line_height, + ), + ), + selection_color, + )); + } + + // Last visual line + window.paint_quad(fill( + Bounds::from_corners( + point(bounds.left(), bounds.top() + line_y + end_pos.y), + point( + bounds.left() + end_pos.x, + bounds.top() + line_y + end_pos.y + line_height, + ), + ), + selection_color, + )); + } + } + } +} + +fn paint_multiline_text( + line_layouts: &[InputLineLayout], + bounds: Bounds, + scroll_offset: Pixels, + line_height: Pixels, + window: &mut Window, + cx: &mut App, +) { + for line_layout in line_layouts { + let line_y = line_layout.y_offset - scroll_offset; + + if !is_line_visible( + line_y, + line_height, + line_layout.visual_line_count, + bounds.size.height, + ) { + continue; + } + + if let Some(wrapped) = &line_layout.wrapped_line { + let paint_pos = point(bounds.left(), bounds.top() + line_y); + let _ = wrapped.paint( + paint_pos, + line_height, + TextAlign::Left, + Some(bounds), + window, + cx, + ); + } + } +} + +fn paint_multiline_marked_underline( + line_layouts: &[InputLineLayout], + marked_range: &std::ops::Range, + bounds: Bounds, + scroll_offset: Pixels, + line_height: Pixels, + underline_color: Hsla, + window: &mut Window, +) { + let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); + let underline_offset = line_height - underline_thickness; + + for line in line_layouts { + let line_y = line.y_offset - scroll_offset; + + if !is_line_visible( + line_y, + line_height, + line.visual_line_count, + bounds.size.height, + ) { + continue; + } + + if !line_intersects_range(&line.text_range, marked_range) { + continue; + } + + if line.text_range.is_empty() { + continue; + } + + if let Some(wrapped) = &line.wrapped_line { + let line_start = line.text_range.start; + let line_end = line.text_range.end; + + let mark_start = marked_range.start.max(line_start) - line_start; + let mark_end = marked_range.end.min(line_end) - line_start; + + let start_pos = wrapped + .position_for_index(mark_start, line_height) + .unwrap_or(point(px(0.), px(0.))); + let end_pos = wrapped + .position_for_index(mark_end, line_height) + .unwrap_or_else(|| { + let last_line_y = line_height * (line.visual_line_count - 1) as f32; + point(wrapped.width(), last_line_y) + }); + + let start_visual_line = compute_visual_line_index(start_pos.y, line_height); + let end_visual_line = compute_visual_line_index(end_pos.y, line_height); + + if start_visual_line == end_visual_line { + window.paint_quad(fill( + Bounds::from_corners( + point( + bounds.left() + start_pos.x, + bounds.top() + line_y + start_pos.y + underline_offset, + ), + point( + bounds.left() + end_pos.x, + bounds.top() + line_y + start_pos.y + line_height, + ), + ), + underline_color, + )); + } else { + // First visual line + window.paint_quad(fill( + Bounds::from_corners( + point( + bounds.left() + start_pos.x, + bounds.top() + line_y + start_pos.y + underline_offset, + ), + point( + bounds.left() + wrapped.width(), + bounds.top() + line_y + start_pos.y + line_height, + ), + ), + underline_color, + )); + + // Middle visual lines + for visual_line in (start_visual_line + 1)..end_visual_line { + let y = line_height * visual_line as f32; + window.paint_quad(fill( + Bounds::from_corners( + point(bounds.left(), bounds.top() + line_y + y + underline_offset), + point( + bounds.left() + wrapped.width(), + bounds.top() + line_y + y + line_height, + ), + ), + underline_color, + )); + } + + // Last visual line + window.paint_quad(fill( + Bounds::from_corners( + point( + bounds.left(), + bounds.top() + line_y + end_pos.y + underline_offset, + ), + point( + bounds.left() + end_pos.x, + bounds.top() + line_y + end_pos.y + line_height, + ), + ), + underline_color, + )); + } + } + } +} + +fn paint_multiline_cursor( + line_layouts: &[InputLineLayout], + cursor_offset: usize, + _content: &str, + bounds: Bounds, + scroll_offset: Pixels, + line_height: Pixels, + cursor_color: Hsla, + window: &mut Window, +) { + for line in line_layouts.iter() { + let line_y = line.y_offset - scroll_offset; + + if !is_line_visible( + line_y, + line_height, + line.visual_line_count, + bounds.size.height, + ) { + continue; + } + + // Since range is non-inclusive of the end value we need to check for it explicitly + let is_cursor_in_line = if line.text_range.is_empty() { + cursor_offset == line.text_range.start + } else { + line.text_range.contains(&cursor_offset) || cursor_offset == line.text_range.end + }; + + if !is_cursor_in_line { + continue; + } + + let cursor_position = if let Some(wrapped) = &line.wrapped_line { + let local_offset = cursor_offset.saturating_sub(line.text_range.start); + wrapped + .position_for_index(local_offset, line_height) + .unwrap_or(point(px(0.), px(0.))) + } else { + point(px(0.), px(0.)) + }; + + window.paint_quad(fill( + Bounds::new( + point( + bounds.left() + cursor_position.x, + bounds.top() + line_y + cursor_position.y, + ), + size(px(CURSOR_WIDTH), line_height), + ), + cursor_color, + )); + break; + } +} + +/// State for single-line painting that pre-computes character positions. +struct SingleLinePaintState { + content: String, + selected_range: std::ops::Range, + marked_range: Option>, + cursor_offset: usize, + scroll_offset: Pixels, + line_height: Pixels, + text_width: Pixels, + is_focused: bool, + char_positions: Vec, + wrapped_line: Option>, +} + +impl SingleLinePaintState { + fn from_input( + input: &Entity, + focus_handle: &FocusHandle, + window: &Window, + cx: &App, + ) -> Self { + let input_state = input.read(cx); + + let mut char_positions = Vec::new(); + let mut text_width = px(0.); + + if let Some(line) = input_state.line_layouts.first() { + if let Some(wrapped) = &line.wrapped_line { + text_width = wrapped.width(); + let content = input_state.content(); + let mut idx = 0; + for ch in content.chars() { + if let Some(pos) = wrapped.position_for_index(idx, input_state.line_height) { + char_positions.push(pos.x); + } else { + char_positions.push(text_width); + } + idx += ch.len_utf8(); + } + char_positions.push(text_width); + } + } + + let wrapped_line = input_state + .line_layouts + .first() + .and_then(|l| l.wrapped_line.clone()); + + Self { + content: input_state.content().to_string(), + selected_range: input_state.selected_range().clone(), + marked_range: input_state.marked_range().cloned(), + cursor_offset: input_state.cursor_offset(), + scroll_offset: input_state.scroll_offset, + line_height: input_state.line_height, + text_width, + is_focused: focus_handle.is_focused(window), + char_positions, + wrapped_line, + } + } + + fn x_for_index(&self, index: usize) -> Pixels { + let char_index = self.content[..index.min(self.content.len())] + .chars() + .count(); + self.char_positions + .get(char_index) + .copied() + .unwrap_or(self.text_width) + } +} + +fn paint_singleline( + input: &Entity, + focus_handle: &FocusHandle, + bounds: Bounds, + text_style: &TextStyle, + placeholder: Option<&SharedString>, + colors: &PaintColors, + cursor_visible: bool, + window: &mut Window, + cx: &mut App, +) { + let state = SingleLinePaintState::from_input(input, focus_handle, window, cx); + + if !state.selected_range.is_empty() { + paint_singleline_selection(&state, bounds, colors.selection, window); + } + + if state.content.is_empty() { + if let Some(placeholder_str) = placeholder { + if !placeholder_str.is_empty() { + paint_placeholder( + placeholder_str, + bounds, + text_style, + colors.placeholder, + window, + cx, + true, + ); + } + } + } else { + paint_singleline_text(&state, bounds, window, cx); + } + + if let Some(marked_range) = &state.marked_range { + if !marked_range.is_empty() { + paint_singleline_marked_underline(&state, marked_range, bounds, colors.cursor, window); + } + } + + if state.is_focused && state.selected_range.is_empty() && cursor_visible { + paint_singleline_cursor(&state, bounds, colors.cursor, window); + } +} + +fn paint_singleline_selection( + state: &SingleLinePaintState, + bounds: Bounds, + selection_color: Hsla, + window: &mut Window, +) { + let start_x = state.x_for_index(state.selected_range.start) - state.scroll_offset; + let end_x = state.x_for_index(state.selected_range.end) - state.scroll_offset; + + let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; + + window.paint_quad(fill( + Bounds::from_corners( + point(bounds.left() + start_x, bounds.top() + y_offset), + point( + bounds.left() + end_x, + bounds.top() + y_offset + state.line_height, + ), + ), + selection_color, + )); +} + +fn paint_placeholder( + placeholder: &SharedString, + bounds: Bounds, + text_style: &TextStyle, + color: Hsla, + window: &mut Window, + cx: &mut App, + baseline: bool, +) { + let run = TextRun { + len: placeholder.len(), + font: text_style.font(), + color, + background_color: None, + underline: None, + strikethrough: None, + }; + + let font_size = text_style.font_size.to_pixels(window.rem_size()); + let shaped_line = window + .text_system() + .shape_line(placeholder.clone(), font_size, &[run], None); + let line_height = text_style.line_height_in_pixels(window.rem_size()); + + let mut paint_origin = bounds.origin; + if baseline { + let y_offset = (bounds.size.height - line_height).max(px(0.)) / 2.0; + paint_origin.y += y_offset; + } + + let _ = shaped_line.paint(paint_origin, line_height, TextAlign::Left, None, window, cx); +} + +fn paint_singleline_text( + state: &SingleLinePaintState, + bounds: Bounds, + window: &mut Window, + cx: &mut App, +) { + let Some(wrapped_line) = &state.wrapped_line else { + return; + }; + + let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; + let paint_origin = point( + bounds.origin.x - state.scroll_offset, + bounds.origin.y + y_offset, + ); + + let _ = wrapped_line.paint( + paint_origin, + state.line_height, + TextAlign::Left, + Some(bounds), + window, + cx, + ); +} + +fn paint_singleline_marked_underline( + state: &SingleLinePaintState, + marked_range: &std::ops::Range, + bounds: Bounds, + underline_color: Hsla, + window: &mut Window, +) { + let start_x = state.x_for_index(marked_range.start) - state.scroll_offset; + let end_x = state.x_for_index(marked_range.end) - state.scroll_offset; + + let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); + let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; + let underline_y = bounds.top() + y_offset + state.line_height - underline_thickness; + + window.paint_quad(fill( + Bounds::from_corners( + point(bounds.left() + start_x, underline_y), + point(bounds.left() + end_x, underline_y + underline_thickness), + ), + underline_color, + )); +} + +fn paint_singleline_cursor( + state: &SingleLinePaintState, + bounds: Bounds, + cursor_color: Hsla, + window: &mut Window, +) { + let cursor_x = state.x_for_index(state.cursor_offset) - state.scroll_offset; + + let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; + + window.paint_quad(fill( + Bounds::new( + point(bounds.left() + cursor_x, bounds.top() + y_offset), + size(px(CURSOR_WIDTH), state.line_height), + ), + cursor_color, + )); +} diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs new file mode 100644 index 0000000000..d2475e3b52 --- /dev/null +++ b/crates/gpui_elements/src/input/state.rs @@ -0,0 +1,1319 @@ +use super::actions::*; +use crate::input::unicode::UnicodeString; +use gpui::{ + App, AppContext, ClipboardItem, Context, Entity, EntityId, EntityInputHandler, EventEmitter, + FocusHandle, Focusable, Pixels, Point, SharedString, Subscription, TextRun, TextStyle, Window, + WrappedLine, point, px, +}; +use std::{ + ops::Range, + sync::Arc, + time::{Duration, Instant}, +}; +use unicode_segmentation::UnicodeSegmentation; + +/// Events emitted by InputState when significant changes occur. +#[derive(Clone, Debug)] +pub enum InputStateEvent { + /// Emitted when the input gains focus. + Focus, + /// Emitted when the input loses focus. + Blur, + /// Emitted when the text content changes. + TextChanged, + /// Emitted when an undo operation is performed. + Undo, + /// Emitted when a redo operation is performed. + Redo, +} + +impl EventEmitter for InputState {} + +/// `Input` is the state model for text input components. It handles: +/// - Text content storage and manipulation +/// - Selection and cursor management +/// - Keyboard navigation and editing actions +/// - IME (Input Method Editor) support via `EntityInputHandler` +pub struct InputState { + entity_id: EntityId, + focus_handle: FocusHandle, + content: String, + placeholder: SharedString, + pub(super) selected_range: Range, + pub(super) selection_reversed: bool, + pub(super) marked_range: Option>, + pub(super) line_height: Pixels, + pub(super) line_layouts: Vec, + pub(super) wrap_width: Option, + pub(super) text_style: Option, + pub(super) needs_layout: bool, + is_selecting: bool, + last_click_position: Option>, + click_count: usize, + /// Scroll offset - vertical for multiline, horizontal for single-line + pub(super) scroll_offset: Pixels, + pub(super) available_height: Pixels, + pub(super) available_width: Pixels, + pub(super) multiline: bool, + /// Stack of previous states for undo. + undo_stack: Vec, + /// Stack of undone states for redo. + redo_stack: Vec, + /// Optional entity and subscription tracking the blinking of the text cursor. + cursor_blink: Option<(Entity, Subscription)>, + /// Tracks whether we were focused on the last update. + was_focused: bool, + /// Cached UTF-16 length of content for faster IME operations. + /// Lazily computed when None. + pub(super) cached_utf16_len: Option, +} + +/// Layout information for a single logical line of text in an input. +/// +/// A logical line corresponds to content between newlines in the input text. +/// When text wrapping is enabled, a logical line may span multiple visual lines. +#[derive(Clone, Debug)] +pub(super) struct InputLineLayout { + /// The 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 vertical offset from the top of the text area in pixels. + pub y_offset: Pixels, + /// The number of visual lines this logical line spans (due to wrapping). + pub visual_line_count: usize, +} + +pub enum CursorBlinkType<'app> { + Disabled, + Enabled { + app: &'app mut App, + interval: Option, + }, +} + +impl Focusable for InputState { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl InputState { + /// Creates a new `Input` with the specified multiline setting. + /// Cursor blinking is enabled by default. + pub fn new(cx: &mut Context) -> Self { + let mut this = Self { + entity_id: cx.entity_id(), + focus_handle: cx.focus_handle(), + content: String::new(), + placeholder: SharedString::default(), + selected_range: 0..0, + selection_reversed: false, + marked_range: None, + line_height: px(0.), + line_layouts: Vec::new(), + wrap_width: None, + text_style: None, + needs_layout: true, + is_selecting: false, + last_click_position: None, + click_count: 0, + scroll_offset: px(0.), + available_height: px(0.), + available_width: px(0.), + multiline: false, + undo_stack: Vec::new(), + cached_utf16_len: None, + redo_stack: Vec::new(), + cursor_blink: None, + was_focused: false, + }; + this = this.cursor_blink(CursorBlinkType::Enabled { + app: cx, + interval: None, + }); + this + } + + pub fn cursor_blink<'app>(mut self, args: CursorBlinkType<'app>) -> Self { + self.cursor_blink = match args { + CursorBlinkType::Disabled => None, + CursorBlinkType::Enabled { app: cx, interval } => { + let interval = interval.unwrap_or(super::DEFAULT_BLINK_INTERVAL); + let cursor_blink = cx.new(|cx| super::CursorBlink::new(interval, cx)); + let entity_id = self.entity_id; + let subscription = cx.observe(&cursor_blink, move |_, cx| cx.notify(entity_id)); + Some((cursor_blink, subscription)) + } + }; + self + } + + /// Returns whether the cursor should be visible (for blinking). + /// + /// If blinking is not enabled, always returns `true`. + /// This method also updates the blink manager's enabled state based on focus. + pub fn cursor_visible(&mut self, is_focused: bool, cx: &mut Context) -> bool { + // Update cursor blink based on focus changes + if let Some((cursor_blink, _)) = &self.cursor_blink { + if is_focused && !self.was_focused { + cursor_blink.update(cx, |cb, cx| cb.enable(cx)); + cx.emit(InputStateEvent::Focus); + } else if !is_focused && self.was_focused { + cursor_blink.update(cx, |cb, cx| cb.disable(cx)); + cx.emit(InputStateEvent::Blur); + } + } + self.was_focused = is_focused; + + self.cursor_blink + .as_ref() + .map(|(cb, _)| cb.read(cx).visible()) + .unwrap_or(true) + } + + /// Pauses cursor blinking temporarily (e.g., during typing). + pub(super) fn pause_cursor_blink(&self, cx: &mut Context) { + if let Some((cursor_blink, _)) = &self.cursor_blink { + cursor_blink.update(cx, |cb, cx| cb.pause_blinking(cx)); + } + } + + /// Sets the text style used for layout. Marks layout as dirty if the style changed. + pub(crate) fn set_text_style(&mut self, style: &TextStyle) { + let changed = self + .text_style + .as_ref() + .map_or(true, |current| current != style); + + if changed { + self.text_style = Some(style.clone()); + self.needs_layout = true; + } + } + + /// Returns the current text content. + pub fn content(&self) -> &str { + &self.content + } + + pub(super) fn content_mut(&mut self) -> &mut String { + &mut self.content + } + + /// Sets the text content, resetting selection to the beginning. + /// This clears the undo/redo history. + pub fn set_content(&mut self, content: impl Into, cx: &mut Context) { + let content = content.into(); + self.content = if self.multiline { + content + } else { + // Strip newlines for single-line input + content.replace('\n', " ").replace('\r', "") + }; + self.selected_range = 0..0; + self.selection_reversed = false; + self.marked_range = None; + self.needs_layout = true; + self.undo_stack.clear(); + self.redo_stack.clear(); + self.cached_utf16_len = None; + self.pause_cursor_blink(cx); + cx.emit(InputStateEvent::TextChanged); + cx.notify(); + } + + /// Returns whether undo is available. + pub fn can_undo(&self) -> bool { + !self.undo_stack.is_empty() + } + + /// Returns whether redo is available. + pub fn can_redo(&self) -> bool { + !self.redo_stack.is_empty() + } + + /// Records a patch for undo. Called before making changes to content. + /// Returns true if a new entry was created, false if grouped with previous. + pub(super) fn push_undo_patch(&mut self, range: Range, new_text_len: usize) { + // Don't record during IME composition + if self.marked_range.is_some() { + return; + } + + let now = Instant::now(); + + // Check if we should group with the last entry + if let Some(last) = self.undo_stack.last() { + if now.duration_since(last.timestamp) < super::DEFAULT_GROUP_INTERVAL { + // Within group interval - extend the existing patch + // We need to merge this edit with the previous one + return; + } + } + + // Capture the text that will be replaced + let old_text = self.content[range.clone()].to_string(); + + self.undo_stack.push(super::HistoryEntry { + range: range.start..range.start + new_text_len, + old_text, + new_text_len, + selected_range: self.selected_range.clone(), + selection_reversed: self.selection_reversed, + timestamp: now, + }); + + // Limit history size + if self.undo_stack.len() > super::MAX_HISTORY_LEN { + self.undo_stack.remove(0); + } + + // New edit invalidates redo stack + self.redo_stack.clear(); + } + + /// Undoes the last edit by applying the reverse patch. + pub(crate) fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context) { + if let Some(entry) = self.undo_stack.pop() { + // Remember selection to restore + let selected_range = entry.selected_range.clone(); + let selection_reversed = entry.selection_reversed; + + // Apply the undo patch and get the redo patch + let redo_entry = entry.apply_undo(&mut self.content); + self.redo_stack.push(redo_entry); + + // Restore selection state + self.selected_range = selected_range; + self.selection_reversed = selection_reversed; + self.needs_layout = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Undo); + cx.notify(); + } + } + + /// Redoes the last undone edit by applying the forward patch. + pub(crate) fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context) { + if let Some(entry) = self.redo_stack.pop() { + // Apply the redo patch and get the undo patch + let undo_entry = entry.apply_redo(&mut self.content); + + // The undo entry contains the selection state after the original edit + // We need to restore cursor to end of inserted text + let cursor_pos = undo_entry.range.start; + self.selected_range = cursor_pos..cursor_pos; + self.selection_reversed = false; + + self.undo_stack.push(undo_entry); + self.needs_layout = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Redo); + cx.notify(); + } + } + + /// Returns the placeholder text shown when content is empty. + pub fn placeholder(&self) -> &SharedString { + &self.placeholder + } + + /// Sets the placeholder text. + pub fn set_placeholder( + &mut self, + placeholder: impl Into, + cx: &mut Context, + ) { + self.placeholder = placeholder.into(); + cx.notify(); + } + + /// Returns the current selection range. + pub fn selected_range(&self) -> &Range { + &self.selected_range + } + + /// Returns true if the selection is reversed (cursor at start). + pub fn selection_reversed(&self) -> bool { + self.selection_reversed + } + + /// Returns the current cursor offset. + pub fn cursor_offset(&self) -> usize { + if self.selection_reversed { + self.selected_range.start + } else { + self.selected_range.end + } + } + + /// Returns the marked text range (for IME composition). + pub fn marked_range(&self) -> Option<&Range> { + self.marked_range.as_ref() + } + + /// Sets the selection range directly. + pub fn set_selected_range(&mut self, range: Range) { + let range = range.start.min(self.content.len())..range.end.min(self.content.len()); + self.selected_range = range; + self.selection_reversed = false; + } + + /// Returns the selected text range in UTF-16 offsets (for IME). + pub fn selected_text_range_utf16(&self) -> Range { + self.utf_range_8to16(&self.selected_range) + } + + /// Inserts text at the current cursor position, replacing any selection. + pub fn insert_text(&mut self, text: &str, cx: &mut Context) { + let range = self + .marked_range + .clone() + .unwrap_or(self.selected_range.clone()); + let range = range.start.min(self.content.len())..range.end.min(self.content.len()); + + let sanitized_text; + let text_to_insert = if self.multiline { + text + } else { + sanitized_text = text.replace('\n', " ").replace('\r', ""); + &sanitized_text + }; + + // Record patch for undo before modifying content + self.push_undo_patch(range.clone(), text_to_insert.len()); + + // Update cached UTF-16 length incrementally if available + if let Some(cached_len) = self.cached_utf16_len { + let removed_utf16_len: usize = self.content[range.clone()] + .chars() + .map(|c| c.len_utf16()) + .sum(); + let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum(); + self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); + } + + self.content.replace_range(range.clone(), text_to_insert); + self.selected_range = + range.start + text_to_insert.len()..range.start + text_to_insert.len(); + self.marked_range.take(); + self.needs_layout = true; + self.pause_cursor_blink(cx); + cx.emit(InputStateEvent::TextChanged); + cx.notify(); + } + + /// Deletes the character before the cursor (convenience method for benchmarks). + pub fn delete_backward(&mut self, cx: &mut Context) { + if self.selected_range.is_empty() { + self.select_to(self.previous_boundary(self.cursor_offset()), cx); + } + self.insert_text("", cx); + } + + /// Undoes the last edit (convenience method without Window). + pub fn undo_action(&mut self, cx: &mut Context) { + if let Some(entry) = self.undo_stack.pop() { + let selected_range = entry.selected_range.clone(); + let selection_reversed = entry.selection_reversed; + + let redo_entry = entry.apply_undo(&mut self.content); + self.redo_stack.push(redo_entry); + + self.selected_range = selected_range; + self.selection_reversed = selection_reversed; + self.needs_layout = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Undo); + cx.notify(); + } + } + + /// Redoes the last undone edit (convenience method without Window). + pub fn redo_action(&mut self, cx: &mut Context) { + if let Some(entry) = self.redo_stack.pop() { + let undo_entry = entry.apply_redo(&mut self.content); + + let cursor_pos = undo_entry.range.start; + self.selected_range = cursor_pos..cursor_pos; + self.selection_reversed = false; + + self.undo_stack.push(undo_entry); + self.needs_layout = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Redo); + cx.notify(); + } + } + + /// Selects all text. + pub fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { + self.selected_range = 0..self.content.len(); + self.selection_reversed = false; + cx.notify(); + } + + pub(crate) fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + let new_pos = self.previous_boundary(self.cursor_offset()); + self.move_to(new_pos, cx); + } else { + self.move_to(self.selected_range.start, cx); + } + } + + pub(crate) fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + let new_pos = self.next_boundary(self.cursor_offset()); + self.move_to(new_pos, cx); + } else { + self.move_to(self.selected_range.end, cx); + } + } + + pub(crate) fn up(&mut self, _: &Up, _window: &mut Window, cx: &mut Context) { + self.pause_cursor_blink(cx); + if !self.multiline { + // In single-line mode, up moves to start + self.selected_range = 0..0; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + return; + } + if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) { + self.selected_range = new_offset..new_offset; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + } + + pub(crate) fn down(&mut self, _: &Down, _window: &mut Window, cx: &mut Context) { + self.pause_cursor_blink(cx); + if !self.multiline { + // In single-line mode, down moves to end + let end = self.content.len(); + self.selected_range = end..end; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + return; + } + if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) { + self.selected_range = new_offset..new_offset; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + } + + pub(crate) fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { + self.select_to(self.previous_boundary(self.cursor_offset()), cx); + } + + pub(crate) fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { + self.select_to(self.next_boundary(self.cursor_offset()), cx); + } + + pub(crate) fn select_up(&mut self, _: &SelectUp, _window: &mut Window, cx: &mut Context) { + self.pause_cursor_blink(cx); + if !self.multiline { + // In single-line mode, select_up selects to start + self.select_to(0, cx); + return; + } + if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) { + if self.selection_reversed { + self.selected_range.start = new_offset; + } else { + self.selected_range.end = new_offset; + } + if self.selected_range.end < self.selected_range.start { + self.selection_reversed = !self.selection_reversed; + self.selected_range = self.selected_range.end..self.selected_range.start; + } + self.scroll_to_cursor(); + cx.notify(); + } + } + + pub(crate) fn select_down( + &mut self, + _: &SelectDown, + _window: &mut Window, + cx: &mut Context, + ) { + self.pause_cursor_blink(cx); + if !self.multiline { + // In single-line mode, select_down selects to end + self.select_to(self.content.len(), cx); + return; + } + if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) { + if self.selection_reversed { + self.selected_range.start = new_offset; + } else { + self.selected_range.end = new_offset; + } + if self.selected_range.end < self.selected_range.start { + self.selection_reversed = !self.selection_reversed; + self.selected_range = self.selected_range.end..self.selected_range.start; + } + self.scroll_to_cursor(); + cx.notify(); + } + } + + pub(crate) fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context) { + let line_start = self.find_line_start(self.cursor_offset()); + self.move_to(line_start, cx); + } + + pub(crate) fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context) { + let line_end = self.find_line_end(self.cursor_offset()); + self.move_to(line_end, cx); + } + + pub(crate) fn move_to_beginning( + &mut self, + _: &MoveToBeginning, + _: &mut Window, + cx: &mut Context, + ) { + self.move_to(0, cx); + } + + pub(crate) fn move_to_end(&mut self, _: &MoveToEnd, _: &mut Window, cx: &mut Context) { + self.move_to(self.content.len(), cx); + } + + pub(crate) fn select_to_beginning( + &mut self, + _: &SelectToBeginning, + _: &mut Window, + cx: &mut Context, + ) { + self.select_to(0, cx); + } + + pub(crate) fn select_to_end( + &mut self, + _: &SelectToEnd, + _: &mut Window, + cx: &mut Context, + ) { + self.select_to(self.content.len(), cx); + } + + pub(crate) fn word_left(&mut self, _: &WordLeft, _: &mut Window, cx: &mut Context) { + let new_pos = self.previous_word_boundary(self.cursor_offset()); + self.move_to(new_pos, cx); + } + + pub(crate) fn word_right(&mut self, _: &WordRight, _: &mut Window, cx: &mut Context) { + let new_pos = self.next_word_boundary(self.cursor_offset()); + self.move_to(new_pos, cx); + } + + pub(crate) fn select_word_left( + &mut self, + _: &SelectWordLeft, + _: &mut Window, + cx: &mut Context, + ) { + let new_pos = self.previous_word_boundary(self.cursor_offset()); + self.select_to(new_pos, cx); + } + + pub(crate) fn select_word_right( + &mut self, + _: &SelectWordRight, + _: &mut Window, + cx: &mut Context, + ) { + let new_pos = self.next_word_boundary(self.cursor_offset()); + self.select_to(new_pos, cx); + } + + pub(crate) fn enter(&mut self, _: &Enter, window: &mut Window, cx: &mut Context) { + if self.multiline { + self.replace_text_in_range(None, "\n", window, cx); + } + } + + pub(crate) fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { + self.replace_text_in_range(None, "\t", window, cx); + } + + pub(crate) fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + self.select_to(self.previous_boundary(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + self.select_to(self.next_boundary(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn delete_word_left( + &mut self, + _: &DeleteWordLeft, + window: &mut Window, + cx: &mut Context, + ) { + if self.selected_range.is_empty() { + self.select_to(self.previous_word_boundary(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn delete_word_right( + &mut self, + _: &DeleteWordRight, + window: &mut Window, + cx: &mut Context, + ) { + if self.selected_range.is_empty() { + self.select_to(self.next_word_boundary(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn delete_to_beginning_of_line( + &mut self, + _: &DeleteToBeginningOfLine, + window: &mut Window, + cx: &mut Context, + ) { + if self.selected_range.is_empty() { + self.select_to(self.find_line_start(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn delete_to_end_of_line( + &mut self, + _: &DeleteToEndOfLine, + window: &mut Window, + cx: &mut Context, + ) { + if self.selected_range.is_empty() { + self.select_to(self.find_line_end(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(crate) fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { + if self.multiline { + self.replace_text_in_range(None, &text, window, cx); + } else { + // Strip newlines for single-line input + let text = text.replace('\n', " ").replace('\r', ""); + self.replace_text_in_range(None, &text, window, cx); + } + } + } + + pub(crate) fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { + if !self.selected_range.is_empty() { + cx.write_to_clipboard(ClipboardItem::new_string( + self.content[self.selected_range.clone()].to_string(), + )); + } + } + + pub(crate) fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { + if !self.selected_range.is_empty() { + // Cut selected text + cx.write_to_clipboard(ClipboardItem::new_string( + self.content[self.selected_range.clone()].to_string(), + )); + self.replace_text_in_range(None, "", window, cx); + } else { + // No selection: cut the entire current line (including newline) + let cursor = self.cursor_offset(); + let line_start = self.find_line_start(cursor); + let line_end = self.find_line_end(cursor); + + // Include the newline character if there is one after the line + let cut_end = if line_end < self.content.len() { + line_end + 1 // Include the newline + } else if line_start > 0 { + // Last line with no trailing newline - include preceding newline instead + line_end + } else { + line_end + }; + + // For last line, also remove the preceding newline if it exists + let cut_start = if line_end >= self.content.len() && line_start > 0 { + line_start - 1 // Include preceding newline for last line + } else { + line_start + }; + + let line_text = self.content[cut_start..cut_end].to_string(); + cx.write_to_clipboard(ClipboardItem::new_string(line_text)); + + self.selected_range = cut_start..cut_end; + self.replace_text_in_range(None, "", window, cx); + } + } + + pub(crate) fn on_mouse_down( + &mut self, + position: Point, + click_count: usize, + shift: bool, + window: &mut Window, + cx: &mut Context, + ) { + window.focus(&self.focus_handle, cx); + self.is_selecting = true; + + let is_same_position = self + .last_click_position + .map(|last| { + let threshold = px(4.); + (position.x - last.x).abs() < threshold && (position.y - last.y).abs() < threshold + }) + .unwrap_or(false); + + if is_same_position && click_count > 1 { + self.click_count = click_count; + } else { + self.click_count = 1; + } + self.last_click_position = Some(position); + + let clicked_offset = self.index_for_position(position); + + match self.click_count { + 2 => { + let (word_start, word_end) = self.word_range_at(clicked_offset); + self.selected_range = word_start..word_end; + self.selection_reversed = false; + cx.notify(); + } + 3 => { + let line_start = self.find_line_start(clicked_offset); + let line_end = self.find_line_end(clicked_offset); + let line_end_with_newline = if line_end < self.content.len() { + line_end + 1 + } else { + line_end + }; + self.selected_range = line_start..line_end_with_newline; + self.selection_reversed = false; + cx.notify(); + } + _ => { + if shift { + self.select_to(clicked_offset, cx); + } else { + self.move_to(clicked_offset, cx); + } + } + } + } + + pub(crate) fn on_mouse_up(&mut self, _cx: &mut Context) { + self.is_selecting = false; + } + + pub(crate) fn on_mouse_move(&mut self, position: Point, cx: &mut Context) { + if self.is_selecting && self.click_count == 1 { + self.select_to(self.index_for_position(position), cx); + } + } + + fn move_to(&mut self, offset: usize, cx: &mut Context) { + self.pause_cursor_blink(cx); + let offset = offset.min(self.content.len()); + self.selected_range = offset..offset; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + + fn select_to(&mut self, offset: usize, cx: &mut Context) { + self.pause_cursor_blink(cx); + let offset = offset.min(self.content.len()); + if self.selection_reversed { + self.selected_range.start = offset; + } else { + self.selected_range.end = offset; + } + if self.selected_range.end < self.selected_range.start { + self.selection_reversed = !self.selection_reversed; + self.selected_range = self.selected_range.end..self.selected_range.start; + } + self.scroll_to_cursor(); + cx.notify(); + } + + pub(crate) fn find_line_start(&self, offset: usize) -> usize { + self.content[..offset.min(self.content.len())] + .rfind('\n') + .map(|pos| pos + 1) + .unwrap_or(0) + } + + pub(crate) fn find_line_end(&self, offset: usize) -> usize { + self.content[offset.min(self.content.len())..] + .find('\n') + .map(|pos| offset + pos) + .unwrap_or(self.content.len()) + } + + fn move_vertically(&self, offset: usize, direction: i32) -> Option { + let (visual_line_idx, x_pixels) = self.find_visual_line_and_x_offset(offset); + let target_visual_line_idx = (visual_line_idx as i32 + direction).max(0) as usize; + + let mut current_visual_line = 0; + for layout in self.line_layouts.iter() { + let visual_lines_in_layout = layout.visual_line_count; + + if target_visual_line_idx < current_visual_line + visual_lines_in_layout { + let visual_line_within_layout = target_visual_line_idx - current_visual_line; + + if layout.text_range.is_empty() { + return Some(layout.text_range.start); + } + + if let Some(wrapped) = &layout.wrapped_line { + let y_within_wrapped = self.line_height * visual_line_within_layout as f32; + let target_point = point(px(x_pixels), y_within_wrapped); + + let closest_result = + wrapped.closest_index_for_position(target_point, self.line_height); + + let closest_idx = closest_result.unwrap_or_else(|closest| closest); + let clamped = closest_idx.min(wrapped.text.len()); + let result = layout.text_range.start + clamped; + + return Some(result); + } + + return Some(layout.text_range.start); + } + + current_visual_line += visual_lines_in_layout; + } + + if direction > 0 { + Some(self.content.len()) + } else { + None + } + } + + fn find_visual_line_and_x_offset(&self, offset: usize) -> (usize, f32) { + if self.line_layouts.is_empty() { + return (0, 0.0); + } + + let mut visual_line_idx = 0; + + for line in &self.line_layouts { + if line.text_range.is_empty() { + if offset == line.text_range.start { + return (visual_line_idx, 0.0); + } + } else if offset >= line.text_range.start && offset <= line.text_range.end { + if let Some(wrapped) = &line.wrapped_line { + let local_offset = (offset - line.text_range.start).min(wrapped.text.len()); + if let Some(position) = + wrapped.position_for_index(local_offset, self.line_height) + { + let visual_line_within = (position.y / self.line_height).floor() as usize; + return (visual_line_idx + visual_line_within, position.x.into()); + } + } + return (visual_line_idx, 0.0); + } + visual_line_idx += line.visual_line_count; + } + + (visual_line_idx.saturating_sub(1), 0.0) + } + + pub(crate) fn index_for_position(&self, position: Point) -> usize { + if self.content.is_empty() { + return 0; + } + + for line in self.line_layouts.iter() { + let line_height_total = self.line_height * line.visual_line_count as f32; + + if position.y >= line.y_offset && position.y < line.y_offset + line_height_total { + if line.text_range.is_empty() { + return line.text_range.start; + } + + if let Some(wrapped) = &line.wrapped_line { + let relative_y = position.y - line.y_offset; + let relative_point = point(position.x, relative_y); + + let closest_result = + wrapped.closest_index_for_position(relative_point, self.line_height); + + let local_idx = closest_result.unwrap_or_else(|closest| closest); + let clamped = local_idx.min(wrapped.text.len()); + return line.text_range.start + clamped; + } + return line.text_range.start; + } + } + + self.content.len() + } + + pub(crate) fn scroll_to_cursor(&mut self) { + if self.line_layouts.is_empty() { + return; + } + + let cursor_offset = self.cursor_offset(); + + if self.multiline { + self.scroll_to_cursor_vertical(cursor_offset); + } else { + self.scroll_to_cursor_horizontal(cursor_offset); + } + } + + fn scroll_to_cursor_vertical(&mut self, cursor_offset: usize) { + if self.available_height <= px(0.) { + return; + } + + let line_height = self.line_height; + + for line in &self.line_layouts { + let is_cursor_in_line = if line.text_range.is_empty() { + cursor_offset == line.text_range.start + } else { + line.text_range.contains(&cursor_offset) + || (cursor_offset == line.text_range.end && cursor_offset == self.content.len()) + }; + + if is_cursor_in_line { + let cursor_visual_y = if let Some(wrapped) = &line.wrapped_line { + let local_offset = cursor_offset.saturating_sub(line.text_range.start); + if let Some(position) = + wrapped.position_for_index(local_offset, self.line_height) + { + line.y_offset + position.y + } else { + line.y_offset + } + } else { + line.y_offset + }; + + let visible_top = self.scroll_offset; + let visible_bottom = self.scroll_offset + self.available_height; + + if cursor_visual_y < visible_top { + self.scroll_offset = cursor_visual_y; + } else if cursor_visual_y + line_height > visible_bottom { + self.scroll_offset = (cursor_visual_y + line_height) - self.available_height; + } + + self.scroll_offset = self.scroll_offset.max(px(0.)); + break; + } + } + } + + fn scroll_to_cursor_horizontal(&mut self, cursor_offset: usize) { + if self.available_width <= px(0.) { + return; + } + + // For single-line input, get cursor x position from the first (only) line + let Some(line) = self.line_layouts.first() else { + return; + }; + + let cursor_x = if let Some(wrapped) = &line.wrapped_line { + let local_offset = cursor_offset.saturating_sub(line.text_range.start); + wrapped + .position_for_index(local_offset, self.line_height) + .map(|p| p.x) + .unwrap_or(px(0.)) + } else { + px(0.) + }; + + let visible_left = self.scroll_offset; + let visible_right = self.scroll_offset + self.available_width; + + // Add some padding so cursor isn't right at the edge + let padding = px(2.0); + + if cursor_x < visible_left + padding { + self.scroll_offset = (cursor_x - padding).max(px(0.)); + } else if cursor_x > visible_right - padding { + self.scroll_offset = cursor_x - self.available_width + padding; + } + + self.scroll_offset = self.scroll_offset.max(px(0.)); + } + + pub(crate) fn update_line_layouts( + &mut self, + width: Pixels, + line_height: Pixels, + text_style: &TextStyle, + window: &mut Window, + ) { + self.line_height = line_height; + self.set_text_style(text_style); + + if !self.needs_layout && self.wrap_width == Some(width) { + return; + } + + self.line_layouts.clear(); + self.wrap_width = Some(width); + + let text_color = text_style.color; + let font_size = text_style.font_size.to_pixels(window.rem_size()); + + if self.content.is_empty() { + self.line_layouts.push(InputLineLayout { + text_range: 0..0, + wrapped_line: None, + y_offset: px(0.), + visual_line_count: 1, + }); + self.needs_layout = false; + return; + } + + let mut y_offset = px(0.); + let mut current_pos = 0; + + while current_pos < self.content.len() { + let line_end = self.content[current_pos..] + .find('\n') + .map(|pos| current_pos + pos) + .unwrap_or(self.content.len()); + + let line_text = &self.content[current_pos..line_end]; + + if line_text.is_empty() { + self.line_layouts.push(InputLineLayout { + text_range: current_pos..current_pos, + wrapped_line: None, + y_offset, + visual_line_count: 1, + }); + y_offset += line_height; + } else { + let run = TextRun { + len: line_text.len(), + font: text_style.font(), + color: text_color, + background_color: None, + underline: None, + strikethrough: None, + }; + + let wrapped_lines = window + .text_system() + .shape_text( + SharedString::from(line_text.to_string()), + font_size, + &[run], + Some(width), + None, + ) + .unwrap_or_default(); + + for wrapped in wrapped_lines { + let visual_line_count = wrapped.wrap_boundaries().len() + 1; + let line_height_total = line_height * visual_line_count as f32; + + self.line_layouts.push(InputLineLayout { + text_range: current_pos..line_end, + wrapped_line: Some(Arc::new(wrapped)), + y_offset, + visual_line_count, + }); + + y_offset += line_height_total; + } + } + + current_pos = if line_end < self.content.len() { + line_end + 1 + } else { + self.content.len() + }; + } + + if self.content.ends_with('\n') { + self.line_layouts.push(InputLineLayout { + text_range: self.content.len()..self.content.len(), + wrapped_line: None, + y_offset, + visual_line_count: 1, + }); + } + + self.needs_layout = false; + self.scroll_to_cursor(); + } + + pub(crate) fn total_content_height(&self) -> Pixels { + self.line_layouts + .last() + .map(|last| last.y_offset + self.line_height * last.visual_line_count as f32) + .unwrap_or(px(0.)) + } + + /// Returns true if the scroll position is at the top. + pub fn at_top(&self) -> bool { + self.scroll_offset <= px(0.) + } + + /// Returns true if the scroll position is at the bottom. + pub fn at_bottom(&self) -> bool { + let content_height = self.total_content_height(); + let visible_height = self.available_height; + + if content_height <= visible_height { + return true; + } + + self.scroll_offset + visible_height >= content_height + } + + /// Returns the scroll progress as a value from 0.0 (top) to 1.0 (bottom). + pub fn scroll_progress(&self) -> f32 { + let content_height = self.total_content_height(); + let visible_height = self.available_height; + let max_scroll = content_height - visible_height; + + if max_scroll <= px(0.) { + return 0.0; + } + + (self.scroll_offset / max_scroll).clamp(0.0, 1.0) + } + + /// Returns how far the content is scrolled from the top in pixels. + pub fn distance_from_top(&self) -> Pixels { + self.scroll_offset.max(px(0.)) + } + + /// Returns how far the content is from the bottom in pixels. + pub fn distance_from_bottom(&self) -> Pixels { + let content_height = self.total_content_height(); + let visible_height = self.available_height; + let max_scroll = content_height - visible_height; + + if max_scroll <= px(0.) { + return px(0.); + } + + (max_scroll - self.scroll_offset).max(px(0.)) + } + + fn previous_boundary(&self, offset: usize) -> usize { + if offset == 0 { + return 0; + } + + let text_before = &self.content[..offset.min(self.content.len())]; + text_before + .grapheme_indices(true) + .map(|(i, _)| i) + .next_back() + .unwrap_or(0) + } + + fn next_boundary(&self, offset: usize) -> usize { + if offset >= self.content.len() { + return self.content.len(); + } + + let text_after = &self.content[offset..]; + text_after + .grapheme_indices(true) + .nth(1) + .map(|(i, _)| offset + i) + .unwrap_or(self.content.len()) + } + + fn previous_word_boundary(&self, offset: usize) -> usize { + if offset == 0 { + return 0; + } + + let text_before = &self.content[..offset.min(self.content.len())]; + + let mut last_word_start = 0; + for (idx, _) in text_before.unicode_word_indices() { + if idx < offset { + last_word_start = idx; + } + } + + if last_word_start == 0 && offset > 0 { + let trimmed = text_before.trim_end(); + if trimmed.is_empty() { + return 0; + } + for (idx, _) in trimmed.unicode_word_indices() { + last_word_start = idx; + } + } + + last_word_start + } + + fn next_word_boundary(&self, offset: usize) -> usize { + if offset >= self.content.len() { + return self.content.len(); + } + + let text_after = &self.content[offset..]; + + for (idx, word) in text_after.unicode_word_indices() { + let word_end = offset + idx + word.len(); + if word_end > offset { + return word_end; + } + } + + self.content.len() + } + + fn word_range_at(&self, offset: usize) -> (usize, usize) { + let offset = offset.min(self.content.len()); + + for (idx, word) in self.content.unicode_word_indices() { + let word_end = idx + word.len(); + if offset >= idx && offset <= word_end { + return (idx, word_end); + } + } + + (offset, offset) + } +} diff --git a/crates/gpui_elements/src/input/state_input_handler.rs b/crates/gpui_elements/src/input/state_input_handler.rs new file mode 100644 index 0000000000..94274ebfd3 --- /dev/null +++ b/crates/gpui_elements/src/input/state_input_handler.rs @@ -0,0 +1,229 @@ +use super::unicode::UnicodeString; +use crate::input::InputStateEvent; +use gpui::{Bounds, Context, EntityInputHandler, Pixels, Point, UTF16Selection, Window, point, px}; +use std::ops::Range; + +impl EntityInputHandler for super::InputState { + fn text_for_range( + &mut self, + range_utf16: Range, + adjusted_range: &mut Option>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + let range = self.utf_range_16to8(&range_utf16); + let clamped_range = + range.start.min(self.content().len())..range.end.min(self.content().len()); + adjusted_range.replace(self.utf_range_8to16(&clamped_range)); + Some(self.content()[clamped_range].to_string()) + } + + fn selected_text_range( + &mut self, + _ignore_disabled_input: bool, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + Some(UTF16Selection { + range: self.utf_range_8to16(&self.selected_range), + reversed: self.selection_reversed, + }) + } + + fn marked_text_range( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + self.marked_range + .as_ref() + .map(|range| self.utf_range_8to16(range)) + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { + self.marked_range = None; + } + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + _window: &mut Window, + cx: &mut Context, + ) { + let range = range_utf16 + .as_ref() + .map(|range_utf16| self.utf_range_16to8(range_utf16)) + .or(self.marked_range.clone()) + .unwrap_or(self.selected_range.clone()); + + let range = range.start.min(self.content().len())..range.end.min(self.content().len()); + + // Strip newlines for single-line input + let sanitized_text; + let text_to_insert = if self.multiline { + new_text + } else { + sanitized_text = new_text.replace('\n', " ").replace('\r', ""); + &sanitized_text + }; + + // Record patch for undo before modifying content + self.push_undo_patch(range.clone(), text_to_insert.len()); + + // Update cached UTF-16 length incrementally if available + if let Some(cached_len) = self.cached_utf16_len { + let removed_utf16_len: usize = self.content()[range.clone()] + .chars() + .map(|c| c.len_utf16()) + .sum(); + let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum(); + self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); + } + + self.content_mut() + .replace_range(range.clone(), text_to_insert); + self.selected_range = + range.start + text_to_insert.len()..range.start + text_to_insert.len(); + self.marked_range.take(); + self.needs_layout = true; + self.pause_cursor_blink(cx); + cx.emit(InputStateEvent::TextChanged); + cx.notify(); + } + + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + new_selected_range_utf16: Option>, + _window: &mut Window, + cx: &mut Context, + ) { + let range = range_utf16 + .as_ref() + .map(|range_utf16| self.utf_range_16to8(range_utf16)) + .or(self.marked_range.clone()) + .unwrap_or(self.selected_range.clone()); + + let range = range.start.min(self.content().len())..range.end.min(self.content().len()); + + // Strip newlines for single-line input + let sanitized_text; + let text_to_insert = if self.multiline { + new_text + } else { + sanitized_text = new_text.replace('\n', " ").replace('\r', ""); + &sanitized_text + }; + + // Update cached UTF-16 length incrementally if available + if let Some(cached_len) = self.cached_utf16_len { + let removed_utf16_len: usize = self.content()[range.clone()] + .chars() + .map(|c| c.len_utf16()) + .sum(); + let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum(); + self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); + } + + self.content_mut() + .replace_range(range.clone(), text_to_insert); + + if !text_to_insert.is_empty() { + self.marked_range = Some(range.start..range.start + text_to_insert.len()); + } else { + self.marked_range = None; + } + + self.selected_range = new_selected_range_utf16 + .as_ref() + .map(|range_utf16| self.utf_range_16to8(range_utf16)) + .map(|new_range| new_range.start + range.start..new_range.end + range.start) + .unwrap_or_else(|| { + range.start + text_to_insert.len()..range.start + text_to_insert.len() + }); + + self.needs_layout = true; + cx.emit(InputStateEvent::TextChanged); + cx.notify(); + } + + fn bounds_for_range( + &mut self, + range_utf16: Range, + bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + let range = self.utf_range_16to8(&range_utf16); + + for line in &self.line_layouts { + if line.text_range.is_empty() { + if range.start == line.text_range.start { + return Some(Bounds::from_corners( + point(bounds.left(), bounds.top() + line.y_offset), + point( + bounds.left() + px(4.), + bounds.top() + line.y_offset + self.line_height, + ), + )); + } + } else if line.text_range.contains(&range.start) { + if let Some(wrapped) = &line.wrapped_line { + let local_start = range.start - line.text_range.start; + let local_end = (range.end - line.text_range.start).min(wrapped.text.len()); + + let start_pos = wrapped + .position_for_index(local_start, self.line_height) + .unwrap_or(point(px(0.), px(0.))); + let end_pos = wrapped + .position_for_index(local_end, self.line_height) + .unwrap_or_else(|| { + let last_line_y = + self.line_height * (line.visual_line_count - 1) as f32; + point(wrapped.width(), last_line_y) + }); + + let start_visual_line = (start_pos.y / self.line_height).floor() as usize; + let end_visual_line = (end_pos.y / self.line_height).floor() as usize; + + if start_visual_line == end_visual_line { + return Some(Bounds::from_corners( + point( + bounds.left() + start_pos.x, + bounds.top() + line.y_offset + start_pos.y, + ), + point( + bounds.left() + end_pos.x, + bounds.top() + line.y_offset + start_pos.y + self.line_height, + ), + )); + } else { + return Some(Bounds::from_corners( + point( + bounds.left() + start_pos.x, + bounds.top() + line.y_offset + start_pos.y, + ), + point( + bounds.left() + wrapped.width(), + bounds.top() + line.y_offset + start_pos.y + self.line_height, + ), + )); + } + } + } + } + None + } + + fn character_index_for_point( + &mut self, + point: Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + let index = self.index_for_position(point); + Some(self.utf_offset_8to16(index)) + } +} diff --git a/crates/gpui_elements/src/input/unicode.rs b/crates/gpui_elements/src/input/unicode.rs new file mode 100644 index 0000000000..dc8c56b5e7 --- /dev/null +++ b/crates/gpui_elements/src/input/unicode.rs @@ -0,0 +1,85 @@ +use std::ops::Range; + +pub trait UnicodeString { + fn len_utf16_cached(&self) -> Option; + /// Returns a reference to the utf8 string. + fn content_utf8(&self) -> &str; + /// Returns the UTF-16 length of the content. + fn len_utf16(&self) -> usize; + + fn utf_offset_8to16(&self, pos_uft8: usize) -> usize { + // Fast path: if offset is 0, return 0 + if pos_uft8 == 0 { + return 0; + } + + // Fast path: if offset is at or past end, return cached length + if pos_uft8 >= self.content_utf8().len() { + return self.len_utf16(); + } + + let mut pos_utf16 = 0; + let mut counter_utf8 = 0; + + for character in self.content_utf8().chars() { + if counter_utf8 >= pos_uft8 { + break; + } + counter_utf8 += character.len_utf8(); + pos_utf16 += character.len_utf16(); + } + + pos_utf16 + } + + fn utf_offset_16to8(&self, pos_utf16: usize) -> usize { + // Fast path: if offset is 0, return 0 + if pos_utf16 == 0 { + return 0; + } + + // Fast path: if we have cached length and offset is at or past end + if let Some(utf16_len) = self.len_utf16_cached() { + if pos_utf16 >= utf16_len { + return self.content_utf8().len(); + } + } + + let mut pos_utf8 = 0; + let mut counter_utf16 = 0; + + for character in self.content_utf8().chars() { + if counter_utf16 >= pos_utf16 { + break; + } + counter_utf16 += character.len_utf16(); + pos_utf8 += character.len_utf8(); + } + + pos_utf8.min(self.content_utf8().len()) + } + + fn utf_range_8to16(&self, range_utf8: &Range) -> Range { + self.utf_offset_8to16(range_utf8.start)..self.utf_offset_8to16(range_utf8.end) + } + + fn utf_range_16to8(&self, range_utf16: &Range) -> Range { + self.utf_offset_16to8(range_utf16.start)..self.utf_offset_16to8(range_utf16.end) + } +} +impl UnicodeString for super::InputState { + fn len_utf16_cached(&self) -> Option { + self.cached_utf16_len + } + + fn content_utf8(&self) -> &str { + self.content() + } + + fn len_utf16(&self) -> usize { + if let Some(len) = self.cached_utf16_len { + return len; + } + self.content_utf8().chars().map(|c| c.len_utf16()).sum() + } +} From 9c212a52f79b93f8064f291349ed06caa5914917 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Fri, 5 Jun 2026 17:28:58 -0400 Subject: [PATCH 008/117] replace multiline with InputLayout enum --- crates/gpui_elements/src/input.rs | 2 + crates/gpui_elements/src/input/element.rs | 2 - crates/gpui_elements/src/input/layout.rs | 24 +++ crates/gpui_elements/src/input/paint.rs | 146 +++++++------- crates/gpui_elements/src/input/state.rs | 180 +++++++++--------- .../src/input/state_input_handler.rs | 22 +-- 6 files changed, 184 insertions(+), 192 deletions(-) create mode 100644 crates/gpui_elements/src/input/layout.rs diff --git a/crates/gpui_elements/src/input.rs b/crates/gpui_elements/src/input.rs index fef44ff5e8..ae67ff0e12 100644 --- a/crates/gpui_elements/src/input.rs +++ b/crates/gpui_elements/src/input.rs @@ -3,6 +3,7 @@ mod colors; mod cursor; mod element; mod history; +mod layout; mod paint; mod state; mod state_input_handler; @@ -12,4 +13,5 @@ pub use colors::*; pub(self) use cursor::*; pub use element::*; pub(self) use history::*; +pub use layout::*; pub use state::*; diff --git a/crates/gpui_elements/src/input/element.rs b/crates/gpui_elements/src/input/element.rs index f77539479b..f016ee4292 100644 --- a/crates/gpui_elements/src/input/element.rs +++ b/crates/gpui_elements/src/input/element.rs @@ -15,7 +15,6 @@ pub struct Input { pub(super) interactivity: Interactivity, pub(super) placeholder: Option, pub(super) colors: PaintColors, - pub(super) multiline: bool, } impl Input { @@ -27,7 +26,6 @@ impl Input { interactivity: Interactivity::new(), placeholder: None, colors: PaintColors::default(), - multiline: false, }; input.register_actions(); input diff --git a/crates/gpui_elements/src/input/layout.rs b/crates/gpui_elements/src/input/layout.rs new file mode 100644 index 0000000000..8280d23cff --- /dev/null +++ b/crates/gpui_elements/src/input/layout.rs @@ -0,0 +1,24 @@ +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum InputLayout { + SingleLine, + MultiLine, +} + +impl InputLayout { + pub(super) fn sanitize_content<'s>(&self, content: &'s str) -> std::borrow::Cow<'s, str> { + match self { + // Strip newlines for single-line input + Self::SingleLine => { + std::borrow::Cow::Owned(content.replace('\n', " ").replace('\r', "")) + } + Self::MultiLine => std::borrow::Cow::Borrowed(content), + } + } + + pub fn axis(&self) -> gpui::Axis { + match self { + Self::SingleLine => gpui::Axis::Horizontal, + Self::MultiLine => gpui::Axis::Vertical, + } + } +} diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index 8472a017fa..280575ba92 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -1,8 +1,8 @@ use crate::input::{Input, InputLineLayout, InputState, PaintColors}; use gpui::{ - App, Bounds, ContentMask, CursorStyle, DispatchPhase, Element, ElementId, ElementInputHandler, - Entity, FocusHandle, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla, - InspectorElementId, LayoutId, Length, MouseButton, MouseDownEvent, MouseMoveEvent, + Along, App, Bounds, ContentMask, CursorStyle, DispatchPhase, Element, ElementId, + ElementInputHandler, Entity, FocusHandle, Focusable, GlobalElementId, Hitbox, HitboxBehavior, + Hsla, InspectorElementId, LayoutId, Length, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollWheelEvent, SharedString, TextAlign, TextRun, TextStyle, Window, WrappedLine, fill, point, px, relative, size, }; @@ -39,7 +39,6 @@ impl Element for Input { cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { let mut resolved_text_style = None; - let multiline = self.multiline; let layout_id = self.interactivity.request_layout( global_id, @@ -47,11 +46,12 @@ impl Element for Input { window, cx, |element_style, window, cx| { + let layout = self.input.read(cx).get_layout(); window.with_text_style(element_style.text_style().cloned(), |window| { resolved_text_style = Some(window.text_style()); let mut layout_style = element_style.clone(); - if multiline { + if matches!(layout, super::InputLayout::MultiLine) { if let Length::Auto = layout_style.size.width { layout_style.size.width = relative(1.).into(); } @@ -85,10 +85,9 @@ impl Element for Input { .text_style .line_height_in_pixels(window.rem_size()); - let wrap_width = if self.multiline { - bounds.size.width - } else { - px(100000.) + let wrap_width = match self.input.read(cx).get_layout() { + super::InputLayout::SingleLine => px(100000.), + super::InputLayout::MultiLine => bounds.size.width, }; self.input.update(cx, |input, _cx| { @@ -137,7 +136,7 @@ impl Element for Input { let input = self.input.clone(); let placeholder = self.placeholder.clone(); let text_style = layout_state.text_style.clone(); - let multiline = self.multiline; + let layout = input.read(cx).get_layout(); let is_focused = focus_handle.is_focused(window); let cursor_visible = self .input @@ -152,34 +151,31 @@ impl Element for Input { window, cx, |_style, window, cx| { - handle_mouse(&input, bounds, multiline, window, cx); + handle_mouse(&input, bounds, layout.axis(), window, cx); - window.with_content_mask(Some(ContentMask { bounds }), |window| { - if multiline { - paint_multiline( - &input, - &focus_handle, - bounds, - &text_style, - placeholder.as_ref(), - &colors, - cursor_visible, - window, - cx, - ); - } else { - paint_singleline( - &input, - &focus_handle, - bounds, - &text_style, - placeholder.as_ref(), - &colors, - cursor_visible, - window, - cx, - ); - } + window.with_content_mask(Some(ContentMask { bounds }), |window| match layout { + super::InputLayout::SingleLine => paint_singleline( + &input, + &focus_handle, + bounds, + &text_style, + placeholder.as_ref(), + &colors, + cursor_visible, + window, + cx, + ), + super::InputLayout::MultiLine => paint_multiline( + &input, + &focus_handle, + bounds, + &text_style, + placeholder.as_ref(), + &colors, + cursor_visible, + window, + cx, + ), }); }, ); @@ -190,20 +186,20 @@ impl Element for Input { fn handle_mouse( input: &Entity, bounds: Bounds, - multiline: bool, + axis: gpui::Axis, window: &mut Window, cx: &App, ) { - mouse_down(input.clone(), bounds, multiline, window); + mouse_down(input.clone(), bounds, axis, window); mouse_up(input.clone(), window); - mouse_move(input.clone(), bounds, multiline, window); - handle_scroll(input.clone(), bounds, multiline, window, cx); + mouse_move(input.clone(), bounds, axis, window); + handle_scroll(input.clone(), bounds, axis, window, cx); } fn mouse_down( input: Entity, bounds: Bounds, - multiline: bool, + axis: gpui::Axis, window: &mut Window, ) { window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { @@ -219,7 +215,7 @@ fn mouse_down( input.update(cx, |input, cx| { let text_position = - screen_to_text_position(event.position, bounds, input.scroll_offset, multiline); + screen_to_text_position(event.position, bounds, input.scroll_offset, axis); input.on_mouse_down( text_position, event.click_count, @@ -249,7 +245,7 @@ fn mouse_up(input: Entity, window: &mut Window) { fn mouse_move( input: Entity, bounds: Bounds, - multiline: bool, + axis: gpui::Axis, window: &mut Window, ) { window.on_mouse_event(move |event: &MouseMoveEvent, phase, _window, cx| { @@ -259,7 +255,7 @@ fn mouse_move( input.update(cx, |input, cx| { let text_position = - screen_to_text_position(event.position, bounds, input.scroll_offset, multiline); + screen_to_text_position(event.position, bounds, input.scroll_offset, axis); input.on_mouse_move(text_position, cx); }); }); @@ -268,23 +264,20 @@ fn mouse_move( fn handle_scroll( input: Entity, bounds: Bounds, - multiline: bool, + axis: gpui::Axis, window: &mut Window, cx: &App, ) { - let max_scroll = if multiline { - let total_height = input.read(cx).total_content_height(); - (total_height - bounds.size.height).max(px(0.)) - } else { - let text_width = input - .read(cx) - .line_layouts - .first() - .and_then(|l| l.wrapped_line.as_ref()) - .map(|w| w.width()) - .unwrap_or(px(0.)); - (text_width - bounds.size.width).max(px(0.)) + let content_size = match axis { + gpui::Axis::Horizontal => { + let state = input.read(cx); + let line = state.line_layouts.first(); + let line = line.and_then(|l| l.wrapped_line.as_ref()); + line.map(|w| w.width()).unwrap_or(px(0.)) + } + gpui::Axis::Vertical => input.read(cx).total_content_height(), }; + let max_scroll = (content_size - bounds.size.along(axis)).max(px(0.)); window.on_mouse_event(move |event: &ScrollWheelEvent, phase, _window, cx| { if phase != DispatchPhase::Bubble { @@ -296,17 +289,17 @@ fn handle_scroll( let pixel_delta = event.delta.pixel_delta(px(20.)); input.update(cx, |input, cx| { - if multiline { - input.scroll_offset = - (input.scroll_offset - pixel_delta.y).clamp(px(0.), max_scroll); - } else { - let delta = if pixel_delta.x.abs() > pixel_delta.y.abs() { - pixel_delta.x - } else { - pixel_delta.y - }; - input.scroll_offset = (input.scroll_offset - delta).clamp(px(0.), max_scroll); - } + let delta = match axis { + gpui::Axis::Horizontal => pixel_delta.y, + gpui::Axis::Vertical => { + if pixel_delta.x.abs() > pixel_delta.y.abs() { + pixel_delta.x + } else { + pixel_delta.y + } + } + }; + input.scroll_offset = (input.scroll_offset - delta).clamp(px(0.), max_scroll); cx.notify(); }); }); @@ -318,19 +311,10 @@ fn screen_to_text_position( screen_position: Point, bounds: Bounds, scroll_offset: Pixels, - multiline: bool, + axis: gpui::Axis, ) -> Point { - if multiline { - point( - screen_position.x - bounds.origin.x, - screen_position.y - bounds.origin.y + scroll_offset, - ) - } else { - point( - screen_position.x - bounds.origin.x + scroll_offset, - screen_position.y - bounds.origin.y, - ) - } + let point = screen_position - bounds.origin; + point.apply_along(axis, |pos| pos + scroll_offset) } fn paint_multiline( diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index d2475e3b52..c7767449e4 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -1,5 +1,5 @@ use super::actions::*; -use crate::input::unicode::UnicodeString; +use crate::input::{InputLayout, unicode::UnicodeString}; use gpui::{ App, AppContext, ClipboardItem, Context, Entity, EntityId, EntityInputHandler, EventEmitter, FocusHandle, Focusable, Pixels, Point, SharedString, Subscription, TextRun, TextStyle, Window, @@ -54,7 +54,7 @@ pub struct InputState { pub(super) scroll_offset: Pixels, pub(super) available_height: Pixels, pub(super) available_width: Pixels, - pub(super) multiline: bool, + pub(super) layout: InputLayout, /// Stack of previous states for undo. undo_stack: Vec, /// Stack of undone states for redo. @@ -121,7 +121,7 @@ impl InputState { scroll_offset: px(0.), available_height: px(0.), available_width: px(0.), - multiline: false, + layout: InputLayout::SingleLine, undo_stack: Vec::new(), cached_utf16_len: None, redo_stack: Vec::new(), @@ -201,16 +201,14 @@ impl InputState { &mut self.content } + pub fn get_layout(&self) -> InputLayout { + self.layout + } + /// Sets the text content, resetting selection to the beginning. /// This clears the undo/redo history. - pub fn set_content(&mut self, content: impl Into, cx: &mut Context) { - let content = content.into(); - self.content = if self.multiline { - content - } else { - // Strip newlines for single-line input - content.replace('\n', " ").replace('\r', "") - }; + pub fn set_content(&mut self, content: impl AsRef, cx: &mut Context) { + self.content = self.layout.sanitize_content(content.as_ref()).to_string(); self.selected_range = 0..0; self.selection_reversed = false; self.marked_range = None; @@ -375,13 +373,7 @@ impl InputState { .unwrap_or(self.selected_range.clone()); let range = range.start.min(self.content.len())..range.end.min(self.content.len()); - let sanitized_text; - let text_to_insert = if self.multiline { - text - } else { - sanitized_text = text.replace('\n', " ").replace('\r', ""); - &sanitized_text - }; + let text_to_insert = self.layout.sanitize_content(text); // Record patch for undo before modifying content self.push_undo_patch(range.clone(), text_to_insert.len()); @@ -396,7 +388,7 @@ impl InputState { self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); } - self.content.replace_range(range.clone(), text_to_insert); + self.content.replace_range(range.clone(), &text_to_insert); self.selected_range = range.start + text_to_insert.len()..range.start + text_to_insert.len(); self.marked_range.take(); @@ -478,38 +470,44 @@ impl InputState { pub(crate) fn up(&mut self, _: &Up, _window: &mut Window, cx: &mut Context) { self.pause_cursor_blink(cx); - if !self.multiline { - // In single-line mode, up moves to start - self.selected_range = 0..0; - self.selection_reversed = false; - self.scroll_to_cursor(); - cx.notify(); - return; - } - if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) { - self.selected_range = new_offset..new_offset; - self.selection_reversed = false; - self.scroll_to_cursor(); - cx.notify(); + match self.layout { + InputLayout::SingleLine => { + // In single-line mode, up moves to start + self.selected_range = 0..0; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + InputLayout::MultiLine => { + if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) { + self.selected_range = new_offset..new_offset; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + } } } pub(crate) fn down(&mut self, _: &Down, _window: &mut Window, cx: &mut Context) { self.pause_cursor_blink(cx); - if !self.multiline { - // In single-line mode, down moves to end - let end = self.content.len(); - self.selected_range = end..end; - self.selection_reversed = false; - self.scroll_to_cursor(); - cx.notify(); - return; - } - if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) { - self.selected_range = new_offset..new_offset; - self.selection_reversed = false; - self.scroll_to_cursor(); - cx.notify(); + match self.layout { + InputLayout::SingleLine => { + // In single-line mode, down moves to end + let end = self.content.len(); + self.selected_range = end..end; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + InputLayout::MultiLine => { + if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) { + self.selected_range = new_offset..new_offset; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + } } } @@ -523,23 +521,26 @@ impl InputState { pub(crate) fn select_up(&mut self, _: &SelectUp, _window: &mut Window, cx: &mut Context) { self.pause_cursor_blink(cx); - if !self.multiline { - // In single-line mode, select_up selects to start - self.select_to(0, cx); - return; - } - if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) { - if self.selection_reversed { - self.selected_range.start = new_offset; - } else { - self.selected_range.end = new_offset; + match self.layout { + InputLayout::SingleLine => { + // In single-line mode, select_up selects to start + self.select_to(0, cx); } - if self.selected_range.end < self.selected_range.start { - self.selection_reversed = !self.selection_reversed; - self.selected_range = self.selected_range.end..self.selected_range.start; + InputLayout::MultiLine => { + if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) { + if self.selection_reversed { + self.selected_range.start = new_offset; + } else { + self.selected_range.end = new_offset; + } + if self.selected_range.end < self.selected_range.start { + self.selection_reversed = !self.selection_reversed; + self.selected_range = self.selected_range.end..self.selected_range.start; + } + self.scroll_to_cursor(); + cx.notify(); + } } - self.scroll_to_cursor(); - cx.notify(); } } @@ -550,23 +551,26 @@ impl InputState { cx: &mut Context, ) { self.pause_cursor_blink(cx); - if !self.multiline { - // In single-line mode, select_down selects to end - self.select_to(self.content.len(), cx); - return; - } - if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) { - if self.selection_reversed { - self.selected_range.start = new_offset; - } else { - self.selected_range.end = new_offset; + match self.layout { + InputLayout::SingleLine => { + // In single-line mode, select_down selects to end + self.select_to(self.content.len(), cx); } - if self.selected_range.end < self.selected_range.start { - self.selection_reversed = !self.selection_reversed; - self.selected_range = self.selected_range.end..self.selected_range.start; + InputLayout::MultiLine => { + if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) { + if self.selection_reversed { + self.selected_range.start = new_offset; + } else { + self.selected_range.end = new_offset; + } + if self.selected_range.end < self.selected_range.start { + self.selection_reversed = !self.selection_reversed; + self.selected_range = self.selected_range.end..self.selected_range.start; + } + self.scroll_to_cursor(); + cx.notify(); + } } - self.scroll_to_cursor(); - cx.notify(); } } @@ -642,7 +646,7 @@ impl InputState { } pub(crate) fn enter(&mut self, _: &Enter, window: &mut Window, cx: &mut Context) { - if self.multiline { + if matches!(&self.layout, InputLayout::MultiLine) { self.replace_text_in_range(None, "\n", window, cx); } } @@ -714,15 +718,11 @@ impl InputState { } pub(crate) fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { - if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { - if self.multiline { - self.replace_text_in_range(None, &text, window, cx); - } else { - // Strip newlines for single-line input - let text = text.replace('\n', " ").replace('\r', ""); - self.replace_text_in_range(None, &text, window, cx); - } - } + let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { + return; + }; + let text = self.layout.sanitize_content(&text); + self.replace_text_in_range(None, &text, window, cx); } pub(crate) fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { @@ -986,11 +986,9 @@ impl InputState { } let cursor_offset = self.cursor_offset(); - - if self.multiline { - self.scroll_to_cursor_vertical(cursor_offset); - } else { - self.scroll_to_cursor_horizontal(cursor_offset); + match self.layout { + InputLayout::SingleLine => self.scroll_to_cursor_horizontal(cursor_offset), + InputLayout::MultiLine => self.scroll_to_cursor_vertical(cursor_offset), } } diff --git a/crates/gpui_elements/src/input/state_input_handler.rs b/crates/gpui_elements/src/input/state_input_handler.rs index 94274ebfd3..2a88a42e27 100644 --- a/crates/gpui_elements/src/input/state_input_handler.rs +++ b/crates/gpui_elements/src/input/state_input_handler.rs @@ -59,14 +59,7 @@ impl EntityInputHandler for super::InputState { let range = range.start.min(self.content().len())..range.end.min(self.content().len()); - // Strip newlines for single-line input - let sanitized_text; - let text_to_insert = if self.multiline { - new_text - } else { - sanitized_text = new_text.replace('\n', " ").replace('\r', ""); - &sanitized_text - }; + let text_to_insert = self.layout.sanitize_content(new_text); // Record patch for undo before modifying content self.push_undo_patch(range.clone(), text_to_insert.len()); @@ -82,7 +75,7 @@ impl EntityInputHandler for super::InputState { } self.content_mut() - .replace_range(range.clone(), text_to_insert); + .replace_range(range.clone(), &text_to_insert); self.selected_range = range.start + text_to_insert.len()..range.start + text_to_insert.len(); self.marked_range.take(); @@ -108,14 +101,7 @@ impl EntityInputHandler for super::InputState { let range = range.start.min(self.content().len())..range.end.min(self.content().len()); - // Strip newlines for single-line input - let sanitized_text; - let text_to_insert = if self.multiline { - new_text - } else { - sanitized_text = new_text.replace('\n', " ").replace('\r', ""); - &sanitized_text - }; + let text_to_insert = self.layout.sanitize_content(new_text); // Update cached UTF-16 length incrementally if available if let Some(cached_len) = self.cached_utf16_len { @@ -128,7 +114,7 @@ impl EntityInputHandler for super::InputState { } self.content_mut() - .replace_range(range.clone(), text_to_insert); + .replace_range(range.clone(), &text_to_insert); if !text_to_insert.is_empty() { self.marked_range = Some(range.start..range.start + text_to_insert.len()); From 3255bb544ea104001be4ce1a116b02d4b0d053d9 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Fri, 5 Jun 2026 17:55:39 -0400 Subject: [PATCH 009/117] replace InputState::content with a SharedString --- crates/gpui_elements/src/input.rs | 11 +++++++++++ crates/gpui_elements/src/input/history.rs | 8 +++++--- crates/gpui_elements/src/input/state.rs | 15 ++++++--------- .../src/input/state_input_handler.rs | 6 ++---- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/crates/gpui_elements/src/input.rs b/crates/gpui_elements/src/input.rs index ae67ff0e12..e4828739c5 100644 --- a/crates/gpui_elements/src/input.rs +++ b/crates/gpui_elements/src/input.rs @@ -15,3 +15,14 @@ pub use element::*; pub(self) use history::*; pub use layout::*; pub use state::*; + +pub(self) fn replace_range( + string: &mut gpui::SharedString, + range: std::ops::Range, + replace_with: &str, +) { + // NOTE: reallocates the SharedString bc SharedString is immutable + let mut content = string.to_string(); + content.replace_range(range, replace_with); + *string = content.into(); +} diff --git a/crates/gpui_elements/src/input/history.rs b/crates/gpui_elements/src/input/history.rs index bc4add269e..40128d21fc 100644 --- a/crates/gpui_elements/src/input/history.rs +++ b/crates/gpui_elements/src/input/history.rs @@ -3,6 +3,8 @@ use std::{ time::{Duration, Instant}, }; +use gpui::SharedString; + /// Maximum number of history entries to keep. pub const MAX_HISTORY_LEN: usize = 1000; @@ -29,7 +31,7 @@ pub struct HistoryEntry { impl HistoryEntry { /// Apply this patch to undo an edit, returning the reverse patch for redo. - pub fn apply_undo(&self, content: &mut String) -> HistoryEntry { + pub fn apply_undo(&self, content: &mut SharedString) -> HistoryEntry { let undo_start = self.range.start; let undo_end = (self.range.start + self.new_text_len).min(content.len()); @@ -37,7 +39,7 @@ impl HistoryEntry { let removed_text = content[undo_start..undo_end].to_string(); // Replace with the old text - content.replace_range(undo_start..undo_end, &self.old_text); + crate::input::replace_range(content, undo_start..undo_end, &self.old_text); // Return reverse patch for redo HistoryEntry { @@ -51,7 +53,7 @@ impl HistoryEntry { } /// Apply this patch to redo an edit, returning the reverse patch for undo. - pub fn apply_redo(&self, content: &mut String) -> HistoryEntry { + pub fn apply_redo(&self, content: &mut SharedString) -> HistoryEntry { // Redo is the same operation as undo - we're reversing the undo self.apply_undo(content) } diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index c7767449e4..da4a10daa5 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -37,7 +37,7 @@ impl EventEmitter for InputState {} pub struct InputState { entity_id: EntityId, focus_handle: FocusHandle, - content: String, + pub(super) content: SharedString, placeholder: SharedString, pub(super) selected_range: Range, pub(super) selection_reversed: bool, @@ -105,7 +105,7 @@ impl InputState { let mut this = Self { entity_id: cx.entity_id(), focus_handle: cx.focus_handle(), - content: String::new(), + content: SharedString::default(), placeholder: SharedString::default(), selected_range: 0..0, selection_reversed: false, @@ -193,14 +193,10 @@ impl InputState { } /// Returns the current text content. - pub fn content(&self) -> &str { + pub fn content(&self) -> &SharedString { &self.content } - pub(super) fn content_mut(&mut self) -> &mut String { - &mut self.content - } - pub fn get_layout(&self) -> InputLayout { self.layout } @@ -208,7 +204,8 @@ impl InputState { /// Sets the text content, resetting selection to the beginning. /// This clears the undo/redo history. pub fn set_content(&mut self, content: impl AsRef, cx: &mut Context) { - self.content = self.layout.sanitize_content(content.as_ref()).to_string(); + let content = self.layout.sanitize_content(content.as_ref()); + self.content = content.to_string().into(); self.selected_range = 0..0; self.selection_reversed = false; self.marked_range = None; @@ -388,7 +385,7 @@ impl InputState { self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); } - self.content.replace_range(range.clone(), &text_to_insert); + crate::input::replace_range(&mut self.content, range.clone(), &text_to_insert); self.selected_range = range.start + text_to_insert.len()..range.start + text_to_insert.len(); self.marked_range.take(); diff --git a/crates/gpui_elements/src/input/state_input_handler.rs b/crates/gpui_elements/src/input/state_input_handler.rs index 2a88a42e27..a9b9e3d481 100644 --- a/crates/gpui_elements/src/input/state_input_handler.rs +++ b/crates/gpui_elements/src/input/state_input_handler.rs @@ -74,8 +74,7 @@ impl EntityInputHandler for super::InputState { self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); } - self.content_mut() - .replace_range(range.clone(), &text_to_insert); + crate::input::replace_range(&mut self.content, range.clone(), &text_to_insert); self.selected_range = range.start + text_to_insert.len()..range.start + text_to_insert.len(); self.marked_range.take(); @@ -113,8 +112,7 @@ impl EntityInputHandler for super::InputState { self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); } - self.content_mut() - .replace_range(range.clone(), &text_to_insert); + crate::input::replace_range(&mut self.content, range.clone(), &text_to_insert); if !text_to_insert.is_empty() { self.marked_range = Some(range.start..range.start + text_to_insert.len()); From 8bec8f3031aef854b4d7d83c520c10888a74383a Mon Sep 17 00:00:00 2001 From: temportalflux Date: Fri, 5 Jun 2026 17:49:16 -0400 Subject: [PATCH 010/117] begin integrating paint implementations --- crates/gpui_elements/src/input/paint.rs | 652 ++++++++++++------------ 1 file changed, 333 insertions(+), 319 deletions(-) diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index 280575ba92..e5e18b0036 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -3,10 +3,10 @@ use gpui::{ Along, App, Bounds, ContentMask, CursorStyle, DispatchPhase, Element, ElementId, ElementInputHandler, Entity, FocusHandle, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, LayoutId, Length, MouseButton, MouseDownEvent, MouseMoveEvent, - MouseUpEvent, Pixels, Point, ScrollWheelEvent, SharedString, TextAlign, TextRun, TextStyle, + MouseUpEvent, Pixels, ScrollWheelEvent, SharedString, Style, TextAlign, TextRun, TextStyle, Window, WrappedLine, fill, point, px, relative, size, }; -use std::sync::Arc; +use std::{ops::Range, sync::Arc}; const CURSOR_WIDTH: f32 = 2.0; const MARKED_TEXT_UNDERLINE_THICKNESS: f32 = 2.0; @@ -133,16 +133,32 @@ impl Element for Input { cx, ); - let input = self.input.clone(); + let snapshot = InputStateSnapshot::new(&self.input, cx); let placeholder = self.placeholder.clone(); let text_style = layout_state.text_style.clone(); - let layout = input.read(cx).get_layout(); let is_focused = focus_handle.is_focused(window); + let colors = self.colors; + + // TODO: refactor cursor_visible so it is clear that it is called on_paint let cursor_visible = self .input .update(cx, |input, cx| input.cursor_visible(is_focused, cx)); - let colors = self.colors; + let perform_paint = |_style: &Style, window: &mut Window, cx: &mut App| { + let context = PaintContext { + snapshot, + focus_handle: &focus_handle, + bounds, + text_style: &text_style, + placeholder: placeholder.as_ref(), + colors: &colors, + cursor_visible, + }; + context.process_mouse_events(&self.input, window, cx); + window.with_content_mask(Some(ContentMask { bounds }), |window| { + context.paint(window, cx); + }); + }; self.interactivity.paint( global_id, inspector_id, @@ -150,256 +166,286 @@ impl Element for Input { prepaint_state.hitbox.as_ref(), window, cx, - |_style, window, cx| { - handle_mouse(&input, bounds, layout.axis(), window, cx); - - window.with_content_mask(Some(ContentMask { bounds }), |window| match layout { - super::InputLayout::SingleLine => paint_singleline( - &input, - &focus_handle, - bounds, - &text_style, - placeholder.as_ref(), - &colors, - cursor_visible, - window, - cx, - ), - super::InputLayout::MultiLine => paint_multiline( - &input, - &focus_handle, - bounds, - &text_style, - placeholder.as_ref(), - &colors, - cursor_visible, - window, - cx, - ), - }); - }, + perform_paint, ); } } -/// Registers all mouse event handlers for the input. -fn handle_mouse( - input: &Entity, - bounds: Bounds, - axis: gpui::Axis, - window: &mut Window, - cx: &App, -) { - mouse_down(input.clone(), bounds, axis, window); - mouse_up(input.clone(), window); - mouse_move(input.clone(), bounds, axis, window); - handle_scroll(input.clone(), bounds, axis, window, cx); +struct InputStateSnapshot { + layout: super::InputLayout, + content: SharedString, + selected_range: Range, + marked_range: Option>, + cursor_offset: usize, + line_layouts: Vec, + scroll_offset: Pixels, + line_height: Pixels, +} +impl InputStateSnapshot { + fn new(entity: &Entity, cx: &App) -> Self { + let input_state = entity.read(cx); + let selected_range = input_state.selected_range().clone(); + let marked_range = input_state.marked_range().cloned(); + let cursor_offset = input_state.cursor_offset(); + let line_layouts = input_state.line_layouts.clone(); + let scroll_offset = input_state.scroll_offset; + let line_height = input_state.line_height; + Self { + layout: input_state.get_layout(), + content: input_state.content().clone(), + selected_range, + marked_range, + cursor_offset, + line_layouts, + scroll_offset, + line_height, + } + } } -fn mouse_down( - input: Entity, +struct PaintContext<'app> { + snapshot: InputStateSnapshot, + focus_handle: &'app FocusHandle, bounds: Bounds, - axis: gpui::Axis, - window: &mut Window, -) { - window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { - if phase != DispatchPhase::Bubble { - return; - } - if !bounds.contains(&event.position) { - return; - } - if event.button != MouseButton::Left { - return; - } + text_style: &'app TextStyle, + placeholder: Option<&'app SharedString>, + colors: &'app PaintColors, + cursor_visible: bool, +} - input.update(cx, |input, cx| { - let text_position = - screen_to_text_position(event.position, bounds, input.scroll_offset, axis); - input.on_mouse_down( - text_position, - event.click_count, - event.modifiers.shift, - window, - cx, - ); +impl<'app> PaintContext<'app> { + pub fn process_mouse_events( + &self, + entity: &Entity, + window: &mut Window, + cx: &mut App, + ) { + let axis = self.snapshot.layout.axis(); + let bounds = self.bounds; + window.on_mouse_event({ + let input = 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; + } + + input.update(cx, |input, cx| { + // Converts a screen position to a position relative to the text area origin, adjusted for scroll offset. + let text_position = (event.position - bounds.origin) + .apply_along(axis, |pos| pos + input.scroll_offset); + input.on_mouse_down( + text_position, + event.click_count, + event.modifiers.shift, + window, + cx, + ); + }); + } }); - }); -} + window.on_mouse_event({ + let input = entity.clone(); + move |event: &MouseUpEvent, phase, _window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + if event.button != MouseButton::Left { + return; + } -fn mouse_up(input: Entity, window: &mut Window) { - window.on_mouse_event(move |event: &MouseUpEvent, phase, _window, cx| { - if phase != DispatchPhase::Bubble { - return; - } - if event.button != MouseButton::Left { - return; - } - - input.update(cx, |input, cx| { - input.on_mouse_up(cx); + input.update(cx, |input, cx| { + input.on_mouse_up(cx); + }); + } }); - }); -} + window.on_mouse_event({ + let input = entity.clone(); + move |event: &MouseMoveEvent, phase, _window, cx| { + if phase != DispatchPhase::Bubble { + return; + } -fn mouse_move( - input: Entity, - bounds: Bounds, - axis: gpui::Axis, - window: &mut Window, -) { - window.on_mouse_event(move |event: &MouseMoveEvent, phase, _window, cx| { - if phase != DispatchPhase::Bubble { - return; - } - - input.update(cx, |input, cx| { - let text_position = - screen_to_text_position(event.position, bounds, input.scroll_offset, axis); - input.on_mouse_move(text_position, cx); + input.update(cx, |input, cx| { + // Converts a screen position to a position relative to the text area origin, adjusted for scroll offset. + let text_position = (event.position - bounds.origin) + .apply_along(axis, |pos| pos + input.scroll_offset); + input.on_mouse_move(text_position, cx); + }); + } }); - }); -} + window.on_mouse_event({ + let input = entity.clone(); + let content_size = match axis { + gpui::Axis::Horizontal => { + let state = input.read(cx); + let line = state.line_layouts.first(); + let line = line.and_then(|l| l.wrapped_line.as_ref()); + line.map(|w| w.width()).unwrap_or(px(0.)) + } + gpui::Axis::Vertical => input.read(cx).total_content_height(), + }; + let max_scroll = (content_size - bounds.size.along(axis)).max(px(0.)); + move |event: &ScrollWheelEvent, phase, _window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + if !bounds.contains(&event.position) { + return; + } -fn handle_scroll( - input: Entity, - bounds: Bounds, - axis: gpui::Axis, - window: &mut Window, - cx: &App, -) { - let content_size = match axis { - gpui::Axis::Horizontal => { - let state = input.read(cx); - let line = state.line_layouts.first(); - let line = line.and_then(|l| l.wrapped_line.as_ref()); - line.map(|w| w.width()).unwrap_or(px(0.)) - } - gpui::Axis::Vertical => input.read(cx).total_content_height(), - }; - let max_scroll = (content_size - bounds.size.along(axis)).max(px(0.)); + let pixel_delta = event.delta.pixel_delta(px(20.)); + input.update(cx, |input, cx| { + let delta = match axis { + gpui::Axis::Horizontal => pixel_delta.y, + gpui::Axis::Vertical => { + if pixel_delta.x.abs() > pixel_delta.y.abs() { + pixel_delta.x + } else { + pixel_delta.y + } + } + }; + input.scroll_offset = (input.scroll_offset - delta).clamp(px(0.), max_scroll); + cx.notify(); + }); + } + }); + } - window.on_mouse_event(move |event: &ScrollWheelEvent, phase, _window, cx| { - if phase != DispatchPhase::Bubble { - return; - } - if !bounds.contains(&event.position) { - return; - } + pub fn paint(&self, window: &mut Window, cx: &mut App) { + match self.snapshot.layout { + super::InputLayout::MultiLine => { + let is_focused = self.focus_handle.is_focused(window); - let pixel_delta = event.delta.pixel_delta(px(20.)); - input.update(cx, |input, cx| { - let delta = match axis { - gpui::Axis::Horizontal => pixel_delta.y, - gpui::Axis::Vertical => { - if pixel_delta.x.abs() > pixel_delta.y.abs() { - pixel_delta.x - } else { - pixel_delta.y + if !self.snapshot.selected_range.is_empty() { + paint_multiline_selection( + &self.snapshot.line_layouts, + &self.snapshot.selected_range, + self.bounds, + self.snapshot.scroll_offset, + self.snapshot.line_height, + self.colors.selection, + window, + ); + } + + if self.snapshot.content.is_empty() { + if let Some(placeholder_str) = self.placeholder { + if !placeholder_str.is_empty() { + paint_placeholder( + placeholder_str, + self.bounds, + self.text_style, + self.colors.placeholder, + window, + cx, + false, + ); + } + } + } else { + paint_multiline_text( + &self.snapshot.line_layouts, + self.bounds, + self.snapshot.scroll_offset, + self.snapshot.line_height, + window, + cx, + ); + } + + if let Some(marked_range) = &self.snapshot.marked_range { + if !marked_range.is_empty() { + paint_multiline_marked_underline( + &self.snapshot.line_layouts, + marked_range, + self.bounds, + self.snapshot.scroll_offset, + self.snapshot.line_height, + self.colors.cursor, + window, + ); } } - }; - input.scroll_offset = (input.scroll_offset - delta).clamp(px(0.), max_scroll); - cx.notify(); - }); - }); -} -/// Converts a screen position to a position relative to the text area origin, -/// adjusted for scroll offset. -fn screen_to_text_position( - screen_position: Point, - bounds: Bounds, - scroll_offset: Pixels, - axis: gpui::Axis, -) -> Point { - let point = screen_position - bounds.origin; - point.apply_along(axis, |pos| pos + scroll_offset) -} + if is_focused && self.snapshot.selected_range.is_empty() && self.cursor_visible { + paint_multiline_cursor( + &self.snapshot.line_layouts, + self.snapshot.cursor_offset, + &self.snapshot.content, + self.bounds, + self.snapshot.scroll_offset, + self.snapshot.line_height, + self.colors.cursor, + window, + ); + } + } + super::InputLayout::SingleLine => { + let state = + SingleLinePaintState::from_input(&self.snapshot, self.focus_handle, window); -fn paint_multiline( - input: &Entity, - focus_handle: &FocusHandle, - bounds: Bounds, - text_style: &TextStyle, - placeholder: Option<&SharedString>, - colors: &PaintColors, - cursor_visible: bool, - window: &mut Window, - cx: &mut App, -) { - let input_state = input.read(cx); - let content = input_state.content().to_string(); - let selected_range = input_state.selected_range().clone(); - let marked_range = input_state.marked_range().cloned(); - let cursor_offset = input_state.cursor_offset(); - let line_layouts = input_state.line_layouts.clone(); - let scroll_offset = input_state.scroll_offset; - let line_height = input_state.line_height; - let is_focused = focus_handle.is_focused(window); + if !self.snapshot.selected_range.is_empty() { + paint_singleline_selection( + &self.snapshot, + &state, + self.bounds, + self.colors.selection, + window, + ); + } - if !selected_range.is_empty() { - paint_multiline_selection( - &line_layouts, - &selected_range, - bounds, - scroll_offset, - line_height, - colors.selection, - window, - ); - } + if self.snapshot.content.is_empty() { + if let Some(placeholder_str) = self.placeholder { + if !placeholder_str.is_empty() { + paint_placeholder( + placeholder_str, + self.bounds, + self.text_style, + self.colors.placeholder, + window, + cx, + true, + ); + } + } + } else { + paint_singleline_text(&self.snapshot, &state, self.bounds, window, cx); + } - if content.is_empty() { - if let Some(placeholder_str) = placeholder { - if !placeholder_str.is_empty() { - paint_placeholder( - placeholder_str, - bounds, - text_style, - colors.placeholder, - window, - cx, - false, - ); + if let Some(marked_range) = &self.snapshot.marked_range { + if !marked_range.is_empty() { + paint_singleline_marked_underline( + &self.snapshot, + &state, + marked_range, + self.bounds, + self.colors.cursor, + window, + ); + } + } + + if state.is_focused + && self.snapshot.selected_range.is_empty() + && self.cursor_visible + { + paint_singleline_cursor( + &self.snapshot, + &state, + self.bounds, + self.colors.cursor, + window, + ); + } } } - } else { - paint_multiline_text( - &line_layouts, - bounds, - scroll_offset, - line_height, - window, - cx, - ); - } - - if let Some(marked_range) = &marked_range { - if !marked_range.is_empty() { - paint_multiline_marked_underline( - &line_layouts, - marked_range, - bounds, - scroll_offset, - line_height, - colors.cursor, - window, - ); - } - } - - if is_focused && selected_range.is_empty() && cursor_visible { - paint_multiline_cursor( - &line_layouts, - cursor_offset, - &content, - bounds, - scroll_offset, - line_height, - colors.cursor, - window, - ); } } @@ -756,12 +802,6 @@ fn paint_multiline_cursor( /// State for single-line painting that pre-computes character positions. struct SingleLinePaintState { - content: String, - selected_range: std::ops::Range, - marked_range: Option>, - cursor_offset: usize, - scroll_offset: Pixels, - line_height: Pixels, text_width: Pixels, is_focused: bool, char_positions: Vec, @@ -770,23 +810,20 @@ struct SingleLinePaintState { impl SingleLinePaintState { fn from_input( - input: &Entity, + snapshot: &InputStateSnapshot, focus_handle: &FocusHandle, window: &Window, - cx: &App, ) -> Self { - let input_state = input.read(cx); - let mut char_positions = Vec::new(); let mut text_width = px(0.); - if let Some(line) = input_state.line_layouts.first() { + if let Some(line) = snapshot.line_layouts.first() { if let Some(wrapped) = &line.wrapped_line { text_width = wrapped.width(); - let content = input_state.content(); + let content = &snapshot.content; let mut idx = 0; for ch in content.chars() { - if let Some(pos) = wrapped.position_for_index(idx, input_state.line_height) { + if let Some(pos) = wrapped.position_for_index(idx, snapshot.line_height) { char_positions.push(pos.x); } else { char_positions.push(text_width); @@ -797,99 +834,58 @@ impl SingleLinePaintState { } } - let wrapped_line = input_state + let wrapped_line = snapshot .line_layouts .first() .and_then(|l| l.wrapped_line.clone()); Self { - content: input_state.content().to_string(), - selected_range: input_state.selected_range().clone(), - marked_range: input_state.marked_range().cloned(), - cursor_offset: input_state.cursor_offset(), - scroll_offset: input_state.scroll_offset, - line_height: input_state.line_height, text_width, is_focused: focus_handle.is_focused(window), char_positions, wrapped_line, } } - - fn x_for_index(&self, index: usize) -> Pixels { - let char_index = self.content[..index.min(self.content.len())] - .chars() - .count(); - self.char_positions - .get(char_index) - .copied() - .unwrap_or(self.text_width) - } } -fn paint_singleline( - input: &Entity, - focus_handle: &FocusHandle, - bounds: Bounds, - text_style: &TextStyle, - placeholder: Option<&SharedString>, - colors: &PaintColors, - cursor_visible: bool, - window: &mut Window, - cx: &mut App, -) { - let state = SingleLinePaintState::from_input(input, focus_handle, window, cx); - - if !state.selected_range.is_empty() { - paint_singleline_selection(&state, bounds, colors.selection, window); - } - - if state.content.is_empty() { - if let Some(placeholder_str) = placeholder { - if !placeholder_str.is_empty() { - paint_placeholder( - placeholder_str, - bounds, - text_style, - colors.placeholder, - window, - cx, - true, - ); - } - } - } else { - paint_singleline_text(&state, bounds, window, cx); - } - - if let Some(marked_range) = &state.marked_range { - if !marked_range.is_empty() { - paint_singleline_marked_underline(&state, marked_range, bounds, colors.cursor, window); - } - } - - if state.is_focused && state.selected_range.is_empty() && cursor_visible { - paint_singleline_cursor(&state, bounds, colors.cursor, window); - } +fn x_for_index<'chars>( + content: &SharedString, + char_positions: &'chars Vec, + index: usize, + default: &Pixels, +) -> Pixels { + let char_index = content[..index.min(content.len())].chars().count(); + char_positions.get(char_index).unwrap_or(default).clone() } fn paint_singleline_selection( + snapshot: &InputStateSnapshot, state: &SingleLinePaintState, bounds: Bounds, selection_color: Hsla, window: &mut Window, ) { - let start_x = state.x_for_index(state.selected_range.start) - state.scroll_offset; - let end_x = state.x_for_index(state.selected_range.end) - state.scroll_offset; + let start_x = x_for_index( + &snapshot.content, + &state.char_positions, + snapshot.selected_range.start, + &state.text_width, + ) - snapshot.scroll_offset; + let end_x = x_for_index( + &snapshot.content, + &state.char_positions, + snapshot.selected_range.end, + &state.text_width, + ) - snapshot.scroll_offset; - let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; + let y_offset = (bounds.size.height - snapshot.line_height).max(px(0.)) / 2.0; window.paint_quad(fill( Bounds::from_corners( point(bounds.left() + start_x, bounds.top() + y_offset), point( bounds.left() + end_x, - bounds.top() + y_offset + state.line_height, + bounds.top() + y_offset + snapshot.line_height, ), ), selection_color, @@ -930,6 +926,7 @@ fn paint_placeholder( } fn paint_singleline_text( + snapshot: &InputStateSnapshot, state: &SingleLinePaintState, bounds: Bounds, window: &mut Window, @@ -939,15 +936,15 @@ fn paint_singleline_text( return; }; - let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; + let y_offset = (bounds.size.height - snapshot.line_height).max(px(0.)) / 2.0; let paint_origin = point( - bounds.origin.x - state.scroll_offset, + bounds.origin.x - snapshot.scroll_offset, bounds.origin.y + y_offset, ); let _ = wrapped_line.paint( paint_origin, - state.line_height, + snapshot.line_height, TextAlign::Left, Some(bounds), window, @@ -956,18 +953,29 @@ fn paint_singleline_text( } fn paint_singleline_marked_underline( + snapshot: &InputStateSnapshot, state: &SingleLinePaintState, marked_range: &std::ops::Range, bounds: Bounds, underline_color: Hsla, window: &mut Window, ) { - let start_x = state.x_for_index(marked_range.start) - state.scroll_offset; - let end_x = state.x_for_index(marked_range.end) - state.scroll_offset; + let start_x = x_for_index( + &snapshot.content, + &state.char_positions, + marked_range.start, + &state.text_width, + ) - snapshot.scroll_offset; + let end_x = x_for_index( + &snapshot.content, + &state.char_positions, + marked_range.end, + &state.text_width, + ) - snapshot.scroll_offset; let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); - let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; - let underline_y = bounds.top() + y_offset + state.line_height - underline_thickness; + let y_offset = (bounds.size.height - snapshot.line_height).max(px(0.)) / 2.0; + let underline_y = bounds.top() + y_offset + snapshot.line_height - underline_thickness; window.paint_quad(fill( Bounds::from_corners( @@ -979,19 +987,25 @@ fn paint_singleline_marked_underline( } fn paint_singleline_cursor( + snapshot: &InputStateSnapshot, state: &SingleLinePaintState, bounds: Bounds, cursor_color: Hsla, window: &mut Window, ) { - let cursor_x = state.x_for_index(state.cursor_offset) - state.scroll_offset; + let cursor_x = x_for_index( + &snapshot.content, + &state.char_positions, + snapshot.cursor_offset, + &state.text_width, + ) - snapshot.scroll_offset; - let y_offset = (bounds.size.height - state.line_height).max(px(0.)) / 2.0; + let y_offset = (bounds.size.height - snapshot.line_height).max(px(0.)) / 2.0; window.paint_quad(fill( Bounds::new( point(bounds.left() + cursor_x, bounds.top() + y_offset), - size(px(CURSOR_WIDTH), state.line_height), + size(px(CURSOR_WIDTH), snapshot.line_height), ), cursor_color, )); From fca15d4ec2b3a930678d2c0d997669456140c691 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Fri, 5 Jun 2026 18:23:21 -0400 Subject: [PATCH 011/117] group paint_selection_quad logic together --- crates/gpui_elements/src/input/paint.rs | 116 +++++++++++------------- 1 file changed, 53 insertions(+), 63 deletions(-) diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index e5e18b0036..5c3282be35 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -3,8 +3,8 @@ use gpui::{ Along, App, Bounds, ContentMask, CursorStyle, DispatchPhase, Element, ElementId, ElementInputHandler, Entity, FocusHandle, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, LayoutId, Length, MouseButton, MouseDownEvent, MouseMoveEvent, - MouseUpEvent, Pixels, ScrollWheelEvent, SharedString, Style, TextAlign, TextRun, TextStyle, - Window, WrappedLine, fill, point, px, relative, size, + MouseUpEvent, Pixels, Point, ScrollWheelEvent, SharedString, Style, TextAlign, TextRun, + TextStyle, Window, WrappedLine, fill, point, px, relative, size, }; use std::{ops::Range, sync::Arc}; @@ -500,17 +500,14 @@ fn paint_multiline_selection( } if line.text_range.is_empty() { - let empty_line_selection_width = px(6.); - window.paint_quad(fill( - Bounds::from_corners( - point(bounds.left(), bounds.top() + line_y), - point( - bounds.left() + empty_line_selection_width, - bounds.top() + line_y + line_height, - ), - ), + const EMPTY_LINE_SELECTION_WIDTH: Pixels = px(6.); + paint_selection_quad( + window, selection_color, - )); + &bounds, + point(px(0.), line_y), + point(EMPTY_LINE_SELECTION_WIDTH, line_y + line_height), + ); } else if let Some(wrapped) = &line.wrapped_line { let line_start = line.text_range.start; let line_end = line.text_range.end; @@ -532,63 +529,45 @@ fn paint_multiline_selection( let end_visual_line = compute_visual_line_index(end_pos.y, line_height); if start_visual_line == end_visual_line { - window.paint_quad(fill( - Bounds::from_corners( - point( - bounds.left() + start_pos.x, - bounds.top() + line_y + start_pos.y, - ), - point( - bounds.left() + end_pos.x, - bounds.top() + line_y + start_pos.y + line_height, - ), - ), + paint_selection_quad( + window, selection_color, - )); + &bounds, + point(start_pos.x, line_y + start_pos.y), + point(end_pos.x, line_y + start_pos.y + line_height), + ); } else { let line_width = wrapped.width(); // First visual line - window.paint_quad(fill( - Bounds::from_corners( - point( - bounds.left() + start_pos.x, - bounds.top() + line_y + start_pos.y, - ), - point( - bounds.left() + line_width, - bounds.top() + line_y + start_pos.y + line_height, - ), - ), + paint_selection_quad( + window, selection_color, - )); + &bounds, + point(start_pos.x, line_y + start_pos.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; - window.paint_quad(fill( - Bounds::from_corners( - point(bounds.left(), bounds.top() + line_y + y), - point( - bounds.left() + line_width, - bounds.top() + line_y + y + line_height, - ), - ), + paint_selection_quad( + window, selection_color, - )); + &bounds, + point(px(0.), line_y + y), + point(line_width, line_y + y + line_height), + ); } // Last visual line - window.paint_quad(fill( - Bounds::from_corners( - point(bounds.left(), bounds.top() + line_y + end_pos.y), - point( - bounds.left() + end_pos.x, - bounds.top() + line_y + end_pos.y + line_height, - ), - ), + paint_selection_quad( + window, selection_color, - )); + &bounds, + point(px(0.), line_y + end_pos.y), + point(end_pos.x, line_y + end_pos.y + line_height), + ); } } } @@ -858,6 +837,20 @@ fn x_for_index<'chars>( char_positions.get(char_index).unwrap_or(default).clone() } +fn paint_selection_quad( + window: &mut Window, + color: Hsla, + bounds: &Bounds, + offset_start: Point, + offset_end: Point, +) { + let top_left = point(bounds.left(), bounds.top()); + window.paint_quad(fill( + Bounds::from_corners(top_left + offset_start, top_left + offset_end), + color, + )); +} + fn paint_singleline_selection( snapshot: &InputStateSnapshot, state: &SingleLinePaintState, @@ -880,16 +873,13 @@ fn paint_singleline_selection( let y_offset = (bounds.size.height - snapshot.line_height).max(px(0.)) / 2.0; - window.paint_quad(fill( - Bounds::from_corners( - point(bounds.left() + start_x, bounds.top() + y_offset), - point( - bounds.left() + end_x, - bounds.top() + y_offset + snapshot.line_height, - ), - ), + paint_selection_quad( + window, selection_color, - )); + &bounds, + point(start_x, y_offset), + point(end_x, y_offset + snapshot.line_height), + ); } fn paint_placeholder( From 9c09fd2cd1131d064bc9c530e4ba1a7a1e4ddb15 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 10:33:15 -0400 Subject: [PATCH 012/117] reintroduce tests from gpuikit::input --- crates/gpui_elements/src/input/state.rs | 1704 ++++++++++++++++++++++- 1 file changed, 1702 insertions(+), 2 deletions(-) diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index da4a10daa5..41e27f241a 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -55,6 +55,7 @@ pub struct InputState { pub(super) available_height: Pixels, pub(super) available_width: Pixels, pub(super) layout: InputLayout, + history_grouping_interval: Duration, /// Stack of previous states for undo. undo_stack: Vec, /// Stack of undone states for redo. @@ -74,7 +75,7 @@ pub struct InputState { /// When text wrapping is enabled, a logical line may span multiple visual lines. #[derive(Clone, Debug)] pub(super) struct InputLineLayout { - /// The byte range in the content string that this line covers. + /// 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>, @@ -122,6 +123,7 @@ impl InputState { available_height: px(0.), available_width: px(0.), layout: InputLayout::SingleLine, + history_grouping_interval: super::DEFAULT_GROUP_INTERVAL, undo_stack: Vec::new(), cached_utf16_len: None, redo_stack: Vec::new(), @@ -197,6 +199,11 @@ impl InputState { &self.content } + pub fn layout(mut self, layout: InputLayout) -> Self { + self.layout = layout; + self + } + pub fn get_layout(&self) -> InputLayout { self.layout } @@ -218,6 +225,10 @@ impl InputState { cx.notify(); } + pub fn set_history_group_interval(&mut self, interval: Duration) { + self.history_grouping_interval = interval; + } + /// Returns whether undo is available. pub fn can_undo(&self) -> bool { !self.undo_stack.is_empty() @@ -240,7 +251,7 @@ impl InputState { // Check if we should group with the last entry if let Some(last) = self.undo_stack.last() { - if now.duration_since(last.timestamp) < super::DEFAULT_GROUP_INTERVAL { + if now.duration_since(last.timestamp) < self.history_grouping_interval { // Within group interval - extend the existing patch // We need to merge this edit with the previous one return; @@ -1312,3 +1323,1692 @@ impl InputState { (offset, offset) } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{ + AppContext, Entity, IntoElement, Render, TestAppContext, TextStyle, WindowHandle, div, + }; + + struct TestView { + input: Entity, + } + + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + fn create_test_input( + cx: &mut TestAppContext, + content: &str, + range: std::ops::Range, + ) -> WindowHandle { + cx.add_window(|_window, cx| { + let input = cx.new(|cx| { + let mut input = InputState::new(cx).layout(InputLayout::MultiLine); + input.content = content.to_string().into(); + input.selected_range = range; + input + }); + TestView { input } + }) + } + + fn create_test_input_with_layout( + cx: &mut TestAppContext, + content: &str, + range: std::ops::Range, + ) -> WindowHandle { + let view = cx.add_window(|window, cx| { + let input = cx.new(|cx| { + let mut input = InputState::new(cx).layout(InputLayout::MultiLine); + input.content = content.to_string().into(); + input.selected_range = range; + input.update_line_layouts(px(500.), px(20.), &TextStyle::default(), window); + input + }); + TestView { input } + }); + view + } + + // ============================================================ + // BASIC MOVEMENT + // ============================================================ + + #[gpui::test] + fn test_left_at_start_of_content(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.left(&Left, window, cx); + assert_eq!(input.selected_range, 0..0); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_left_moves_by_grapheme(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 3..3); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.left(&Left, window, cx); + assert_eq!(input.selected_range, 2..2); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_left_collapses_selection_to_start(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 1..4); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.left(&Left, window, cx); + assert_eq!(input.selected_range, 1..1); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_left_stops_at_end_of_line(cx: &mut TestAppContext) { + // "ab\ncd" - cursor at position 3 (start of "cd", after newline) + // Pressing left should move to position 2 (end of "ab", before newline) + let view = create_test_input(cx, "ab\ncd", 3..3); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.left(&Left, window, cx); + assert_eq!(input.selected_range, 2..2); // cursor at end of line 1 + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_right_at_end_of_content(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.right(&Right, window, cx); + assert_eq!(input.selected_range, 5..5); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_right_moves_by_grapheme(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 2..2); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.right(&Right, window, cx); + assert_eq!(input.selected_range, 3..3); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_right_collapses_selection_to_end(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 1..4); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.right(&Right, window, cx); + assert_eq!(input.selected_range, 4..4); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_right_stops_at_end_of_line(cx: &mut TestAppContext) { + // "ab\ncd" - cursor at position 1 (after 'a') + // Pressing right should move to position 2 (end of "ab", before newline) + let view = create_test_input(cx, "ab\ncd", 1..1); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.right(&Right, window, cx); + assert_eq!(input.selected_range, 2..2); // cursor at end of line 1 + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_right_crosses_newline(cx: &mut TestAppContext) { + // "ab\ncd" - cursor at position 2 (end of "ab", before newline) + // Pressing right should move to position 3 (after newline, start of "cd") + let view = create_test_input(cx, "ab\ncd", 2..2); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.right(&Right, window, cx); + assert_eq!(input.selected_range, 3..3); // cursor at start of line 2 + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_left_crosses_newline(cx: &mut TestAppContext) { + // "ab\ncd" - cursor at position 2 (end of "ab", before newline) + // Pressing left should move to position 1 (after 'a') + let view = create_test_input(cx, "ab\ncd", 2..2); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.left(&Left, window, cx); + assert_eq!(input.selected_range, 1..1); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_home_moves_to_line_start(cx: &mut TestAppContext) { + let view = create_test_input(cx, "first\nsecond", 9..9); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.home(&Home, window, cx); + assert_eq!(input.selected_range, 6..6); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_end_moves_to_line_end(cx: &mut TestAppContext) { + let view = create_test_input(cx, "first\nsecond", 8..8); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.end(&End, window, cx); + assert_eq!(input.selected_range, 12..12); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_move_to_beginning(cx: &mut TestAppContext) { + let view = create_test_input(cx, "first\nsecond\nthird", 9..9); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.move_to_beginning(&MoveToBeginning, window, cx); + assert_eq!(input.selected_range, 0..0); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_move_to_end(cx: &mut TestAppContext) { + let view = create_test_input(cx, "first\nsecond\nthird", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.move_to_end(&MoveToEnd, window, cx); + assert_eq!(input.selected_range, 18..18); + }); + }) + .unwrap(); + } + + // ============================================================ + // WORD MOVEMENT + // ============================================================ + + #[gpui::test] + fn test_word_left_at_start(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.word_left(&WordLeft, window, cx); + assert_eq!(input.selected_range, 0..0); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_word_left_stops_at_boundary(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world test", 11..11); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.word_left(&WordLeft, window, cx); + assert_eq!(input.selected_range, 6..6); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_word_right_at_end(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 11..11); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.word_right(&WordRight, window, cx); + assert_eq!(input.selected_range, 11..11); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_word_right_stops_at_boundary(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world test", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.word_right(&WordRight, window, cx); + assert_eq!(input.selected_range, 5..5); + }); + }) + .unwrap(); + } + + // ============================================================ + // SELECTION + // ============================================================ + + #[gpui::test] + fn test_select_left_extends_selection(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 3..3); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.select_left(&SelectLeft, window, cx); + assert_eq!(input.selected_range, 2..3); + assert!(input.selection_reversed); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_select_right_extends_selection(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 2..2); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.select_right(&SelectRight, window, cx); + assert_eq!(input.selected_range, 2..3); + assert!(!input.selection_reversed); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_select_all(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello\nworld", 3..3); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.select_all(&SelectAll, window, cx); + assert_eq!(input.selected_range, 0..11); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_select_to_beginning(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 6..6); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.select_to_beginning(&SelectToBeginning, window, cx); + assert_eq!(input.selected_range, 0..6); + assert!(input.selection_reversed); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_select_to_end(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 6..6); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.select_to_end(&SelectToEnd, window, cx); + assert_eq!(input.selected_range, 6..11); + assert!(!input.selection_reversed); + }); + }) + .unwrap(); + } + + // ============================================================ + // EDITING - BACKSPACE + // ============================================================ + + #[gpui::test] + fn test_backspace_deletes_selection(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 6..11); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.backspace(&Backspace, window, cx); + assert_eq!(input.content(), "hello "); + assert_eq!(input.selected_range, 6..6); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_backspace_deletes_previous_grapheme(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.backspace(&Backspace, window, cx); + assert_eq!(input.content(), "hell"); + assert_eq!(input.selected_range, 4..4); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_backspace_at_start_does_nothing(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.backspace(&Backspace, window, cx); + assert_eq!(input.content(), "hello"); + assert_eq!(input.selected_range, 0..0); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_backspace_deletes_entire_emoji(cx: &mut TestAppContext) { + let view = create_test_input(cx, "Hi 👋", 7..7); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.backspace(&Backspace, window, cx); + assert_eq!(input.content(), "Hi "); + assert_eq!(input.selected_range, 3..3); + }); + }) + .unwrap(); + } + + // ============================================================ + // EDITING - DELETE + // ============================================================ + + #[gpui::test] + fn test_delete_deletes_selection(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 0..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.delete(&Delete, window, cx); + assert_eq!(input.content(), " world"); + assert_eq!(input.selected_range, 0..0); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_deletes_next_grapheme(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.delete(&Delete, window, cx); + assert_eq!(input.content(), "ello"); + assert_eq!(input.selected_range, 0..0); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_at_end_does_nothing(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.delete(&Delete, window, cx); + assert_eq!(input.content(), "hello"); + assert_eq!(input.selected_range, 5..5); + }); + }) + .unwrap(); + } + + // ============================================================ + // EDITING - ENTER + // ============================================================ + + #[gpui::test] + fn test_enter_inserts_newline(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.enter(&Enter, window, cx); + assert_eq!(input.content(), "hello\n world"); + assert_eq!(input.selected_range, 6..6); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_enter_replaces_selection(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 5..6); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.enter(&Enter, window, cx); + assert_eq!(input.content(), "hello\nworld"); + assert_eq!(input.selected_range, 6..6); + }); + }) + .unwrap(); + } + + // ============================================================ + // CLIPBOARD + // ============================================================ + + #[gpui::test] + fn test_copy_with_selection(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 6..11); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.copy(&Copy, window, cx); + }); + }) + .unwrap(); + + let clipboard = cx.read_from_clipboard(); + assert!(clipboard.is_some()); + assert_eq!(clipboard.unwrap().text().as_deref(), Some("world")); + } + + #[gpui::test] + fn test_cut_with_selection(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 0..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.cut(&Cut, window, cx); + assert_eq!(input.content(), " world"); + assert_eq!(input.selected_range, 0..0); + }); + }) + .unwrap(); + + let clipboard = cx.read_from_clipboard(); + assert_eq!(clipboard.unwrap().text().as_deref(), Some("hello")); + } + + #[gpui::test] + fn test_paste_inserts_text(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 5..5); + cx.write_to_clipboard(ClipboardItem::new_string(" there".to_string())); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.paste(&Paste, window, cx); + assert_eq!(input.content(), "hello there world"); + assert_eq!(input.selected_range, 11..11); + }); + }) + .unwrap(); + } + + // ============================================================ + // UNICODE / GRAPHEME HANDLING + // ============================================================ + + #[gpui::test] + fn test_movement_with_multibyte_utf8(cx: &mut TestAppContext) { + let view = create_test_input(cx, "café", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.right(&Right, window, cx); + assert_eq!(input.selected_range, 1..1); + input.right(&Right, window, cx); + assert_eq!(input.selected_range, 2..2); + input.right(&Right, window, cx); + assert_eq!(input.selected_range, 3..3); + input.right(&Right, window, cx); + assert_eq!(input.selected_range, 5..5); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_movement_with_emoji(cx: &mut TestAppContext) { + let view = create_test_input(cx, "a👋b", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.right(&Right, window, cx); + assert_eq!(input.selected_range, 1..1); + input.right(&Right, window, cx); + assert_eq!(input.selected_range, 5..5); + input.right(&Right, window, cx); + assert_eq!(input.selected_range, 6..6); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_selection_with_multibyte_characters(cx: &mut TestAppContext) { + let view = create_test_input(cx, "日本語", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.select_right(&SelectRight, window, cx); + assert_eq!(input.selected_range, 0..3); + input.select_right(&SelectRight, window, cx); + assert_eq!(input.selected_range, 0..6); + input.select_right(&SelectRight, window, cx); + assert_eq!(input.selected_range, 0..9); + }); + }) + .unwrap(); + } + + // ============================================================ + // NEWLINE HANDLING + // ============================================================ + + #[gpui::test] + fn test_find_line_start_and_end(cx: &mut TestAppContext) { + let view = create_test_input(cx, "first\nsecond\nthird", 0..0); + view.update(cx, |view, _window, cx| { + view.input.update(cx, |input, _cx| { + assert_eq!(input.find_line_start(0), 0); + assert_eq!(input.find_line_start(3), 0); + assert_eq!(input.find_line_start(6), 6); + assert_eq!(input.find_line_start(13), 13); + + assert_eq!(input.find_line_end(0), 5); + assert_eq!(input.find_line_end(6), 12); + assert_eq!(input.find_line_end(13), 18); + }); + }) + .unwrap(); + } + + // ============================================================ + // EDGE CASES + // ============================================================ + + #[gpui::test] + fn test_operations_on_empty_content(cx: &mut TestAppContext) { + let view = create_test_input(cx, "", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.left(&Left, window, cx); + assert_eq!(input.selected_range, 0..0); + + input.right(&Right, window, cx); + assert_eq!(input.selected_range, 0..0); + + input.backspace(&Backspace, window, cx); + assert_eq!(input.content(), ""); + + input.delete(&Delete, window, cx); + assert_eq!(input.content(), ""); + + input.select_all(&SelectAll, window, cx); + assert_eq!(input.selected_range, 0..0); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_set_content_resets_selection(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 3..8); + view.update(cx, |view, _window, cx| { + view.input.update(cx, |input, cx| { + input.selection_reversed = true; + input.marked_range = Some(5..7); + input.set_content("new content", cx); + assert_eq!(input.content(), "new content"); + assert_eq!(input.selected_range, 0..0); + assert!(!input.selection_reversed); + assert_eq!(input.marked_range, None); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_cursor_clamped_to_content_length(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 100..100); + view.update(cx, |view, _window, cx| { + view.input.update(cx, |input, cx| { + input.move_to(1000, cx); + assert_eq!(input.selected_range, 5..5); + + input.selected_range = 0..0; + input.select_to(1000, cx); + assert_eq!(input.selected_range, 0..5); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_previous_boundary_at_start(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 0..0); + view.update(cx, |view, _window, cx| { + view.input.update(cx, |input, _cx| { + assert_eq!(input.previous_boundary(0), 0); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_next_boundary_at_end(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 0..0); + view.update(cx, |view, _window, cx| { + view.input.update(cx, |input, _cx| { + assert_eq!(input.next_boundary(5), 5); + assert_eq!(input.next_boundary(100), 5); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_word_range_at_boundary(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 0..0); + view.update(cx, |view, _window, cx| { + view.input.update(cx, |input, _cx| { + let (start, end) = input.word_range_at(5); + assert_eq!(start, 0); + assert_eq!(end, 5); + + let (start, end) = input.word_range_at(8); + assert_eq!(start, 6); + assert_eq!(end, 11); + }); + }) + .unwrap(); + } + + // ============================================================ + // EMOJI & GRAPHEME CLUSTERS + // ============================================================ + + #[gpui::test] + fn test_simple_emoji_navigation(cx: &mut TestAppContext) { + // 😀 is 4 bytes in UTF-8 + let view = create_test_input(cx, "a😀b", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + // Move right through: a -> 😀 -> b + input.right(&Right, window, cx); + assert_eq!(input.selected_range.start, 1); // after 'a' + + input.right(&Right, window, cx); + assert_eq!(input.selected_range.start, 5); // after 😀 (1 + 4 bytes) + + input.right(&Right, window, cx); + assert_eq!(input.selected_range.start, 6); // after 'b' + + // Move left back + input.left(&Left, window, cx); + assert_eq!(input.selected_range.start, 5); // before 'b' + + input.left(&Left, window, cx); + assert_eq!(input.selected_range.start, 1); // before 😀 + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_emoji_with_skin_tone_modifier(cx: &mut TestAppContext) { + // 👋🏽 = 👋 (U+1F44B, 4 bytes) + 🏽 (U+1F3FD, 4 bytes) = 8 bytes total + let emoji = "👋🏽"; + assert_eq!(emoji.len(), 8); + + let view = create_test_input(cx, &format!("a{}b", emoji), 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.right(&Right, window, cx); // past 'a' + assert_eq!(input.selected_range.start, 1); + + input.right(&Right, window, cx); // past entire emoji with modifier + assert_eq!(input.selected_range.start, 9); // 1 + 8 + + input.left(&Left, window, cx); // back before emoji + assert_eq!(input.selected_range.start, 1); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_zwj_family_emoji(cx: &mut TestAppContext) { + // 👨‍👩‍👧 = man + ZWJ + woman + ZWJ + girl + // Each person emoji is 4 bytes, ZWJ is 3 bytes + // Total: 4 + 3 + 4 + 3 + 4 = 18 bytes + let family = "👨‍👩‍👧"; + assert_eq!(family.len(), 18); + + let view = create_test_input(cx, &format!("x{}y", family), 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.right(&Right, window, cx); // past 'x' + assert_eq!(input.selected_range.start, 1); + + input.right(&Right, window, cx); // past entire ZWJ sequence + assert_eq!(input.selected_range.start, 19); // 1 + 18 + + input.right(&Right, window, cx); // past 'y' + assert_eq!(input.selected_range.start, 20); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_backspace_deletes_emoji_between_ascii(cx: &mut TestAppContext) { + let view = create_test_input(cx, "a😀b", 5..5); // cursor after emoji + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.backspace(&Backspace, window, cx); + assert_eq!(input.content(), "ab"); + assert_eq!(input.selected_range.start, 1); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_backspace_deletes_zwj_sequence(cx: &mut TestAppContext) { + let family = "👨‍👩‍👧"; + let content = format!("a{}b", family); + let cursor_pos = 1 + family.len(); // after the family emoji + + let view = create_test_input(cx, &content, cursor_pos..cursor_pos); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.backspace(&Backspace, window, cx); + assert_eq!(input.content(), "ab"); + assert_eq!(input.selected_range.start, 1); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_removes_entire_emoji(cx: &mut TestAppContext) { + let view = create_test_input(cx, "a😀b", 1..1); // cursor before emoji + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.delete(&Delete, window, cx); + assert_eq!(input.content(), "ab"); + assert_eq!(input.selected_range.start, 1); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_flag_emoji_navigation(cx: &mut TestAppContext) { + // 🇯🇵 = Regional Indicator J (4 bytes) + Regional Indicator P (4 bytes) + let flag = "🇯🇵"; + assert_eq!(flag.len(), 8); + + let view = create_test_input(cx, &format!("x{}y", flag), 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.right(&Right, window, cx); // past 'x' + input.right(&Right, window, cx); // past flag (should be single grapheme) + assert_eq!(input.selected_range.start, 9); // 1 + 8 + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_combining_diacritical_marks(cx: &mut TestAppContext) { + // é as e + combining acute accent (U+0301) + let combining = "e\u{0301}"; // 1 + 2 = 3 bytes + assert_eq!(combining.len(), 3); + + let view = create_test_input(cx, &format!("a{}b", combining), 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.right(&Right, window, cx); // past 'a' + assert_eq!(input.selected_range.start, 1); + + input.right(&Right, window, cx); // past e + combining mark (single grapheme) + assert_eq!(input.selected_range.start, 4); // 1 + 3 + + input.left(&Left, window, cx); + assert_eq!(input.selected_range.start, 1); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_multiple_combining_marks(cx: &mut TestAppContext) { + // ë́ = e + combining diaeresis (U+0308) + combining acute (U+0301) + let multi_combining = "e\u{0308}\u{0301}"; // 1 + 2 + 2 = 5 bytes + assert_eq!(multi_combining.len(), 5); + + let view = create_test_input(cx, &format!("x{}y", multi_combining), 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.right(&Right, window, cx); // past 'x' + input.right(&Right, window, cx); // past entire combined character + assert_eq!(input.selected_range.start, 6); // 1 + 5 + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_select_emoji_with_shift(cx: &mut TestAppContext) { + let view = create_test_input(cx, "a😀b", 1..1); // cursor before emoji + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.select_right(&SelectRight, window, cx); + assert_eq!(input.selected_range, 1..5); // selected the entire emoji + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_cjk_characters(cx: &mut TestAppContext) { + // 你好 - each character is 3 bytes in UTF-8 + let view = create_test_input(cx, "a你好b", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.right(&Right, window, cx); // past 'a' + assert_eq!(input.selected_range.start, 1); + + input.right(&Right, window, cx); // past 你 + assert_eq!(input.selected_range.start, 4); // 1 + 3 + + input.right(&Right, window, cx); // past 好 + assert_eq!(input.selected_range.start, 7); // 4 + 3 + + input.right(&Right, window, cx); // past 'b' + assert_eq!(input.selected_range.start, 8); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_mixed_script_text(cx: &mut TestAppContext) { + // Mix of ASCII, CJK, and emoji + let view = create_test_input(cx, "Hi你😀", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.right(&Right, window, cx); // past 'H' + assert_eq!(input.selected_range.start, 1); + + input.right(&Right, window, cx); // past 'i' + assert_eq!(input.selected_range.start, 2); + + input.right(&Right, window, cx); // past 你 (3 bytes) + assert_eq!(input.selected_range.start, 5); + + input.right(&Right, window, cx); // past 😀 (4 bytes) + assert_eq!(input.selected_range.start, 9); + + // Now go back + input.left(&Left, window, cx); + assert_eq!(input.selected_range.start, 5); + + input.left(&Left, window, cx); + assert_eq!(input.selected_range.start, 2); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_variation_selector_emoji(cx: &mut TestAppContext) { + // ☺️ = ☺ (U+263A, 3 bytes) + variation selector-16 (U+FE0F, 3 bytes) + let emoji_presentation = "☺\u{FE0F}"; + assert_eq!(emoji_presentation.len(), 6); + + let view = create_test_input(cx, &format!("a{}b", emoji_presentation), 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.right(&Right, window, cx); // past 'a' + input.right(&Right, window, cx); // past emoji with variation selector + assert_eq!(input.selected_range.start, 7); // 1 + 6 + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_keycap_emoji(cx: &mut TestAppContext) { + // 1️⃣ = 1 + variation selector + combining enclosing keycap + let keycap = "1\u{FE0F}\u{20E3}"; + + let view = create_test_input(cx, &format!("x{}y", keycap), 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.right(&Right, window, cx); // past 'x' + input.right(&Right, window, cx); // past keycap sequence + let expected_pos = 1 + keycap.len(); + assert_eq!(input.selected_range.start, expected_pos); + }); + }) + .unwrap(); + } + + // Single-line input tests + + fn create_single_line_input( + cx: &mut TestAppContext, + content: &str, + selected_range: Range, + ) -> WindowHandle { + cx.add_window(|_window, cx| { + let input = cx.new(|cx| { + let mut input = InputState::new(cx).layout(InputLayout::SingleLine); + input.content = content.to_string().into(); + input.selected_range = selected_range; + input + }); + TestView { input } + }) + } + + #[gpui::test] + fn test_single_line_enter_does_nothing(cx: &mut TestAppContext) { + let view = create_single_line_input(cx, "hello", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.enter(&Enter, window, cx); + assert_eq!(input.content(), "hello"); + assert_eq!(input.selected_range, 5..5); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_single_line_set_content_strips_newlines(cx: &mut TestAppContext) { + let view = create_single_line_input(cx, "", 0..0); + view.update(cx, |view, _window, cx| { + view.input.update(cx, |input, cx| { + input.set_content("hello\nworld\r\nfoo", cx); + assert_eq!(input.content(), "hello world foo"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_single_line_up_moves_to_start(cx: &mut TestAppContext) { + let view = create_single_line_input(cx, "hello world", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.up(&Up, window, cx); + assert_eq!(input.selected_range, 0..0); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_single_line_down_moves_to_end(cx: &mut TestAppContext) { + let view = create_single_line_input(cx, "hello world", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.down(&Down, window, cx); + assert_eq!(input.selected_range, 11..11); // "hello world".len() == 11 + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_single_line_select_up_selects_to_start(cx: &mut TestAppContext) { + let view = create_single_line_input(cx, "hello world", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.select_up(&SelectUp, window, cx); + assert_eq!(input.selected_range, 0..5); + assert!(input.selection_reversed); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_single_line_select_down_selects_to_end(cx: &mut TestAppContext) { + let view = create_single_line_input(cx, "hello world", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.select_down(&SelectDown, window, cx); + assert_eq!(input.selected_range, 5..11); // "hello world".len() == 11 + assert!(!input.selection_reversed); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_single_line_multiline_getter(cx: &mut TestAppContext) { + let view = create_single_line_input(cx, "hello", 0..0); + view.update(cx, |view, _window, cx| { + view.input.update(cx, |input, _cx| { + assert_eq!(input.get_layout(), InputLayout::SingleLine); + }); + }) + .unwrap(); + + let multiline_view = create_test_input(cx, "hello", 0..0); + multiline_view + .update(cx, |view, _window, cx| { + view.input.update(cx, |input, _cx| { + assert_eq!(input.get_layout(), InputLayout::MultiLine); + }); + }) + .unwrap(); + } + + // ============================================================ + // UNDO / REDO + // ============================================================ + + #[gpui::test] + fn test_undo_restores_content(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + // Disable grouping for predictable test behavior + input.set_history_group_interval(Duration::from_secs(0)); + + // Make an edit + input.replace_text_in_range(None, " world", window, cx); + assert_eq!(input.content(), "hello world"); + + // Undo should restore original content + input.undo(&Undo, window, cx); + assert_eq!(input.content(), "hello"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_redo_restores_undone_content(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.set_history_group_interval(Duration::from_secs(0)); + + input.replace_text_in_range(None, " world", window, cx); + assert_eq!(input.content(), "hello world"); + + input.undo(&Undo, window, cx); + assert_eq!(input.content(), "hello"); + + input.redo(&Redo, window, cx); + assert_eq!(input.content(), "hello world"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_undo_with_no_history_does_nothing(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + assert!(!input.can_undo()); + input.undo(&Undo, window, cx); + assert_eq!(input.content(), "hello"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_redo_with_no_history_does_nothing(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + assert!(!input.can_redo()); + input.redo(&Redo, window, cx); + assert_eq!(input.content(), "hello"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_undo_restores_selection(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 0..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.set_history_group_interval(Duration::from_secs(0)); + + // Delete selection + input.replace_text_in_range(None, "", window, cx); + assert_eq!(input.content(), " world"); + assert_eq!(input.selected_range, 0..0); + + // Undo should restore content and selection + input.undo(&Undo, window, cx); + assert_eq!(input.content(), "hello world"); + assert_eq!(input.selected_range, 0..5); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_multiple_undo_redo(cx: &mut TestAppContext) { + let view = create_test_input(cx, "", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.set_history_group_interval(Duration::from_secs(0)); + + input.replace_text_in_range(None, "a", window, cx); + input.replace_text_in_range(None, "b", window, cx); + input.replace_text_in_range(None, "c", window, cx); + assert_eq!(input.content(), "abc"); + + input.undo(&Undo, window, cx); + assert_eq!(input.content(), "ab"); + + input.undo(&Undo, window, cx); + assert_eq!(input.content(), "a"); + + input.undo(&Undo, window, cx); + assert_eq!(input.content(), ""); + + input.redo(&Redo, window, cx); + assert_eq!(input.content(), "a"); + + input.redo(&Redo, window, cx); + assert_eq!(input.content(), "ab"); + + input.redo(&Redo, window, cx); + assert_eq!(input.content(), "abc"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_new_edit_clears_redo_stack(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.set_history_group_interval(Duration::from_secs(0)); + + input.replace_text_in_range(None, " world", window, cx); + assert_eq!(input.content(), "hello world"); + + input.undo(&Undo, window, cx); + assert_eq!(input.content(), "hello"); + assert!(input.can_redo()); + + // New edit should clear redo stack + input.replace_text_in_range(None, "!", window, cx); + assert_eq!(input.content(), "hello!"); + assert!(!input.can_redo()); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_set_content_clears_history(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.set_history_group_interval(Duration::from_secs(0)); + + input.replace_text_in_range(None, " world", window, cx); + assert!(input.can_undo()); + + input.set_content("new content", cx); + assert!(!input.can_undo()); + assert!(!input.can_redo()); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_can_undo_can_redo(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.set_history_group_interval(Duration::from_secs(0)); + + assert!(!input.can_undo()); + assert!(!input.can_redo()); + + input.replace_text_in_range(None, "!", window, cx); + assert!(input.can_undo()); + assert!(!input.can_redo()); + + input.undo(&Undo, window, cx); + assert!(!input.can_undo()); + assert!(input.can_redo()); + + input.redo(&Redo, window, cx); + assert!(input.can_undo()); + assert!(!input.can_redo()); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_backspace_is_undoable(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.set_history_group_interval(Duration::from_secs(0)); + + input.backspace(&Backspace, window, cx); + assert_eq!(input.content(), "hell"); + + input.undo(&Undo, window, cx); + assert_eq!(input.content(), "hello"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_is_undoable(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.set_history_group_interval(Duration::from_secs(0)); + + input.delete(&Delete, window, cx); + assert_eq!(input.content(), "ello"); + + input.undo(&Undo, window, cx); + assert_eq!(input.content(), "hello"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_cut_is_undoable(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 0..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.set_history_group_interval(Duration::from_secs(0)); + + input.cut(&Cut, window, cx); + assert_eq!(input.content(), " world"); + + input.undo(&Undo, window, cx); + assert_eq!(input.content(), "hello world"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_cut_line_with_no_selection(cx: &mut TestAppContext) { + // Cursor in middle line, no selection - should cut entire line including newline + let view = create_test_input(cx, "line1\nline2\nline3", 8..8); // cursor in "line2" + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.cut(&Cut, window, cx); + assert_eq!(input.content(), "line1\nline3"); + }); + }) + .unwrap(); + + let clipboard = cx.read_from_clipboard(); + assert_eq!(clipboard.unwrap().text().as_deref(), Some("line2\n")); + } + + #[gpui::test] + fn test_cut_first_line_with_no_selection(cx: &mut TestAppContext) { + // Cursor on first line, no selection + let view = create_test_input(cx, "line1\nline2\nline3", 2..2); // cursor in "line1" + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.cut(&Cut, window, cx); + assert_eq!(input.content(), "line2\nline3"); + }); + }) + .unwrap(); + + let clipboard = cx.read_from_clipboard(); + assert_eq!(clipboard.unwrap().text().as_deref(), Some("line1\n")); + } + + #[gpui::test] + fn test_cut_last_line_with_no_selection(cx: &mut TestAppContext) { + // Cursor on last line, no selection - should include preceding newline + let view = create_test_input(cx, "line1\nline2\nline3", 14..14); // cursor in "line3" + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.cut(&Cut, window, cx); + assert_eq!(input.content(), "line1\nline2"); + }); + }) + .unwrap(); + + let clipboard = cx.read_from_clipboard(); + assert_eq!(clipboard.unwrap().text().as_deref(), Some("\nline3")); + } + + #[gpui::test] + fn test_cut_empty_line(cx: &mut TestAppContext) { + // Cursor on empty line - should remove that line + let view = create_test_input(cx, "line1\n\nline3", 6..6); // cursor on empty line + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.cut(&Cut, window, cx); + assert_eq!(input.content(), "line1\nline3"); + }); + }) + .unwrap(); + + let clipboard = cx.read_from_clipboard(); + assert_eq!(clipboard.unwrap().text().as_deref(), Some("\n")); + } + + #[gpui::test] + fn test_cut_only_line_with_no_selection(cx: &mut TestAppContext) { + // Single line content, no selection - should cut entire content + let view = create_test_input(cx, "hello", 2..2); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.cut(&Cut, window, cx); + assert_eq!(input.content(), ""); + }); + }) + .unwrap(); + + let clipboard = cx.read_from_clipboard(); + assert_eq!(clipboard.unwrap().text().as_deref(), Some("hello")); + } + + #[gpui::test] + fn test_cut_line_is_undoable(cx: &mut TestAppContext) { + let view = create_test_input(cx, "line1\nline2\nline3", 8..8); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.set_history_group_interval(Duration::from_secs(0)); + + input.cut(&Cut, window, cx); + assert_eq!(input.content(), "line1\nline3"); + + input.undo(&Undo, window, cx); + assert_eq!(input.content(), "line1\nline2\nline3"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_paste_is_undoable(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello", 5..5); + cx.write_to_clipboard(ClipboardItem::new_string(" world".to_string())); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.set_history_group_interval(Duration::from_secs(0)); + + input.paste(&Paste, window, cx); + assert_eq!(input.content(), "hello world"); + + input.undo(&Undo, window, cx); + assert_eq!(input.content(), "hello"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_enter_is_undoable(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.set_history_group_interval(Duration::from_secs(0)); + + input.enter(&Enter, window, cx); + assert_eq!(input.content(), "hello\n world"); + + input.undo(&Undo, window, cx); + assert_eq!(input.content(), "hello world"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_word_left(cx: &mut TestAppContext) { + // Cursor at end of "hello" in "hello world" + let view = create_test_input(cx, "hello world", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.delete_word_left(&DeleteWordLeft, window, cx); + assert_eq!(input.content(), " world"); + assert_eq!(input.selected_range, 0..0); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_word_left_with_selection(cx: &mut TestAppContext) { + // Selection from 0 to 5 ("hello") + let view = create_test_input(cx, "hello world", 0..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.delete_word_left(&DeleteWordLeft, window, cx); + assert_eq!(input.content(), " world"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_word_left_at_start(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.delete_word_left(&DeleteWordLeft, window, cx); + assert_eq!(input.content(), "hello world"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_word_right(cx: &mut TestAppContext) { + // Cursor at start + let view = create_test_input(cx, "hello world", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.delete_word_right(&DeleteWordRight, window, cx); + assert_eq!(input.content(), " world"); + assert_eq!(input.selected_range, 0..0); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_word_right_with_selection(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 0..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.delete_word_right(&DeleteWordRight, window, cx); + assert_eq!(input.content(), " world"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_word_right_at_end(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 11..11); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.delete_word_right(&DeleteWordRight, window, cx); + assert_eq!(input.content(), "hello world"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_to_beginning_of_line(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.delete_to_beginning_of_line(&DeleteToBeginningOfLine, window, cx); + assert_eq!(input.content(), " world"); + assert_eq!(input.selected_range, 0..0); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_to_beginning_of_line_multiline(cx: &mut TestAppContext) { + // Cursor at position 8 (middle of "line2") + let view = create_test_input(cx, "line1\nline2\nline3", 8..8); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.delete_to_beginning_of_line(&DeleteToBeginningOfLine, window, cx); + assert_eq!(input.content(), "line1\nne2\nline3"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_to_beginning_of_line_at_start(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 0..0); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.delete_to_beginning_of_line(&DeleteToBeginningOfLine, window, cx); + assert_eq!(input.content(), "hello world"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_to_end_of_line(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.delete_to_end_of_line(&DeleteToEndOfLine, window, cx); + assert_eq!(input.content(), "hello"); + assert_eq!(input.selected_range, 5..5); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_to_end_of_line_multiline(cx: &mut TestAppContext) { + // Cursor at position 8 (middle of "line2") + let view = create_test_input(cx, "line1\nline2\nline3", 8..8); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.delete_to_end_of_line(&DeleteToEndOfLine, window, cx); + assert_eq!(input.content(), "line1\nli\nline3"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_to_end_of_line_at_end(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 11..11); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.delete_to_end_of_line(&DeleteToEndOfLine, window, cx); + assert_eq!(input.content(), "hello world"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_word_left_is_undoable(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.set_history_group_interval(Duration::from_secs(0)); + + input.delete_word_left(&DeleteWordLeft, window, cx); + assert_eq!(input.content(), " world"); + + input.undo(&Undo, window, cx); + assert_eq!(input.content(), "hello world"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_word_right_is_undoable(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 6..6); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.set_history_group_interval(Duration::from_secs(0)); + + input.delete_word_right(&DeleteWordRight, window, cx); + assert_eq!(input.content(), "hello "); + + input.undo(&Undo, window, cx); + assert_eq!(input.content(), "hello world"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_to_beginning_of_line_is_undoable(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.set_history_group_interval(Duration::from_secs(0)); + + input.delete_to_beginning_of_line(&DeleteToBeginningOfLine, window, cx); + assert_eq!(input.content(), " world"); + + input.undo(&Undo, window, cx); + assert_eq!(input.content(), "hello world"); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delete_to_end_of_line_is_undoable(cx: &mut TestAppContext) { + let view = create_test_input(cx, "hello world", 5..5); + view.update(cx, |view, window, cx| { + view.input.update(cx, |input, cx| { + input.set_history_group_interval(Duration::from_secs(0)); + + input.delete_to_end_of_line(&DeleteToEndOfLine, window, cx); + assert_eq!(input.content(), "hello"); + + input.undo(&Undo, window, cx); + assert_eq!(input.content(), "hello world"); + }); + }) + .unwrap(); + } +} From a418fcb9e0b04e063b7fe3f1d5f69b43738c5427 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 11:54:10 -0400 Subject: [PATCH 013/117] merge single/multi line paint operations into PaintContext --- crates/gpui_elements/src/input/paint.rs | 1087 ++++++++++------------- 1 file changed, 491 insertions(+), 596 deletions(-) diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index 5c3282be35..9bd364873e 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -1,12 +1,12 @@ use crate::input::{Input, InputLineLayout, InputState, PaintColors}; use gpui::{ Along, App, Bounds, ContentMask, CursorStyle, DispatchPhase, Element, ElementId, - ElementInputHandler, Entity, FocusHandle, Focusable, GlobalElementId, Hitbox, HitboxBehavior, - Hsla, InspectorElementId, LayoutId, Length, MouseButton, MouseDownEvent, MouseMoveEvent, + ElementInputHandler, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla, + InspectorElementId, LayoutId, Length, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollWheelEvent, SharedString, Style, TextAlign, TextRun, TextStyle, Window, WrappedLine, fill, point, px, relative, size, }; -use std::{ops::Range, sync::Arc}; +use std::ops::Range; const CURSOR_WIDTH: f32 = 2.0; const MARKED_TEXT_UNDERLINE_THICKNESS: f32 = 2.0; @@ -145,14 +145,29 @@ impl Element for Input { .update(cx, |input, cx| input.cursor_visible(is_focused, cx)); let perform_paint = |_style: &Style, window: &mut Window, cx: &mut App| { + let precomputed_first_line = match (snapshot.layout, snapshot.line_layouts.first()) { + ( + super::InputLayout::SingleLine, + Some(InputLineLayout { + wrapped_line: Some(wrapped_line), + .. + }), + ) => Some(PrecomputedLinePosition::new( + &snapshot.content, + &**wrapped_line, + snapshot.line_height, + )), + _ => None, + }; let context = PaintContext { snapshot, - focus_handle: &focus_handle, + is_focused, bounds, text_style: &text_style, placeholder: placeholder.as_ref(), colors: &colors, cursor_visible, + precomputed_first_line, }; context.process_mouse_events(&self.input, window, cx); window.with_content_mask(Some(ContentMask { bounds }), |window| { @@ -205,12 +220,13 @@ impl InputStateSnapshot { struct PaintContext<'app> { snapshot: InputStateSnapshot, - focus_handle: &'app FocusHandle, + is_focused: bool, bounds: Bounds, text_style: &'app TextStyle, placeholder: Option<&'app SharedString>, colors: &'app PaintColors, cursor_visible: bool, + precomputed_first_line: Option, } impl<'app> PaintContext<'app> { @@ -319,132 +335,487 @@ impl<'app> PaintContext<'app> { } pub fn paint(&self, window: &mut Window, cx: &mut App) { + if !self.snapshot.selected_range.is_empty() { + self.paint_selection(window); + } + + if self.snapshot.content.is_empty() { + self.paint_placeholder(window, cx); + } else { + self.paint_text(window, cx); + } + + self.paint_marked_underline(window); + + if self.is_focused && self.snapshot.selected_range.is_empty() && self.cursor_visible { + self.paint_cursor(window); + } + } + + fn paint_selection(&self, window: &mut Window) { match self.snapshot.layout { super::InputLayout::MultiLine => { - let is_focused = self.focus_handle.is_focused(window); + for line in &self.snapshot.line_layouts { + let line_y = line.y_offset - self.snapshot.scroll_offset; - if !self.snapshot.selected_range.is_empty() { - paint_multiline_selection( - &self.snapshot.line_layouts, - &self.snapshot.selected_range, - self.bounds, - self.snapshot.scroll_offset, + if !is_line_visible( + line_y, self.snapshot.line_height, - self.colors.selection, - window, - ); - } + line.visual_line_count, + self.bounds.size.height, + ) { + continue; + } - if self.snapshot.content.is_empty() { - if let Some(placeholder_str) = self.placeholder { - if !placeholder_str.is_empty() { - paint_placeholder( - placeholder_str, - self.bounds, - self.text_style, - self.colors.placeholder, + if !line_intersects_range(&line.text_range, &self.snapshot.selected_range) { + continue; + } + + if line.text_range.is_empty() { + const EMPTY_LINE_SELECTION_WIDTH: Pixels = px(6.); + paint_selection_quad( + window, + self.colors.selection, + &self.bounds, + point(px(0.), line_y), + point( + EMPTY_LINE_SELECTION_WIDTH, + line_y + self.snapshot.line_height, + ), + ); + } else if let Some(wrapped) = &line.wrapped_line { + let line_start = line.text_range.start; + let line_end = line.text_range.end; + + let sel_start = + self.snapshot.selected_range.start.max(line_start) - line_start; + let sel_end = self.snapshot.selected_range.end.min(line_end) - line_start; + + let start_pos = wrapped + .position_for_index(sel_start, self.snapshot.line_height) + .unwrap_or(point(px(0.), px(0.))); + let end_pos = wrapped + .position_for_index(sel_end, self.snapshot.line_height) + .unwrap_or_else(|| { + let last_line_y = + self.snapshot.line_height * (line.visual_line_count - 1) as f32; + point(wrapped.width(), last_line_y) + }); + + let start_visual_line = + compute_visual_line_index(start_pos.y, self.snapshot.line_height); + let end_visual_line = + compute_visual_line_index(end_pos.y, self.snapshot.line_height); + + if start_visual_line == end_visual_line { + paint_selection_quad( window, - cx, - false, + self.colors.selection, + &self.bounds, + point(start_pos.x, line_y + start_pos.y), + point(end_pos.x, line_y + start_pos.y + self.snapshot.line_height), + ); + } else { + let line_width = wrapped.width(); + + // First visual line + paint_selection_quad( + window, + self.colors.selection, + &self.bounds, + point(start_pos.x, line_y + start_pos.y), + point(line_width, line_y + start_pos.y + self.snapshot.line_height), + ); + + // Middle visual lines + for visual_line in (start_visual_line + 1)..end_visual_line { + let y = self.snapshot.line_height * visual_line as f32; + paint_selection_quad( + window, + self.colors.selection, + &self.bounds, + point(px(0.), line_y + y), + point(line_width, line_y + y + self.snapshot.line_height), + ); + } + + // Last visual line + paint_selection_quad( + window, + self.colors.selection, + &self.bounds, + point(px(0.), line_y + end_pos.y), + point(end_pos.x, line_y + end_pos.y + self.snapshot.line_height), ); } } - } else { - paint_multiline_text( - &self.snapshot.line_layouts, - self.bounds, - self.snapshot.scroll_offset, - self.snapshot.line_height, - window, - cx, - ); - } - - if let Some(marked_range) = &self.snapshot.marked_range { - if !marked_range.is_empty() { - paint_multiline_marked_underline( - &self.snapshot.line_layouts, - marked_range, - self.bounds, - self.snapshot.scroll_offset, - self.snapshot.line_height, - self.colors.cursor, - window, - ); - } - } - - if is_focused && self.snapshot.selected_range.is_empty() && self.cursor_visible { - paint_multiline_cursor( - &self.snapshot.line_layouts, - self.snapshot.cursor_offset, - &self.snapshot.content, - self.bounds, - self.snapshot.scroll_offset, - self.snapshot.line_height, - self.colors.cursor, - window, - ); } } super::InputLayout::SingleLine => { - let state = - SingleLinePaintState::from_input(&self.snapshot, self.focus_handle, window); + let precomputed = self + .precomputed_first_line + .as_ref() + .expect("missing precomputed single-line"); + let start_x = pos_in_string_for_char_index( + &self.snapshot.content, + &precomputed.char_positions, + self.snapshot.selected_range.start, + &precomputed.text_width, + ) - self.snapshot.scroll_offset; + let end_x = pos_in_string_for_char_index( + &self.snapshot.content, + &precomputed.char_positions, + self.snapshot.selected_range.end, + &precomputed.text_width, + ) - self.snapshot.scroll_offset; - if !self.snapshot.selected_range.is_empty() { - paint_singleline_selection( - &self.snapshot, - &state, - self.bounds, - self.colors.selection, - window, - ); - } + let y_offset = + (self.bounds.size.height - self.snapshot.line_height).max(px(0.)) / 2.0; - if self.snapshot.content.is_empty() { - if let Some(placeholder_str) = self.placeholder { - if !placeholder_str.is_empty() { - paint_placeholder( - placeholder_str, - self.bounds, - self.text_style, - self.colors.placeholder, - window, - cx, - true, - ); - } + paint_selection_quad( + window, + self.colors.selection, + &self.bounds, + point(start_x, y_offset), + point(end_x, y_offset + self.snapshot.line_height), + ); + } + } + } + + fn paint_placeholder(&self, window: &mut Window, cx: &mut App) { + let baseline = matches!(self.snapshot.layout, super::InputLayout::SingleLine); + let Some(placeholder) = self.placeholder else { + return; + }; + if placeholder.is_empty() { + return; + } + + let run = TextRun { + len: placeholder.len(), + font: self.text_style.font(), + color: self.colors.placeholder, + background_color: None, + underline: None, + strikethrough: None, + }; + + let font_size = self.text_style.font_size.to_pixels(window.rem_size()); + let shaped_line = + window + .text_system() + .shape_line(placeholder.clone(), font_size, &[run], None); + let line_height = self.text_style.line_height_in_pixels(window.rem_size()); + + let mut paint_origin = self.bounds.origin; + if baseline { + let y_offset = (self.bounds.size.height - line_height).max(px(0.)) / 2.0; + paint_origin.y += y_offset; + } + + let _ = shaped_line.paint(paint_origin, line_height, TextAlign::Left, None, window, cx); + } + + fn paint_text(&self, window: &mut Window, cx: &mut App) { + match self.snapshot.layout { + super::InputLayout::MultiLine => { + for line_layout in &self.snapshot.line_layouts { + let line_y = line_layout.y_offset - self.snapshot.scroll_offset; + + if !is_line_visible( + line_y, + self.snapshot.line_height, + line_layout.visual_line_count, + self.bounds.size.height, + ) { + continue; } - } else { - paint_singleline_text(&self.snapshot, &state, self.bounds, window, cx); - } - if let Some(marked_range) = &self.snapshot.marked_range { - if !marked_range.is_empty() { - paint_singleline_marked_underline( - &self.snapshot, - &state, - marked_range, - self.bounds, - self.colors.cursor, + if let Some(wrapped) = &line_layout.wrapped_line { + let paint_pos = point(self.bounds.left(), self.bounds.top() + line_y); + let _ = wrapped.paint( + paint_pos, + self.snapshot.line_height, + TextAlign::Left, + Some(self.bounds), window, + cx, ); } } + } + super::InputLayout::SingleLine => { + let Some(line_layout) = self.snapshot.line_layouts.first() else { + return; + }; + let Some(wrapped_line) = &line_layout.wrapped_line else { + return; + }; - if state.is_focused - && self.snapshot.selected_range.is_empty() - && self.cursor_visible - { - paint_singleline_cursor( - &self.snapshot, - &state, - self.bounds, - self.colors.cursor, - window, - ); + let y_offset = + (self.bounds.size.height - self.snapshot.line_height).max(px(0.)) / 2.0; + let paint_origin = point( + self.bounds.origin.x - self.snapshot.scroll_offset, + self.bounds.origin.y + y_offset, + ); + + let _ = wrapped_line.paint( + paint_origin, + self.snapshot.line_height, + TextAlign::Left, + Some(self.bounds), + window, + cx, + ); + } + } + } + + fn paint_marked_underline(&self, window: &mut Window) { + let Some(marked_range) = &self.snapshot.marked_range else { + return; + }; + if marked_range.is_empty() { + return; + } + match self.snapshot.layout { + super::InputLayout::MultiLine => { + let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); + let underline_offset = self.snapshot.line_height - underline_thickness; + + for line in &self.snapshot.line_layouts { + let line_y = line.y_offset - self.snapshot.scroll_offset; + + if !is_line_visible( + line_y, + self.snapshot.line_height, + line.visual_line_count, + self.bounds.size.height, + ) { + continue; + } + + if !line_intersects_range(&line.text_range, marked_range) { + continue; + } + + if line.text_range.is_empty() { + continue; + } + + if let Some(wrapped) = &line.wrapped_line { + let line_start = line.text_range.start; + let line_end = line.text_range.end; + + let mark_start = marked_range.start.max(line_start) - line_start; + let mark_end = marked_range.end.min(line_end) - line_start; + + let start_pos = wrapped + .position_for_index(mark_start, self.snapshot.line_height) + .unwrap_or(point(px(0.), px(0.))); + let end_pos = wrapped + .position_for_index(mark_end, self.snapshot.line_height) + .unwrap_or_else(|| { + let last_line_y = + self.snapshot.line_height * (line.visual_line_count - 1) as f32; + point(wrapped.width(), last_line_y) + }); + + let start_visual_line = + compute_visual_line_index(start_pos.y, self.snapshot.line_height); + let end_visual_line = + compute_visual_line_index(end_pos.y, self.snapshot.line_height); + + if start_visual_line == end_visual_line { + window.paint_quad(fill( + Bounds::from_corners( + point( + self.bounds.left() + start_pos.x, + self.bounds.top() + line_y + start_pos.y + underline_offset, + ), + point( + self.bounds.left() + end_pos.x, + self.bounds.top() + + line_y + + start_pos.y + + self.snapshot.line_height, + ), + ), + self.colors.cursor, + )); + } else { + // First visual line + window.paint_quad(fill( + Bounds::from_corners( + point( + self.bounds.left() + start_pos.x, + self.bounds.top() + line_y + start_pos.y + underline_offset, + ), + point( + self.bounds.left() + wrapped.width(), + self.bounds.top() + + line_y + + start_pos.y + + self.snapshot.line_height, + ), + ), + self.colors.cursor, + )); + + // Middle visual lines + for visual_line in (start_visual_line + 1)..end_visual_line { + let y = self.snapshot.line_height * visual_line as f32; + window.paint_quad(fill( + Bounds::from_corners( + point( + self.bounds.left(), + self.bounds.top() + line_y + y + underline_offset, + ), + point( + self.bounds.left() + wrapped.width(), + self.bounds.top() + + line_y + + y + + self.snapshot.line_height, + ), + ), + self.colors.cursor, + )); + } + + // Last visual line + window.paint_quad(fill( + Bounds::from_corners( + point( + self.bounds.left(), + self.bounds.top() + line_y + end_pos.y + underline_offset, + ), + point( + self.bounds.left() + end_pos.x, + self.bounds.top() + + line_y + + end_pos.y + + self.snapshot.line_height, + ), + ), + self.colors.cursor, + )); + } + } } } + super::InputLayout::SingleLine => { + let Some(precomputed) = &self.precomputed_first_line else { + return; + }; + let start_x = pos_in_string_for_char_index( + &self.snapshot.content, + &precomputed.char_positions, + marked_range.start, + &precomputed.text_width, + ) - self.snapshot.scroll_offset; + let end_x = pos_in_string_for_char_index( + &self.snapshot.content, + &precomputed.char_positions, + marked_range.end, + &precomputed.text_width, + ) - self.snapshot.scroll_offset; + + let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); + let y_offset = + (self.bounds.size.height - self.snapshot.line_height).max(px(0.)) / 2.0; + let underline_y = + self.bounds.top() + y_offset + self.snapshot.line_height - underline_thickness; + + window.paint_quad(fill( + Bounds::from_corners( + point(self.bounds.left() + start_x, underline_y), + point( + self.bounds.left() + end_x, + underline_y + underline_thickness, + ), + ), + self.colors.cursor, + )); + } + } + } + + fn paint_cursor(&self, window: &mut Window) { + match self.snapshot.layout { + super::InputLayout::MultiLine => { + for line in &self.snapshot.line_layouts { + let line_y = line.y_offset - self.snapshot.scroll_offset; + + if !is_line_visible( + line_y, + self.snapshot.line_height, + line.visual_line_count, + self.bounds.size.height, + ) { + continue; + } + + // Since range is non-inclusive of the end value we need to check for it explicitly + let is_cursor_in_line = if line.text_range.is_empty() { + self.snapshot.cursor_offset == line.text_range.start + } else { + line.text_range.contains(&self.snapshot.cursor_offset) + || self.snapshot.cursor_offset == line.text_range.end + }; + + if !is_cursor_in_line { + continue; + } + + let cursor_position = if let Some(wrapped) = &line.wrapped_line { + let local_offset = self + .snapshot + .cursor_offset + .saturating_sub(line.text_range.start); + wrapped + .position_for_index(local_offset, self.snapshot.line_height) + .unwrap_or(point(px(0.), px(0.))) + } else { + point(px(0.), px(0.)) + }; + + window.paint_quad(fill( + Bounds::new( + point( + self.bounds.left() + cursor_position.x, + self.bounds.top() + line_y + cursor_position.y, + ), + size(px(CURSOR_WIDTH), self.snapshot.line_height), + ), + self.colors.cursor, + )); + break; + } + } + super::InputLayout::SingleLine => { + let Some(precomputed) = &self.precomputed_first_line else { + return; + }; + let cursor_x = pos_in_string_for_char_index( + &self.snapshot.content, + &precomputed.char_positions, + self.snapshot.cursor_offset, + &precomputed.text_width, + ) - self.snapshot.scroll_offset; + + let y_offset = + (self.bounds.size.height - self.snapshot.line_height).max(px(0.)) / 2.0; + + window.paint_quad(fill( + Bounds::new( + point(self.bounds.left() + cursor_x, self.bounds.top() + y_offset), + size(px(CURSOR_WIDTH), self.snapshot.line_height), + ), + self.colors.cursor, + )); + } } } } @@ -474,360 +845,34 @@ fn compute_visual_line_index(y: Pixels, line_height: Pixels) -> usize { (y / line_height).floor() as usize } -fn paint_multiline_selection( - line_layouts: &[InputLineLayout], - selected_range: &std::ops::Range, - bounds: Bounds, - scroll_offset: Pixels, - line_height: Pixels, - selection_color: Hsla, - window: &mut Window, -) { - for line in line_layouts { - let line_y = line.y_offset - scroll_offset; - - if !is_line_visible( - line_y, - line_height, - line.visual_line_count, - bounds.size.height, - ) { - continue; - } - - if !line_intersects_range(&line.text_range, selected_range) { - continue; - } - - if line.text_range.is_empty() { - const EMPTY_LINE_SELECTION_WIDTH: Pixels = px(6.); - paint_selection_quad( - window, - selection_color, - &bounds, - point(px(0.), line_y), - point(EMPTY_LINE_SELECTION_WIDTH, line_y + line_height), - ); - } else if let Some(wrapped) = &line.wrapped_line { - let line_start = line.text_range.start; - let line_end = line.text_range.end; - - let sel_start = selected_range.start.max(line_start) - line_start; - let sel_end = selected_range.end.min(line_end) - line_start; - - let start_pos = wrapped - .position_for_index(sel_start, line_height) - .unwrap_or(point(px(0.), px(0.))); - let end_pos = wrapped - .position_for_index(sel_end, line_height) - .unwrap_or_else(|| { - let last_line_y = line_height * (line.visual_line_count - 1) as f32; - point(wrapped.width(), last_line_y) - }); - - let start_visual_line = compute_visual_line_index(start_pos.y, line_height); - let end_visual_line = compute_visual_line_index(end_pos.y, line_height); - - if start_visual_line == end_visual_line { - paint_selection_quad( - window, - selection_color, - &bounds, - point(start_pos.x, line_y + start_pos.y), - point(end_pos.x, line_y + start_pos.y + line_height), - ); - } else { - let line_width = wrapped.width(); - - // First visual line - paint_selection_quad( - window, - selection_color, - &bounds, - point(start_pos.x, line_y + start_pos.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; - paint_selection_quad( - window, - selection_color, - &bounds, - point(px(0.), line_y + y), - point(line_width, line_y + y + line_height), - ); - } - - // Last visual line - paint_selection_quad( - window, - selection_color, - &bounds, - point(px(0.), line_y + end_pos.y), - point(end_pos.x, line_y + end_pos.y + line_height), - ); - } - } - } -} - -fn paint_multiline_text( - line_layouts: &[InputLineLayout], - bounds: Bounds, - scroll_offset: Pixels, - line_height: Pixels, - window: &mut Window, - cx: &mut App, -) { - for line_layout in line_layouts { - let line_y = line_layout.y_offset - scroll_offset; - - if !is_line_visible( - line_y, - line_height, - line_layout.visual_line_count, - bounds.size.height, - ) { - continue; - } - - if let Some(wrapped) = &line_layout.wrapped_line { - let paint_pos = point(bounds.left(), bounds.top() + line_y); - let _ = wrapped.paint( - paint_pos, - line_height, - TextAlign::Left, - Some(bounds), - window, - cx, - ); - } - } -} - -fn paint_multiline_marked_underline( - line_layouts: &[InputLineLayout], - marked_range: &std::ops::Range, - bounds: Bounds, - scroll_offset: Pixels, - line_height: Pixels, - underline_color: Hsla, - window: &mut Window, -) { - let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); - let underline_offset = line_height - underline_thickness; - - for line in line_layouts { - let line_y = line.y_offset - scroll_offset; - - if !is_line_visible( - line_y, - line_height, - line.visual_line_count, - bounds.size.height, - ) { - continue; - } - - if !line_intersects_range(&line.text_range, marked_range) { - continue; - } - - if line.text_range.is_empty() { - continue; - } - - if let Some(wrapped) = &line.wrapped_line { - let line_start = line.text_range.start; - let line_end = line.text_range.end; - - let mark_start = marked_range.start.max(line_start) - line_start; - let mark_end = marked_range.end.min(line_end) - line_start; - - let start_pos = wrapped - .position_for_index(mark_start, line_height) - .unwrap_or(point(px(0.), px(0.))); - let end_pos = wrapped - .position_for_index(mark_end, line_height) - .unwrap_or_else(|| { - let last_line_y = line_height * (line.visual_line_count - 1) as f32; - point(wrapped.width(), last_line_y) - }); - - let start_visual_line = compute_visual_line_index(start_pos.y, line_height); - let end_visual_line = compute_visual_line_index(end_pos.y, line_height); - - if start_visual_line == end_visual_line { - window.paint_quad(fill( - Bounds::from_corners( - point( - bounds.left() + start_pos.x, - bounds.top() + line_y + start_pos.y + underline_offset, - ), - point( - bounds.left() + end_pos.x, - bounds.top() + line_y + start_pos.y + line_height, - ), - ), - underline_color, - )); - } else { - // First visual line - window.paint_quad(fill( - Bounds::from_corners( - point( - bounds.left() + start_pos.x, - bounds.top() + line_y + start_pos.y + underline_offset, - ), - point( - bounds.left() + wrapped.width(), - bounds.top() + line_y + start_pos.y + line_height, - ), - ), - underline_color, - )); - - // Middle visual lines - for visual_line in (start_visual_line + 1)..end_visual_line { - let y = line_height * visual_line as f32; - window.paint_quad(fill( - Bounds::from_corners( - point(bounds.left(), bounds.top() + line_y + y + underline_offset), - point( - bounds.left() + wrapped.width(), - bounds.top() + line_y + y + line_height, - ), - ), - underline_color, - )); - } - - // Last visual line - window.paint_quad(fill( - Bounds::from_corners( - point( - bounds.left(), - bounds.top() + line_y + end_pos.y + underline_offset, - ), - point( - bounds.left() + end_pos.x, - bounds.top() + line_y + end_pos.y + line_height, - ), - ), - underline_color, - )); - } - } - } -} - -fn paint_multiline_cursor( - line_layouts: &[InputLineLayout], - cursor_offset: usize, - _content: &str, - bounds: Bounds, - scroll_offset: Pixels, - line_height: Pixels, - cursor_color: Hsla, - window: &mut Window, -) { - for line in line_layouts.iter() { - let line_y = line.y_offset - scroll_offset; - - if !is_line_visible( - line_y, - line_height, - line.visual_line_count, - bounds.size.height, - ) { - continue; - } - - // Since range is non-inclusive of the end value we need to check for it explicitly - let is_cursor_in_line = if line.text_range.is_empty() { - cursor_offset == line.text_range.start - } else { - line.text_range.contains(&cursor_offset) || cursor_offset == line.text_range.end - }; - - if !is_cursor_in_line { - continue; - } - - let cursor_position = if let Some(wrapped) = &line.wrapped_line { - let local_offset = cursor_offset.saturating_sub(line.text_range.start); - wrapped - .position_for_index(local_offset, line_height) - .unwrap_or(point(px(0.), px(0.))) - } else { - point(px(0.), px(0.)) - }; - - window.paint_quad(fill( - Bounds::new( - point( - bounds.left() + cursor_position.x, - bounds.top() + line_y + cursor_position.y, - ), - size(px(CURSOR_WIDTH), line_height), - ), - cursor_color, - )); - break; - } -} - -/// State for single-line painting that pre-computes character positions. -struct SingleLinePaintState { +struct PrecomputedLinePosition { text_width: Pixels, - is_focused: bool, char_positions: Vec, - wrapped_line: Option>, } - -impl SingleLinePaintState { - fn from_input( - snapshot: &InputStateSnapshot, - focus_handle: &FocusHandle, - window: &Window, - ) -> Self { +impl PrecomputedLinePosition { + fn new(string: &str, line: &WrappedLine, line_height: Pixels) -> Self { + let text_width = line.width(); let mut char_positions = Vec::new(); - let mut text_width = px(0.); - if let Some(line) = snapshot.line_layouts.first() { - if let Some(wrapped) = &line.wrapped_line { - text_width = wrapped.width(); - let content = &snapshot.content; - let mut idx = 0; - for ch in content.chars() { - if let Some(pos) = wrapped.position_for_index(idx, snapshot.line_height) { - char_positions.push(pos.x); - } else { - char_positions.push(text_width); - } - idx += ch.len_utf8(); - } + let mut idx = 0; + for ch in string.chars() { + if let Some(pos) = line.position_for_index(idx, line_height) { + char_positions.push(pos.x); + } else { char_positions.push(text_width); } + idx += ch.len_utf8(); } - - let wrapped_line = snapshot - .line_layouts - .first() - .and_then(|l| l.wrapped_line.clone()); + char_positions.push(text_width); Self { text_width, - is_focused: focus_handle.is_focused(window), char_positions, - wrapped_line, } } } -fn x_for_index<'chars>( +fn pos_in_string_for_char_index<'chars>( content: &SharedString, char_positions: &'chars Vec, index: usize, @@ -850,153 +895,3 @@ fn paint_selection_quad( color, )); } - -fn paint_singleline_selection( - snapshot: &InputStateSnapshot, - state: &SingleLinePaintState, - bounds: Bounds, - selection_color: Hsla, - window: &mut Window, -) { - let start_x = x_for_index( - &snapshot.content, - &state.char_positions, - snapshot.selected_range.start, - &state.text_width, - ) - snapshot.scroll_offset; - let end_x = x_for_index( - &snapshot.content, - &state.char_positions, - snapshot.selected_range.end, - &state.text_width, - ) - snapshot.scroll_offset; - - let y_offset = (bounds.size.height - snapshot.line_height).max(px(0.)) / 2.0; - - paint_selection_quad( - window, - selection_color, - &bounds, - point(start_x, y_offset), - point(end_x, y_offset + snapshot.line_height), - ); -} - -fn paint_placeholder( - placeholder: &SharedString, - bounds: Bounds, - text_style: &TextStyle, - color: Hsla, - window: &mut Window, - cx: &mut App, - baseline: bool, -) { - let run = TextRun { - len: placeholder.len(), - font: text_style.font(), - color, - background_color: None, - underline: None, - strikethrough: None, - }; - - let font_size = text_style.font_size.to_pixels(window.rem_size()); - let shaped_line = window - .text_system() - .shape_line(placeholder.clone(), font_size, &[run], None); - let line_height = text_style.line_height_in_pixels(window.rem_size()); - - let mut paint_origin = bounds.origin; - if baseline { - let y_offset = (bounds.size.height - line_height).max(px(0.)) / 2.0; - paint_origin.y += y_offset; - } - - let _ = shaped_line.paint(paint_origin, line_height, TextAlign::Left, None, window, cx); -} - -fn paint_singleline_text( - snapshot: &InputStateSnapshot, - state: &SingleLinePaintState, - bounds: Bounds, - window: &mut Window, - cx: &mut App, -) { - let Some(wrapped_line) = &state.wrapped_line else { - return; - }; - - let y_offset = (bounds.size.height - snapshot.line_height).max(px(0.)) / 2.0; - let paint_origin = point( - bounds.origin.x - snapshot.scroll_offset, - bounds.origin.y + y_offset, - ); - - let _ = wrapped_line.paint( - paint_origin, - snapshot.line_height, - TextAlign::Left, - Some(bounds), - window, - cx, - ); -} - -fn paint_singleline_marked_underline( - snapshot: &InputStateSnapshot, - state: &SingleLinePaintState, - marked_range: &std::ops::Range, - bounds: Bounds, - underline_color: Hsla, - window: &mut Window, -) { - let start_x = x_for_index( - &snapshot.content, - &state.char_positions, - marked_range.start, - &state.text_width, - ) - snapshot.scroll_offset; - let end_x = x_for_index( - &snapshot.content, - &state.char_positions, - marked_range.end, - &state.text_width, - ) - snapshot.scroll_offset; - - let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); - let y_offset = (bounds.size.height - snapshot.line_height).max(px(0.)) / 2.0; - let underline_y = bounds.top() + y_offset + snapshot.line_height - underline_thickness; - - window.paint_quad(fill( - Bounds::from_corners( - point(bounds.left() + start_x, underline_y), - point(bounds.left() + end_x, underline_y + underline_thickness), - ), - underline_color, - )); -} - -fn paint_singleline_cursor( - snapshot: &InputStateSnapshot, - state: &SingleLinePaintState, - bounds: Bounds, - cursor_color: Hsla, - window: &mut Window, -) { - let cursor_x = x_for_index( - &snapshot.content, - &state.char_positions, - snapshot.cursor_offset, - &state.text_width, - ) - snapshot.scroll_offset; - - let y_offset = (bounds.size.height - snapshot.line_height).max(px(0.)) / 2.0; - - window.paint_quad(fill( - Bounds::new( - point(bounds.left() + cursor_x, bounds.top() + y_offset), - size(px(CURSOR_WIDTH), snapshot.line_height), - ), - cursor_color, - )); -} From 769202c6565b94767f702e9db4ec9781da853ff1 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 12:20:51 -0400 Subject: [PATCH 014/117] combine quad painting calls --- crates/gpui_elements/src/input/paint.rs | 372 +++++++++--------------- 1 file changed, 144 insertions(+), 228 deletions(-) diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index 9bd364873e..bd9b5f1dbf 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -334,6 +334,20 @@ impl<'app> PaintContext<'app> { }); } + fn paint_bounds_quad( + &self, + window: &mut Window, + color: Hsla, + offset_start: Point, + offset_end: Point, + ) { + let top_left = point(self.bounds.left(), self.bounds.top()); + window.paint_quad(fill( + Bounds::from_corners(top_left + offset_start, top_left + offset_end), + color, + )); + } + pub fn paint(&self, window: &mut Window, cx: &mut App) { if !self.snapshot.selected_range.is_empty() { self.paint_selection(window); @@ -358,12 +372,7 @@ impl<'app> PaintContext<'app> { for line in &self.snapshot.line_layouts { let line_y = line.y_offset - self.snapshot.scroll_offset; - if !is_line_visible( - line_y, - self.snapshot.line_height, - line.visual_line_count, - self.bounds.size.height, - ) { + if !self.is_line_visible(line_y, line.visual_line_count) { continue; } @@ -373,10 +382,9 @@ impl<'app> PaintContext<'app> { if line.text_range.is_empty() { const EMPTY_LINE_SELECTION_WIDTH: Pixels = px(6.); - paint_selection_quad( + self.paint_bounds_quad( window, self.colors.selection, - &self.bounds, point(px(0.), line_y), point( EMPTY_LINE_SELECTION_WIDTH, @@ -393,7 +401,7 @@ impl<'app> PaintContext<'app> { let start_pos = wrapped .position_for_index(sel_start, self.snapshot.line_height) - .unwrap_or(point(px(0.), px(0.))); + .unwrap_or_default(); let end_pos = wrapped .position_for_index(sel_end, self.snapshot.line_height) .unwrap_or_else(|| { @@ -402,16 +410,13 @@ impl<'app> PaintContext<'app> { point(wrapped.width(), last_line_y) }); - let start_visual_line = - compute_visual_line_index(start_pos.y, self.snapshot.line_height); - let end_visual_line = - compute_visual_line_index(end_pos.y, self.snapshot.line_height); + let start_visual_line = self.compute_visual_line_index(start_pos.y); + let end_visual_line = self.compute_visual_line_index(end_pos.y); if start_visual_line == end_visual_line { - paint_selection_quad( + self.paint_bounds_quad( window, self.colors.selection, - &self.bounds, point(start_pos.x, line_y + start_pos.y), point(end_pos.x, line_y + start_pos.y + self.snapshot.line_height), ); @@ -419,10 +424,9 @@ impl<'app> PaintContext<'app> { let line_width = wrapped.width(); // First visual line - paint_selection_quad( + self.paint_bounds_quad( window, self.colors.selection, - &self.bounds, point(start_pos.x, line_y + start_pos.y), point(line_width, line_y + start_pos.y + self.snapshot.line_height), ); @@ -430,20 +434,18 @@ impl<'app> PaintContext<'app> { // Middle visual lines for visual_line in (start_visual_line + 1)..end_visual_line { let y = self.snapshot.line_height * visual_line as f32; - paint_selection_quad( + self.paint_bounds_quad( window, self.colors.selection, - &self.bounds, point(px(0.), line_y + y), point(line_width, line_y + y + self.snapshot.line_height), ); } // Last visual line - paint_selection_quad( + self.paint_bounds_quad( window, self.colors.selection, - &self.bounds, point(px(0.), line_y + end_pos.y), point(end_pos.x, line_y + end_pos.y + self.snapshot.line_height), ); @@ -472,10 +474,9 @@ impl<'app> PaintContext<'app> { let y_offset = (self.bounds.size.height - self.snapshot.line_height).max(px(0.)) / 2.0; - paint_selection_quad( + self.paint_bounds_quad( window, self.colors.selection, - &self.bounds, point(start_x, y_offset), point(end_x, y_offset + self.snapshot.line_height), ); @@ -484,7 +485,6 @@ impl<'app> PaintContext<'app> { } fn paint_placeholder(&self, window: &mut Window, cx: &mut App) { - let baseline = matches!(self.snapshot.layout, super::InputLayout::SingleLine); let Some(placeholder) = self.placeholder else { return; }; @@ -509,7 +509,7 @@ impl<'app> PaintContext<'app> { let line_height = self.text_style.line_height_in_pixels(window.rem_size()); let mut paint_origin = self.bounds.origin; - if baseline { + if matches!(self.snapshot.layout, super::InputLayout::SingleLine) { let y_offset = (self.bounds.size.height - line_height).max(px(0.)) / 2.0; paint_origin.y += y_offset; } @@ -523,12 +523,7 @@ impl<'app> PaintContext<'app> { for line_layout in &self.snapshot.line_layouts { let line_y = line_layout.y_offset - self.snapshot.scroll_offset; - if !is_line_visible( - line_y, - self.snapshot.line_height, - line_layout.visual_line_count, - self.bounds.size.height, - ) { + if !self.is_line_visible(line_y, line_layout.visual_line_count) { continue; } @@ -587,12 +582,11 @@ impl<'app> PaintContext<'app> { for line in &self.snapshot.line_layouts { let line_y = line.y_offset - self.snapshot.scroll_offset; - if !is_line_visible( - line_y, - self.snapshot.line_height, - line.visual_line_count, - self.bounds.size.height, - ) { + if line.text_range.is_empty() { + continue; + } + + if !self.is_line_visible(line_y, line.visual_line_count) { continue; } @@ -600,108 +594,66 @@ impl<'app> PaintContext<'app> { continue; } - if line.text_range.is_empty() { + let Some(wrapped) = &line.wrapped_line else { continue; - } + }; + let line_start = line.text_range.start; + let line_end = line.text_range.end; - if let Some(wrapped) = &line.wrapped_line { - let line_start = line.text_range.start; - let line_end = line.text_range.end; + let mark_start = marked_range.start.max(line_start) - line_start; + let mark_end = marked_range.end.min(line_end) - line_start; - let mark_start = marked_range.start.max(line_start) - line_start; - let mark_end = marked_range.end.min(line_end) - line_start; + let start_pos = wrapped + .position_for_index(mark_start, self.snapshot.line_height) + .unwrap_or_default(); + let end_pos = wrapped + .position_for_index(mark_end, self.snapshot.line_height) + .unwrap_or_else(|| { + let last_line_y = + self.snapshot.line_height * (line.visual_line_count - 1) as f32; + point(wrapped.width(), last_line_y) + }); - let start_pos = wrapped - .position_for_index(mark_start, self.snapshot.line_height) - .unwrap_or(point(px(0.), px(0.))); - let end_pos = wrapped - .position_for_index(mark_end, self.snapshot.line_height) - .unwrap_or_else(|| { - let last_line_y = - self.snapshot.line_height * (line.visual_line_count - 1) as f32; - point(wrapped.width(), last_line_y) - }); + let start_visual_line = self.compute_visual_line_index(start_pos.y); + let end_visual_line = self.compute_visual_line_index(end_pos.y); - let start_visual_line = - compute_visual_line_index(start_pos.y, self.snapshot.line_height); - let end_visual_line = - compute_visual_line_index(end_pos.y, self.snapshot.line_height); + if start_visual_line == end_visual_line { + self.paint_bounds_quad( + window, + self.colors.cursor, + point(start_pos.x, line_y + start_pos.y + underline_offset), + point(end_pos.x, line_y + start_pos.y + self.snapshot.line_height), + ); + } else { + // First visual line + self.paint_bounds_quad( + window, + self.colors.cursor, + point(start_pos.x, line_y + start_pos.y + underline_offset), + point( + wrapped.width(), + line_y + start_pos.y + self.snapshot.line_height, + ), + ); - if start_visual_line == end_visual_line { - window.paint_quad(fill( - Bounds::from_corners( - point( - self.bounds.left() + start_pos.x, - self.bounds.top() + line_y + start_pos.y + underline_offset, - ), - point( - self.bounds.left() + end_pos.x, - self.bounds.top() - + line_y - + start_pos.y - + self.snapshot.line_height, - ), - ), + // Middle visual lines + for visual_line in (start_visual_line + 1)..end_visual_line { + let y = self.snapshot.line_height * visual_line as f32; + self.paint_bounds_quad( + window, self.colors.cursor, - )); - } else { - // First visual line - window.paint_quad(fill( - Bounds::from_corners( - point( - self.bounds.left() + start_pos.x, - self.bounds.top() + line_y + start_pos.y + underline_offset, - ), - point( - self.bounds.left() + wrapped.width(), - self.bounds.top() - + line_y - + start_pos.y - + self.snapshot.line_height, - ), - ), - self.colors.cursor, - )); - - // Middle visual lines - for visual_line in (start_visual_line + 1)..end_visual_line { - let y = self.snapshot.line_height * visual_line as f32; - window.paint_quad(fill( - Bounds::from_corners( - point( - self.bounds.left(), - self.bounds.top() + line_y + y + underline_offset, - ), - point( - self.bounds.left() + wrapped.width(), - self.bounds.top() - + line_y - + y - + self.snapshot.line_height, - ), - ), - self.colors.cursor, - )); - } - - // Last visual line - window.paint_quad(fill( - Bounds::from_corners( - point( - self.bounds.left(), - self.bounds.top() + line_y + end_pos.y + underline_offset, - ), - point( - self.bounds.left() + end_pos.x, - self.bounds.top() - + line_y - + end_pos.y - + self.snapshot.line_height, - ), - ), - self.colors.cursor, - )); + point(px(0.), line_y + y + underline_offset), + point(wrapped.width(), line_y + y + self.snapshot.line_height), + ); } + + // Last visual line + self.paint_bounds_quad( + window, + self.colors.cursor, + point(px(0.), line_y + end_pos.y + underline_offset), + point(end_pos.x, line_y + end_pos.y + self.snapshot.line_height), + ); } } } @@ -725,75 +677,56 @@ impl<'app> PaintContext<'app> { let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); let y_offset = (self.bounds.size.height - self.snapshot.line_height).max(px(0.)) / 2.0; - let underline_y = - self.bounds.top() + y_offset + self.snapshot.line_height - underline_thickness; + let underline_y = y_offset + self.snapshot.line_height - underline_thickness; - window.paint_quad(fill( - Bounds::from_corners( - point(self.bounds.left() + start_x, underline_y), - point( - self.bounds.left() + end_x, - underline_y + underline_thickness, - ), - ), + self.paint_bounds_quad( + window, self.colors.cursor, - )); + point(start_x, underline_y), + point(end_x, underline_y + underline_thickness), + ); } } } - fn paint_cursor(&self, window: &mut Window) { - match self.snapshot.layout { - super::InputLayout::MultiLine => { - for line in &self.snapshot.line_layouts { - let line_y = line.y_offset - self.snapshot.scroll_offset; + fn find_cursor_position_in_layouts(&self) -> Point { + for line in &self.snapshot.line_layouts { + let line_y = line.y_offset - self.snapshot.scroll_offset; - if !is_line_visible( - line_y, - self.snapshot.line_height, - line.visual_line_count, - self.bounds.size.height, - ) { - continue; - } - - // Since range is non-inclusive of the end value we need to check for it explicitly - let is_cursor_in_line = if line.text_range.is_empty() { - self.snapshot.cursor_offset == line.text_range.start - } else { - line.text_range.contains(&self.snapshot.cursor_offset) - || self.snapshot.cursor_offset == line.text_range.end - }; - - if !is_cursor_in_line { - continue; - } - - let cursor_position = if let Some(wrapped) = &line.wrapped_line { - let local_offset = self - .snapshot - .cursor_offset - .saturating_sub(line.text_range.start); - wrapped - .position_for_index(local_offset, self.snapshot.line_height) - .unwrap_or(point(px(0.), px(0.))) - } else { - point(px(0.), px(0.)) - }; - - window.paint_quad(fill( - Bounds::new( - point( - self.bounds.left() + cursor_position.x, - self.bounds.top() + line_y + cursor_position.y, - ), - size(px(CURSOR_WIDTH), self.snapshot.line_height), - ), - self.colors.cursor, - )); - break; - } + if !self.is_line_visible(line_y, line.visual_line_count) { + continue; } + + // Since range is non-inclusive of the end value we need to check for it explicitly + let is_cursor_in_line = if line.text_range.is_empty() { + self.snapshot.cursor_offset == line.text_range.start + } else { + line.text_range.contains(&self.snapshot.cursor_offset) + || self.snapshot.cursor_offset == line.text_range.end + }; + + if !is_cursor_in_line { + continue; + } + + let Some(wrapped) = &line.wrapped_line else { + return Point::default(); + }; + let local_offset = self + .snapshot + .cursor_offset + .saturating_sub(line.text_range.start); + let cursor_pos = wrapped + .position_for_index(local_offset, self.snapshot.line_height) + .unwrap_or_default(); + return cursor_pos + point(px(0.), line_y); + } + Point::default() + } + + fn paint_cursor(&self, window: &mut Window) { + let cursor_pos = match self.snapshot.layout { + super::InputLayout::MultiLine => self.find_cursor_position_in_layouts(), super::InputLayout::SingleLine => { let Some(precomputed) = &self.precomputed_first_line else { return; @@ -808,26 +741,27 @@ impl<'app> PaintContext<'app> { let y_offset = (self.bounds.size.height - self.snapshot.line_height).max(px(0.)) / 2.0; - window.paint_quad(fill( - Bounds::new( - point(self.bounds.left() + cursor_x, self.bounds.top() + y_offset), - size(px(CURSOR_WIDTH), self.snapshot.line_height), - ), - self.colors.cursor, - )); + point(cursor_x, y_offset) } - } - } -} + }; -fn is_line_visible( - line_y: Pixels, - line_height: Pixels, - visual_line_count: usize, - visible_height: Pixels, -) -> bool { - let line_bottom = line_y + line_height * visual_line_count as f32; - line_bottom >= px(0.) && line_y <= visible_height + window.paint_quad(fill( + Bounds::new( + point(self.bounds.left(), self.bounds.top()) + cursor_pos, + size(px(CURSOR_WIDTH), self.snapshot.line_height), + ), + self.colors.cursor, + )); + } + + fn is_line_visible(&self, line_y: Pixels, visual_line_count: usize) -> bool { + let line_bottom = line_y + self.snapshot.line_height * visual_line_count as f32; + line_bottom >= px(0.) && line_y <= self.bounds.size.height + } + + fn compute_visual_line_index(&self, y: Pixels) -> usize { + (y / self.snapshot.line_height).floor() as usize + } } fn line_intersects_range( @@ -841,10 +775,6 @@ fn line_intersects_range( } } -fn compute_visual_line_index(y: Pixels, line_height: Pixels) -> usize { - (y / line_height).floor() as usize -} - struct PrecomputedLinePosition { text_width: Pixels, char_positions: Vec, @@ -881,17 +811,3 @@ fn pos_in_string_for_char_index<'chars>( let char_index = content[..index.min(content.len())].chars().count(); char_positions.get(char_index).unwrap_or(default).clone() } - -fn paint_selection_quad( - window: &mut Window, - color: Hsla, - bounds: &Bounds, - offset_start: Point, - offset_end: Point, -) { - let top_left = point(bounds.left(), bounds.top()); - window.paint_quad(fill( - Bounds::from_corners(top_left + offset_start, top_left + offset_end), - color, - )); -} From 6c0f6c97f1e02996d04ce651606d2dd95478b9c4 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 12:34:45 -0400 Subject: [PATCH 015/117] combine selection and marked_underline paint sections for multiline --- crates/gpui_elements/src/input/paint.rs | 211 ++++++++++-------------- 1 file changed, 89 insertions(+), 122 deletions(-) diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index bd9b5f1dbf..1cca0a8f36 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -391,65 +391,14 @@ impl<'app> PaintContext<'app> { line_y + self.snapshot.line_height, ), ); - } else if let Some(wrapped) = &line.wrapped_line { - let line_start = line.text_range.start; - let line_end = line.text_range.end; - - let sel_start = - self.snapshot.selected_range.start.max(line_start) - line_start; - let sel_end = self.snapshot.selected_range.end.min(line_end) - line_start; - - let start_pos = wrapped - .position_for_index(sel_start, self.snapshot.line_height) - .unwrap_or_default(); - let end_pos = wrapped - .position_for_index(sel_end, self.snapshot.line_height) - .unwrap_or_else(|| { - let last_line_y = - self.snapshot.line_height * (line.visual_line_count - 1) as f32; - point(wrapped.width(), last_line_y) - }); - - let start_visual_line = self.compute_visual_line_index(start_pos.y); - let end_visual_line = self.compute_visual_line_index(end_pos.y); - - if start_visual_line == end_visual_line { - self.paint_bounds_quad( - window, - self.colors.selection, - point(start_pos.x, line_y + start_pos.y), - point(end_pos.x, line_y + start_pos.y + self.snapshot.line_height), - ); - } else { - let line_width = wrapped.width(); - - // First visual line - self.paint_bounds_quad( - window, - self.colors.selection, - point(start_pos.x, line_y + start_pos.y), - point(line_width, line_y + start_pos.y + self.snapshot.line_height), - ); - - // Middle visual lines - for visual_line in (start_visual_line + 1)..end_visual_line { - let y = self.snapshot.line_height * visual_line as f32; - self.paint_bounds_quad( - window, - self.colors.selection, - point(px(0.), line_y + y), - point(line_width, line_y + y + self.snapshot.line_height), - ); - } - - // Last visual line - self.paint_bounds_quad( - window, - self.colors.selection, - point(px(0.), line_y + end_pos.y), - point(end_pos.x, line_y + end_pos.y + self.snapshot.line_height), - ); - } + } else { + self.paint_line_range( + window, + line, + &self.snapshot.selected_range, + self.colors.selection, + px(0.), + ); } } } @@ -582,10 +531,6 @@ impl<'app> PaintContext<'app> { for line in &self.snapshot.line_layouts { let line_y = line.y_offset - self.snapshot.scroll_offset; - if line.text_range.is_empty() { - continue; - } - if !self.is_line_visible(line_y, line.visual_line_count) { continue; } @@ -594,67 +539,17 @@ impl<'app> PaintContext<'app> { continue; } - let Some(wrapped) = &line.wrapped_line else { + if line.text_range.is_empty() { continue; - }; - let line_start = line.text_range.start; - let line_end = line.text_range.end; - - let mark_start = marked_range.start.max(line_start) - line_start; - let mark_end = marked_range.end.min(line_end) - line_start; - - let start_pos = wrapped - .position_for_index(mark_start, self.snapshot.line_height) - .unwrap_or_default(); - let end_pos = wrapped - .position_for_index(mark_end, self.snapshot.line_height) - .unwrap_or_else(|| { - let last_line_y = - self.snapshot.line_height * (line.visual_line_count - 1) as f32; - point(wrapped.width(), last_line_y) - }); - - let start_visual_line = self.compute_visual_line_index(start_pos.y); - let end_visual_line = self.compute_visual_line_index(end_pos.y); - - if start_visual_line == end_visual_line { - self.paint_bounds_quad( - window, - self.colors.cursor, - point(start_pos.x, line_y + start_pos.y + underline_offset), - point(end_pos.x, line_y + start_pos.y + self.snapshot.line_height), - ); - } else { - // First visual line - self.paint_bounds_quad( - window, - self.colors.cursor, - point(start_pos.x, line_y + start_pos.y + underline_offset), - point( - wrapped.width(), - line_y + start_pos.y + self.snapshot.line_height, - ), - ); - - // Middle visual lines - for visual_line in (start_visual_line + 1)..end_visual_line { - let y = self.snapshot.line_height * visual_line as f32; - self.paint_bounds_quad( - window, - self.colors.cursor, - point(px(0.), line_y + y + underline_offset), - point(wrapped.width(), line_y + y + self.snapshot.line_height), - ); - } - - // Last visual line - self.paint_bounds_quad( - window, - self.colors.cursor, - point(px(0.), line_y + end_pos.y + underline_offset), - point(end_pos.x, line_y + end_pos.y + self.snapshot.line_height), - ); } + + self.paint_line_range( + window, + line, + marked_range, + self.colors.cursor, + underline_offset, + ); } } super::InputLayout::SingleLine => { @@ -762,6 +657,78 @@ impl<'app> PaintContext<'app> { fn compute_visual_line_index(&self, y: Pixels) -> usize { (y / self.snapshot.line_height).floor() as usize } + + fn paint_line_range( + &self, + window: &mut Window, + line: &InputLineLayout, + subrange: &Range, + color: Hsla, + quad_offset_y: Pixels, + ) { + let Some(wrapped) = &line.wrapped_line else { + return; + }; + + let line_y = line.y_offset - self.snapshot.scroll_offset; + + let line_start = line.text_range.start; + let line_end = line.text_range.end; + + let subrange_start = subrange.start.max(line_start) - line_start; + let subrange_end = subrange.end.min(line_end) - line_start; + + let start_pos = wrapped + .position_for_index(subrange_start, self.snapshot.line_height) + .unwrap_or_default(); + let end_pos = wrapped + .position_for_index(subrange_end, self.snapshot.line_height) + .unwrap_or_else(|| { + let last_line_y = self.snapshot.line_height * (line.visual_line_count - 1) as f32; + point(wrapped.width(), last_line_y) + }); + + let start_visual_line = self.compute_visual_line_index(start_pos.y); + let end_visual_line = self.compute_visual_line_index(end_pos.y); + + if start_visual_line == end_visual_line { + self.paint_bounds_quad( + window, + color, + point(start_pos.x, line_y + start_pos.y + quad_offset_y), + point(end_pos.x, line_y + start_pos.y + self.snapshot.line_height), + ); + } else { + let line_width = wrapped.width(); + + // First visual line + self.paint_bounds_quad( + window, + color, + point(start_pos.x, line_y + start_pos.y + quad_offset_y), + point(line_width, line_y + start_pos.y + self.snapshot.line_height), + ); + + // Middle visual lines + for visual_line in (start_visual_line + 1)..end_visual_line { + let y = self.snapshot.line_height * visual_line as f32; + self.paint_bounds_quad( + window, + color, + point(px(0.), line_y + y + quad_offset_y), + point(line_width, line_y + y + self.snapshot.line_height), + ); + } + + // Last visual line + self.paint_bounds_quad( + window, + color, + point(px(0.), line_y + end_pos.y + quad_offset_y), + point(end_pos.x, line_y + end_pos.y + self.snapshot.line_height), + ); + } + } } fn line_intersects_range( From 2e20f98aaf67f511a8e72219025fa349dc277f14 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 12:36:19 -0400 Subject: [PATCH 016/117] reduce is_line_visible to take InputLineLayout --- crates/gpui_elements/src/input/paint.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index 1cca0a8f36..8662b07338 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -372,7 +372,7 @@ impl<'app> PaintContext<'app> { for line in &self.snapshot.line_layouts { let line_y = line.y_offset - self.snapshot.scroll_offset; - if !self.is_line_visible(line_y, line.visual_line_count) { + if !self.is_line_visible(line) { continue; } @@ -472,7 +472,7 @@ impl<'app> PaintContext<'app> { for line_layout in &self.snapshot.line_layouts { let line_y = line_layout.y_offset - self.snapshot.scroll_offset; - if !self.is_line_visible(line_y, line_layout.visual_line_count) { + if !self.is_line_visible(line_layout) { continue; } @@ -529,9 +529,7 @@ impl<'app> PaintContext<'app> { let underline_offset = self.snapshot.line_height - underline_thickness; for line in &self.snapshot.line_layouts { - let line_y = line.y_offset - self.snapshot.scroll_offset; - - if !self.is_line_visible(line_y, line.visual_line_count) { + if !self.is_line_visible(line) { continue; } @@ -588,7 +586,7 @@ impl<'app> PaintContext<'app> { for line in &self.snapshot.line_layouts { let line_y = line.y_offset - self.snapshot.scroll_offset; - if !self.is_line_visible(line_y, line.visual_line_count) { + if !self.is_line_visible(line) { continue; } @@ -649,8 +647,9 @@ impl<'app> PaintContext<'app> { )); } - fn is_line_visible(&self, line_y: Pixels, visual_line_count: usize) -> bool { - let line_bottom = line_y + self.snapshot.line_height * visual_line_count as f32; + fn is_line_visible(&self, line: &InputLineLayout) -> bool { + let line_y = line.y_offset - self.snapshot.scroll_offset; + let line_bottom = line_y + self.snapshot.line_height * line.visual_line_count as f32; line_bottom >= px(0.) && line_y <= self.bounds.size.height } From 890a0e6daf36dd5f6c0474ebf9131c9b39be64fa Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 12:41:43 -0400 Subject: [PATCH 017/117] reorganize math --- crates/gpui_elements/src/input/paint.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index 8662b07338..7c3fad8f06 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -523,11 +523,11 @@ impl<'app> PaintContext<'app> { if marked_range.is_empty() { return; } + + let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); + let underline_offset = self.snapshot.line_height - underline_thickness; match self.snapshot.layout { super::InputLayout::MultiLine => { - let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); - let underline_offset = self.snapshot.line_height - underline_thickness; - for line in &self.snapshot.line_layouts { if !self.is_line_visible(line) { continue; @@ -567,16 +567,14 @@ impl<'app> PaintContext<'app> { &precomputed.text_width, ) - self.snapshot.scroll_offset; - let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); let y_offset = (self.bounds.size.height - self.snapshot.line_height).max(px(0.)) / 2.0; - let underline_y = y_offset + self.snapshot.line_height - underline_thickness; self.paint_bounds_quad( window, self.colors.cursor, - point(start_x, underline_y), - point(end_x, underline_y + underline_thickness), + point(start_x, y_offset + underline_offset), + point(end_x, y_offset + self.snapshot.line_height), ); } } From 4037fc5a9203d78abb3e9327871f5a0eb7394e37 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 12:56:47 -0400 Subject: [PATCH 018/117] rework InputState::cursor_visible to be more clear that its a mutation during paint --- crates/gpui_elements/src/input/paint.rs | 9 +++-- crates/gpui_elements/src/input/state.rs | 44 ++++++++++++++----------- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index 7c3fad8f06..2bf64d13f8 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -139,10 +139,9 @@ impl Element for Input { let is_focused = focus_handle.is_focused(window); let colors = self.colors; - // TODO: refactor cursor_visible so it is clear that it is called on_paint - let cursor_visible = self - .input - .update(cx, |input, cx| input.cursor_visible(is_focused, cx)); + let is_cursor_visible = self.input.update(cx, |input, cx| { + input.toggle_cursor_on_focus_change(is_focused, cx) + }); let perform_paint = |_style: &Style, window: &mut Window, cx: &mut App| { let precomputed_first_line = match (snapshot.layout, snapshot.line_layouts.first()) { @@ -166,7 +165,7 @@ impl Element for Input { text_style: &text_style, placeholder: placeholder.as_ref(), colors: &colors, - cursor_visible, + cursor_visible: is_cursor_visible, precomputed_first_line, }; context.process_mouse_events(&self.input, window, cx); diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index 41e27f241a..0a9a3bbf7b 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -1,5 +1,5 @@ use super::actions::*; -use crate::input::{InputLayout, unicode::UnicodeString}; +use crate::input::{CursorBlink, InputLayout, unicode::UnicodeString}; use gpui::{ App, AppContext, ClipboardItem, Context, Entity, EntityId, EntityInputHandler, EventEmitter, FocusHandle, Focusable, Pixels, Point, SharedString, Subscription, TextRun, TextStyle, Window, @@ -151,27 +151,33 @@ impl InputState { self } - /// Returns whether the cursor should be visible (for blinking). - /// - /// If blinking is not enabled, always returns `true`. - /// This method also updates the blink manager's enabled state based on focus. - pub fn cursor_visible(&mut self, is_focused: bool, cx: &mut Context) -> bool { + /// Processes a focus-flag update during window paint, returning whether the cursor should be visible in this frame. + /// Returns false if the cursor is blinking and not currently visible. + pub(super) fn toggle_cursor_on_focus_change( + &mut self, + is_focused: bool, + cx: &mut Context, + ) -> bool { // Update cursor blink based on focus changes - if let Some((cursor_blink, _)) = &self.cursor_blink { - if is_focused && !self.was_focused { - cursor_blink.update(cx, |cb, cx| cb.enable(cx)); - cx.emit(InputStateEvent::Focus); - } else if !is_focused && self.was_focused { - cursor_blink.update(cx, |cb, cx| cb.disable(cx)); - cx.emit(InputStateEvent::Blur); - } - } + let was_focused = self.was_focused; self.was_focused = is_focused; - self.cursor_blink - .as_ref() - .map(|(cb, _)| cb.read(cx).visible()) - .unwrap_or(true) + match &self.cursor_blink { + None => true, + Some((cursor_blink, _)) => match (is_focused, was_focused) { + (true, false) => { + cursor_blink.update(cx, |cursor, cx| cursor.enable(cx)); + cx.emit(InputStateEvent::Focus); + true + } + (false, true) => { + cursor_blink.update(cx, |cursor, cx| cursor.disable(cx)); + cx.emit(InputStateEvent::Blur); + false + } + _ => cursor_blink.read(cx).visible(), + }, + } } /// Pauses cursor blinking temporarily (e.g., during typing). From 1675c73726602b3d89d8480ef0da381fa1e9dd72 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 13:36:30 -0400 Subject: [PATCH 019/117] separate update_line_layouts from other mutations in prepaint --- crates/gpui_elements/src/input/paint.rs | 22 +++--- crates/gpui_elements/src/input/state.rs | 78 +++++++++---------- .../src/input/state_input_handler.rs | 2 +- 3 files changed, 48 insertions(+), 54 deletions(-) diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index 2bf64d13f8..f5f1bf600c 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -1,4 +1,4 @@ -use crate::input::{Input, InputLineLayout, InputState, PaintColors}; +use crate::input::{Input, InputLogicalLine, InputState, PaintColors}; use gpui::{ Along, App, Bounds, ContentMask, CursorStyle, DispatchPhase, Element, ElementId, ElementInputHandler, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla, @@ -86,14 +86,16 @@ impl Element for Input { .line_height_in_pixels(window.rem_size()); let wrap_width = match self.input.read(cx).get_layout() { - super::InputLayout::SingleLine => px(100000.), - super::InputLayout::MultiLine => bounds.size.width, + super::InputLayout::SingleLine => None, + super::InputLayout::MultiLine => Some(bounds.size.width), }; self.input.update(cx, |input, _cx| { input.available_height = bounds.size.height; input.available_width = bounds.size.width; - input.update_line_layouts(wrap_width, line_height, &layout_state.text_style, window); + input.line_height = line_height; + input.set_text_style(&layout_state.text_style); + input.update_line_layouts(wrap_width, window); }); let hitbox = self.interactivity.prepaint( @@ -147,7 +149,7 @@ impl Element for Input { let precomputed_first_line = match (snapshot.layout, snapshot.line_layouts.first()) { ( super::InputLayout::SingleLine, - Some(InputLineLayout { + Some(InputLogicalLine { wrapped_line: Some(wrapped_line), .. }), @@ -191,7 +193,7 @@ struct InputStateSnapshot { selected_range: Range, marked_range: Option>, cursor_offset: usize, - line_layouts: Vec, + line_layouts: Vec, scroll_offset: Pixels, line_height: Pixels, } @@ -201,7 +203,7 @@ impl InputStateSnapshot { let selected_range = input_state.selected_range().clone(); let marked_range = input_state.marked_range().cloned(); let cursor_offset = input_state.cursor_offset(); - let line_layouts = input_state.line_layouts.clone(); + let line_layouts = input_state.logical_lines.clone(); let scroll_offset = input_state.scroll_offset; let line_height = input_state.line_height; Self { @@ -299,7 +301,7 @@ impl<'app> PaintContext<'app> { let content_size = match axis { gpui::Axis::Horizontal => { let state = input.read(cx); - let line = state.line_layouts.first(); + let line = state.logical_lines.first(); let line = line.and_then(|l| l.wrapped_line.as_ref()); line.map(|w| w.width()).unwrap_or(px(0.)) } @@ -644,7 +646,7 @@ impl<'app> PaintContext<'app> { )); } - fn is_line_visible(&self, line: &InputLineLayout) -> bool { + fn is_line_visible(&self, line: &InputLogicalLine) -> bool { let line_y = line.y_offset - self.snapshot.scroll_offset; let line_bottom = line_y + self.snapshot.line_height * line.visual_line_count as f32; line_bottom >= px(0.) && line_y <= self.bounds.size.height @@ -657,7 +659,7 @@ impl<'app> PaintContext<'app> { fn paint_line_range( &self, window: &mut Window, - line: &InputLineLayout, + line: &InputLogicalLine, subrange: &Range, color: Hsla, quad_offset_y: Pixels, diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index 0a9a3bbf7b..7c4d174109 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -43,7 +43,7 @@ pub struct InputState { pub(super) selection_reversed: bool, pub(super) marked_range: Option>, pub(super) line_height: Pixels, - pub(super) line_layouts: Vec, + pub(super) logical_lines: Vec, pub(super) wrap_width: Option, pub(super) text_style: Option, pub(super) needs_layout: bool, @@ -74,7 +74,7 @@ pub struct InputState { /// A logical line corresponds to content between newlines in the input text. /// When text wrapping is enabled, a logical line may span multiple visual lines. #[derive(Clone, Debug)] -pub(super) struct InputLineLayout { +pub(super) struct InputLogicalLine { /// 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. @@ -112,7 +112,7 @@ impl InputState { selection_reversed: false, marked_range: None, line_height: px(0.), - line_layouts: Vec::new(), + logical_lines: Vec::new(), wrap_width: None, text_style: None, needs_layout: true, @@ -188,13 +188,8 @@ impl InputState { } /// Sets the text style used for layout. Marks layout as dirty if the style changed. - pub(crate) fn set_text_style(&mut self, style: &TextStyle) { - let changed = self - .text_style - .as_ref() - .map_or(true, |current| current != style); - - if changed { + pub(super) fn set_text_style(&mut self, style: &TextStyle) { + if self.text_style.as_ref() != Some(style) { self.text_style = Some(style.clone()); self.needs_layout = true; } @@ -896,7 +891,7 @@ impl InputState { let target_visual_line_idx = (visual_line_idx as i32 + direction).max(0) as usize; let mut current_visual_line = 0; - for layout in self.line_layouts.iter() { + for layout in self.logical_lines.iter() { let visual_lines_in_layout = layout.visual_line_count; if target_visual_line_idx < current_visual_line + visual_lines_in_layout { @@ -934,13 +929,13 @@ impl InputState { } fn find_visual_line_and_x_offset(&self, offset: usize) -> (usize, f32) { - if self.line_layouts.is_empty() { + if self.logical_lines.is_empty() { return (0, 0.0); } let mut visual_line_idx = 0; - for line in &self.line_layouts { + for line in &self.logical_lines { if line.text_range.is_empty() { if offset == line.text_range.start { return (visual_line_idx, 0.0); @@ -968,7 +963,7 @@ impl InputState { return 0; } - for line in self.line_layouts.iter() { + for line in self.logical_lines.iter() { let line_height_total = self.line_height * line.visual_line_count as f32; if position.y >= line.y_offset && position.y < line.y_offset + line_height_total { @@ -995,7 +990,7 @@ impl InputState { } pub(crate) fn scroll_to_cursor(&mut self) { - if self.line_layouts.is_empty() { + if self.logical_lines.is_empty() { return; } @@ -1013,7 +1008,7 @@ impl InputState { let line_height = self.line_height; - for line in &self.line_layouts { + for line in &self.logical_lines { let is_cursor_in_line = if line.text_range.is_empty() { cursor_offset == line.text_range.start } else { @@ -1056,7 +1051,7 @@ impl InputState { } // For single-line input, get cursor x position from the first (only) line - let Some(line) = self.line_layouts.first() else { + let Some(line) = self.logical_lines.first() else { return; }; @@ -1085,28 +1080,23 @@ impl InputState { self.scroll_offset = self.scroll_offset.max(px(0.)); } - pub(crate) fn update_line_layouts( - &mut self, - width: Pixels, - line_height: Pixels, - text_style: &TextStyle, - window: &mut Window, - ) { - self.line_height = line_height; - self.set_text_style(text_style); - - if !self.needs_layout && self.wrap_width == Some(width) { + /// Called internally during prepaint to layout the content into logical lines based on viewport bounds wrapping. + pub(crate) fn update_line_layouts(&mut self, wrap_width: Option, window: &mut Window) { + if !self.needs_layout && self.wrap_width == wrap_width { return; } + let Some(text_style) = &self.text_style else { + return; + }; - self.line_layouts.clear(); - self.wrap_width = Some(width); + self.logical_lines.clear(); + self.wrap_width = wrap_width; let text_color = text_style.color; let font_size = text_style.font_size.to_pixels(window.rem_size()); if self.content.is_empty() { - self.line_layouts.push(InputLineLayout { + self.logical_lines.push(InputLogicalLine { text_range: 0..0, wrapped_line: None, y_offset: px(0.), @@ -1125,19 +1115,19 @@ impl InputState { .map(|pos| current_pos + pos) .unwrap_or(self.content.len()); - let line_text = &self.content[current_pos..line_end]; + let line_slice = &self.content[current_pos..line_end]; - if line_text.is_empty() { - self.line_layouts.push(InputLineLayout { + if line_slice.is_empty() { + self.logical_lines.push(InputLogicalLine { text_range: current_pos..current_pos, wrapped_line: None, y_offset, visual_line_count: 1, }); - y_offset += line_height; + y_offset += self.line_height; } else { let run = TextRun { - len: line_text.len(), + len: line_slice.len(), font: text_style.font(), color: text_color, background_color: None, @@ -1148,19 +1138,19 @@ impl InputState { let wrapped_lines = window .text_system() .shape_text( - SharedString::from(line_text.to_string()), + SharedString::from(line_slice.to_string()), font_size, &[run], - Some(width), + wrap_width, None, ) .unwrap_or_default(); for wrapped in wrapped_lines { let visual_line_count = wrapped.wrap_boundaries().len() + 1; - let line_height_total = line_height * visual_line_count as f32; + let line_height_total = self.line_height * visual_line_count as f32; - self.line_layouts.push(InputLineLayout { + self.logical_lines.push(InputLogicalLine { text_range: current_pos..line_end, wrapped_line: Some(Arc::new(wrapped)), y_offset, @@ -1179,7 +1169,7 @@ impl InputState { } if self.content.ends_with('\n') { - self.line_layouts.push(InputLineLayout { + self.logical_lines.push(InputLogicalLine { text_range: self.content.len()..self.content.len(), wrapped_line: None, y_offset, @@ -1192,7 +1182,7 @@ impl InputState { } pub(crate) fn total_content_height(&self) -> Pixels { - self.line_layouts + self.logical_lines .last() .map(|last| last.y_offset + self.line_height * last.visual_line_count as f32) .unwrap_or(px(0.)) @@ -1373,7 +1363,9 @@ mod tests { let mut input = InputState::new(cx).layout(InputLayout::MultiLine); input.content = content.to_string().into(); input.selected_range = range; - input.update_line_layouts(px(500.), px(20.), &TextStyle::default(), window); + input.line_height = px(20.); + input.set_text_style(&TextStyle::default()); + input.update_line_layouts(Some(px(500.)), window); input }); TestView { input } diff --git a/crates/gpui_elements/src/input/state_input_handler.rs b/crates/gpui_elements/src/input/state_input_handler.rs index a9b9e3d481..4fe80ee16b 100644 --- a/crates/gpui_elements/src/input/state_input_handler.rs +++ b/crates/gpui_elements/src/input/state_input_handler.rs @@ -142,7 +142,7 @@ impl EntityInputHandler for super::InputState { ) -> Option> { let range = self.utf_range_16to8(&range_utf16); - for line in &self.line_layouts { + for line in &self.logical_lines { if line.text_range.is_empty() { if range.start == line.text_range.start { return Some(Bounds::from_corners( From 97c1834435a1443442dfc277605791ae70fcf012 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 16:26:45 -0400 Subject: [PATCH 020/117] reorganize input data updated per element layout --- crates/gpui_elements/src/input/paint.rs | 53 +-- crates/gpui_elements/src/input/state.rs | 314 +++++++++--------- .../src/input/state_input_handler.rs | 20 +- 3 files changed, 202 insertions(+), 185 deletions(-) diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index f5f1bf600c..07ec6d210f 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -1,4 +1,4 @@ -use crate::input::{Input, InputLogicalLine, InputState, PaintColors}; +use crate::input::{Input, InputLayoutData, InputLogicalLine, InputState, PaintColors}; use gpui::{ Along, App, Bounds, ContentMask, CursorStyle, DispatchPhase, Element, ElementId, ElementInputHandler, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla, @@ -64,12 +64,10 @@ impl Element for Input { }, ); - ( - layout_id, - InputLayoutState { - text_style: resolved_text_style.unwrap_or_else(|| window.text_style()), - }, - ) + let layout_state = InputLayoutState { + text_style: resolved_text_style.unwrap_or_else(|| window.text_style()), + }; + (layout_id, layout_state) } fn prepaint( @@ -91,11 +89,22 @@ impl Element for Input { }; self.input.update(cx, |input, _cx| { - input.available_height = bounds.size.height; - input.available_width = bounds.size.width; - input.line_height = line_height; - input.set_text_style(&layout_state.text_style); - input.update_line_layouts(wrap_width, window); + let dirty = input.layout_data.dirty + || input.layout_data.wrap_width != wrap_width + || input.layout_data.text_style != layout_state.text_style; + let layout_data = InputLayoutData { + text_style: layout_state.text_style.clone(), + line_height, + wrap_width, + available_size: bounds.size, + dirty: false, + }; + input.layout_data = layout_data; + if dirty { + input.logical_lines = + InputState::build_logical_lines(input.content(), window, &input.layout_data); + input.scroll_to_cursor(); + } }); let hitbox = self.interactivity.prepaint( @@ -146,7 +155,7 @@ impl Element for Input { }); let perform_paint = |_style: &Style, window: &mut Window, cx: &mut App| { - let precomputed_first_line = match (snapshot.layout, snapshot.line_layouts.first()) { + let precomputed_first_line = match (snapshot.layout, snapshot.logical_lines.first()) { ( super::InputLayout::SingleLine, Some(InputLogicalLine { @@ -193,7 +202,7 @@ struct InputStateSnapshot { selected_range: Range, marked_range: Option>, cursor_offset: usize, - line_layouts: Vec, + logical_lines: Vec, scroll_offset: Pixels, line_height: Pixels, } @@ -203,16 +212,16 @@ impl InputStateSnapshot { let selected_range = input_state.selected_range().clone(); let marked_range = input_state.marked_range().cloned(); let cursor_offset = input_state.cursor_offset(); - let line_layouts = input_state.logical_lines.clone(); + let logical_lines = input_state.logical_lines.clone(); let scroll_offset = input_state.scroll_offset; - let line_height = input_state.line_height; + let line_height = input_state.line_height(); Self { layout: input_state.get_layout(), content: input_state.content().clone(), selected_range, marked_range, cursor_offset, - line_layouts, + logical_lines, scroll_offset, line_height, } @@ -370,7 +379,7 @@ impl<'app> PaintContext<'app> { fn paint_selection(&self, window: &mut Window) { match self.snapshot.layout { super::InputLayout::MultiLine => { - for line in &self.snapshot.line_layouts { + for line in &self.snapshot.logical_lines { let line_y = line.y_offset - self.snapshot.scroll_offset; if !self.is_line_visible(line) { @@ -470,7 +479,7 @@ impl<'app> PaintContext<'app> { fn paint_text(&self, window: &mut Window, cx: &mut App) { match self.snapshot.layout { super::InputLayout::MultiLine => { - for line_layout in &self.snapshot.line_layouts { + for line_layout in &self.snapshot.logical_lines { let line_y = line_layout.y_offset - self.snapshot.scroll_offset; if !self.is_line_visible(line_layout) { @@ -491,7 +500,7 @@ impl<'app> PaintContext<'app> { } } super::InputLayout::SingleLine => { - let Some(line_layout) = self.snapshot.line_layouts.first() else { + let Some(line_layout) = self.snapshot.logical_lines.first() else { return; }; let Some(wrapped_line) = &line_layout.wrapped_line else { @@ -529,7 +538,7 @@ impl<'app> PaintContext<'app> { let underline_offset = self.snapshot.line_height - underline_thickness; match self.snapshot.layout { super::InputLayout::MultiLine => { - for line in &self.snapshot.line_layouts { + for line in &self.snapshot.logical_lines { if !self.is_line_visible(line) { continue; } @@ -582,7 +591,7 @@ impl<'app> PaintContext<'app> { } fn find_cursor_position_in_layouts(&self) -> Point { - for line in &self.snapshot.line_layouts { + for line in &self.snapshot.logical_lines { let line_y = line.y_offset - self.snapshot.scroll_offset; if !self.is_line_visible(line) { diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index 7c4d174109..771d34ff93 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -1,9 +1,9 @@ use super::actions::*; -use crate::input::{CursorBlink, InputLayout, unicode::UnicodeString}; +use crate::input::{InputLayout, unicode::UnicodeString}; use gpui::{ App, AppContext, ClipboardItem, Context, Entity, EntityId, EntityInputHandler, EventEmitter, - FocusHandle, Focusable, Pixels, Point, SharedString, Subscription, TextRun, TextStyle, Window, - WrappedLine, point, px, + FocusHandle, Focusable, Pixels, Point, SharedString, Size, Subscription, TextRun, TextStyle, + Window, WrappedLine, point, px, }; use std::{ ops::Range, @@ -42,18 +42,14 @@ pub struct InputState { pub(super) selected_range: Range, pub(super) selection_reversed: bool, pub(super) marked_range: Option>, - pub(super) line_height: Pixels, pub(super) logical_lines: Vec, - pub(super) wrap_width: Option, - pub(super) text_style: Option, - pub(super) needs_layout: bool, + // refreshed each update by the element, for conveinent access in mutations and painting + pub(super) layout_data: InputLayoutData, is_selecting: bool, last_click_position: Option>, click_count: usize, /// Scroll offset - vertical for multiline, horizontal for single-line pub(super) scroll_offset: Pixels, - pub(super) available_height: Pixels, - pub(super) available_width: Pixels, pub(super) layout: InputLayout, history_grouping_interval: Duration, /// Stack of previous states for undo. @@ -69,6 +65,26 @@ pub struct InputState { pub(super) cached_utf16_len: Option, } +/// Data built during element prepaint that is stored in InputState for conveinence +pub(super) struct InputLayoutData { + pub text_style: TextStyle, + pub wrap_width: Option, + pub available_size: Size, + pub line_height: Pixels, + pub dirty: bool, +} +impl Default for InputLayoutData { + fn default() -> Self { + Self { + text_style: Default::default(), + wrap_width: Default::default(), + available_size: Default::default(), + line_height: Default::default(), + dirty: true, + } + } +} + /// Layout information for a single logical line of text in an input. /// /// A logical line corresponds to content between newlines in the input text. @@ -111,17 +127,12 @@ impl InputState { selected_range: 0..0, selection_reversed: false, marked_range: None, - line_height: px(0.), + layout_data: InputLayoutData::default(), logical_lines: Vec::new(), - wrap_width: None, - text_style: None, - needs_layout: true, is_selecting: false, last_click_position: None, click_count: 0, scroll_offset: px(0.), - available_height: px(0.), - available_width: px(0.), layout: InputLayout::SingleLine, history_grouping_interval: super::DEFAULT_GROUP_INTERVAL, undo_stack: Vec::new(), @@ -187,19 +198,28 @@ impl InputState { } } - /// Sets the text style used for layout. Marks layout as dirty if the style changed. - pub(super) fn set_text_style(&mut self, style: &TextStyle) { - if self.text_style.as_ref() != Some(style) { - self.text_style = Some(style.clone()); - self.needs_layout = true; - } - } - /// Returns the current text content. pub fn content(&self) -> &SharedString { &self.content } + /// Sets the text content, resetting selection to the beginning. + /// This clears the undo/redo history. + pub fn set_content(&mut self, content: impl AsRef, cx: &mut Context) { + let content = self.layout.sanitize_content(content.as_ref()); + self.content = content.to_string().into(); + self.selected_range = 0..0; + self.selection_reversed = false; + self.marked_range = None; + self.layout_data.dirty = true; + self.undo_stack.clear(); + self.redo_stack.clear(); + self.cached_utf16_len = None; + self.pause_cursor_blink(cx); + cx.emit(InputStateEvent::TextChanged); + cx.notify(); + } + pub fn layout(mut self, layout: InputLayout) -> Self { self.layout = layout; self @@ -209,21 +229,8 @@ impl InputState { self.layout } - /// Sets the text content, resetting selection to the beginning. - /// This clears the undo/redo history. - pub fn set_content(&mut self, content: impl AsRef, cx: &mut Context) { - let content = self.layout.sanitize_content(content.as_ref()); - self.content = content.to_string().into(); - self.selected_range = 0..0; - self.selection_reversed = false; - self.marked_range = None; - self.needs_layout = true; - self.undo_stack.clear(); - self.redo_stack.clear(); - self.cached_utf16_len = None; - self.pause_cursor_blink(cx); - cx.emit(InputStateEvent::TextChanged); - cx.notify(); + pub fn line_height(&self) -> Pixels { + self.layout_data.line_height } pub fn set_history_group_interval(&mut self, interval: Duration) { @@ -280,49 +287,6 @@ impl InputState { self.redo_stack.clear(); } - /// Undoes the last edit by applying the reverse patch. - pub(crate) fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context) { - if let Some(entry) = self.undo_stack.pop() { - // Remember selection to restore - let selected_range = entry.selected_range.clone(); - let selection_reversed = entry.selection_reversed; - - // Apply the undo patch and get the redo patch - let redo_entry = entry.apply_undo(&mut self.content); - self.redo_stack.push(redo_entry); - - // Restore selection state - self.selected_range = selected_range; - self.selection_reversed = selection_reversed; - self.needs_layout = true; - self.cached_utf16_len = None; - self.scroll_to_cursor(); - cx.emit(InputStateEvent::Undo); - cx.notify(); - } - } - - /// Redoes the last undone edit by applying the forward patch. - pub(crate) fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context) { - if let Some(entry) = self.redo_stack.pop() { - // Apply the redo patch and get the undo patch - let undo_entry = entry.apply_redo(&mut self.content); - - // The undo entry contains the selection state after the original edit - // We need to restore cursor to end of inserted text - let cursor_pos = undo_entry.range.start; - self.selected_range = cursor_pos..cursor_pos; - self.selection_reversed = false; - - self.undo_stack.push(undo_entry); - self.needs_layout = true; - self.cached_utf16_len = None; - self.scroll_to_cursor(); - cx.emit(InputStateEvent::Redo); - cx.notify(); - } - } - /// Returns the placeholder text shown when content is empty. pub fn placeholder(&self) -> &SharedString { &self.placeholder @@ -401,7 +365,7 @@ impl InputState { self.selected_range = range.start + text_to_insert.len()..range.start + text_to_insert.len(); self.marked_range.take(); - self.needs_layout = true; + self.layout_data.dirty = true; self.pause_cursor_blink(cx); cx.emit(InputStateEvent::TextChanged); cx.notify(); @@ -426,7 +390,7 @@ impl InputState { self.selected_range = selected_range; self.selection_reversed = selection_reversed; - self.needs_layout = true; + self.layout_data.dirty = true; self.cached_utf16_len = None; self.scroll_to_cursor(); cx.emit(InputStateEvent::Undo); @@ -444,7 +408,53 @@ impl InputState { self.selection_reversed = false; self.undo_stack.push(undo_entry); - self.needs_layout = true; + self.layout_data.dirty = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Redo); + cx.notify(); + } + } +} + +// Action implementations +impl InputState { + /// Undoes the last edit by applying the reverse patch. + pub(crate) fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context) { + if let Some(entry) = self.undo_stack.pop() { + // Remember selection to restore + let selected_range = entry.selected_range.clone(); + let selection_reversed = entry.selection_reversed; + + // Apply the undo patch and get the redo patch + let redo_entry = entry.apply_undo(&mut self.content); + self.redo_stack.push(redo_entry); + + // Restore selection state + self.selected_range = selected_range; + self.selection_reversed = selection_reversed; + self.layout_data.dirty = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Undo); + cx.notify(); + } + } + + /// Redoes the last undone edit by applying the forward patch. + pub(crate) fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context) { + if let Some(entry) = self.redo_stack.pop() { + // Apply the redo patch and get the undo patch + let undo_entry = entry.apply_redo(&mut self.content); + + // The undo entry contains the selection state after the original edit + // We need to restore cursor to end of inserted text + let cursor_pos = undo_entry.range.start; + self.selected_range = cursor_pos..cursor_pos; + self.selection_reversed = false; + + self.undo_stack.push(undo_entry); + self.layout_data.dirty = true; self.cached_utf16_len = None; self.scroll_to_cursor(); cx.emit(InputStateEvent::Redo); @@ -872,20 +882,6 @@ impl InputState { cx.notify(); } - pub(crate) fn find_line_start(&self, offset: usize) -> usize { - self.content[..offset.min(self.content.len())] - .rfind('\n') - .map(|pos| pos + 1) - .unwrap_or(0) - } - - pub(crate) fn find_line_end(&self, offset: usize) -> usize { - self.content[offset.min(self.content.len())..] - .find('\n') - .map(|pos| offset + pos) - .unwrap_or(self.content.len()) - } - fn move_vertically(&self, offset: usize, direction: i32) -> Option { let (visual_line_idx, x_pixels) = self.find_visual_line_and_x_offset(offset); let target_visual_line_idx = (visual_line_idx as i32 + direction).max(0) as usize; @@ -902,11 +898,11 @@ impl InputState { } if let Some(wrapped) = &layout.wrapped_line { - let y_within_wrapped = self.line_height * visual_line_within_layout as f32; + let y_within_wrapped = self.line_height() * visual_line_within_layout as f32; let target_point = point(px(x_pixels), y_within_wrapped); let closest_result = - wrapped.closest_index_for_position(target_point, self.line_height); + wrapped.closest_index_for_position(target_point, self.line_height()); let closest_idx = closest_result.unwrap_or_else(|closest| closest); let clamped = closest_idx.min(wrapped.text.len()); @@ -927,6 +923,22 @@ impl InputState { None } } +} + +impl InputState { + pub(crate) fn find_line_start(&self, offset: usize) -> usize { + self.content[..offset.min(self.content.len())] + .rfind('\n') + .map(|pos| pos + 1) + .unwrap_or(0) + } + + pub(crate) fn find_line_end(&self, offset: usize) -> usize { + self.content[offset.min(self.content.len())..] + .find('\n') + .map(|pos| offset + pos) + .unwrap_or(self.content.len()) + } fn find_visual_line_and_x_offset(&self, offset: usize) -> (usize, f32) { if self.logical_lines.is_empty() { @@ -944,9 +956,9 @@ impl InputState { if let Some(wrapped) = &line.wrapped_line { let local_offset = (offset - line.text_range.start).min(wrapped.text.len()); if let Some(position) = - wrapped.position_for_index(local_offset, self.line_height) + wrapped.position_for_index(local_offset, self.line_height()) { - let visual_line_within = (position.y / self.line_height).floor() as usize; + let visual_line_within = (position.y / self.line_height()).floor() as usize; return (visual_line_idx + visual_line_within, position.x.into()); } } @@ -964,7 +976,7 @@ impl InputState { } for line in self.logical_lines.iter() { - let line_height_total = self.line_height * line.visual_line_count as f32; + let line_height_total = self.line_height() * line.visual_line_count as f32; if position.y >= line.y_offset && position.y < line.y_offset + line_height_total { if line.text_range.is_empty() { @@ -976,7 +988,7 @@ impl InputState { let relative_point = point(position.x, relative_y); let closest_result = - wrapped.closest_index_for_position(relative_point, self.line_height); + wrapped.closest_index_for_position(relative_point, self.line_height()); let local_idx = closest_result.unwrap_or_else(|closest| closest); let clamped = local_idx.min(wrapped.text.len()); @@ -1002,11 +1014,11 @@ impl InputState { } fn scroll_to_cursor_vertical(&mut self, cursor_offset: usize) { - if self.available_height <= px(0.) { + if self.layout_data.available_size.height <= px(0.) { return; } - let line_height = self.line_height; + let line_height = self.line_height(); for line in &self.logical_lines { let is_cursor_in_line = if line.text_range.is_empty() { @@ -1020,7 +1032,7 @@ impl InputState { let cursor_visual_y = if let Some(wrapped) = &line.wrapped_line { let local_offset = cursor_offset.saturating_sub(line.text_range.start); if let Some(position) = - wrapped.position_for_index(local_offset, self.line_height) + wrapped.position_for_index(local_offset, self.line_height()) { line.y_offset + position.y } else { @@ -1031,12 +1043,13 @@ impl InputState { }; let visible_top = self.scroll_offset; - let visible_bottom = self.scroll_offset + self.available_height; + let visible_bottom = self.scroll_offset + self.layout_data.available_size.height; if cursor_visual_y < visible_top { self.scroll_offset = cursor_visual_y; } else if cursor_visual_y + line_height > visible_bottom { - self.scroll_offset = (cursor_visual_y + line_height) - self.available_height; + self.scroll_offset = + (cursor_visual_y + line_height) - self.layout_data.available_size.height; } self.scroll_offset = self.scroll_offset.max(px(0.)); @@ -1046,7 +1059,7 @@ impl InputState { } fn scroll_to_cursor_horizontal(&mut self, cursor_offset: usize) { - if self.available_width <= px(0.) { + if self.layout_data.available_size.width <= px(0.) { return; } @@ -1058,7 +1071,7 @@ impl InputState { let cursor_x = if let Some(wrapped) = &line.wrapped_line { let local_offset = cursor_offset.saturating_sub(line.text_range.start); wrapped - .position_for_index(local_offset, self.line_height) + .position_for_index(local_offset, self.line_height()) .map(|p| p.x) .unwrap_or(px(0.)) } else { @@ -1066,7 +1079,7 @@ impl InputState { }; let visible_left = self.scroll_offset; - let visible_right = self.scroll_offset + self.available_width; + let visible_right = self.scroll_offset + self.layout_data.available_size.width; // Add some padding so cursor isn't right at the edge let padding = px(2.0); @@ -1074,57 +1087,53 @@ impl InputState { if cursor_x < visible_left + padding { self.scroll_offset = (cursor_x - padding).max(px(0.)); } else if cursor_x > visible_right - padding { - self.scroll_offset = cursor_x - self.available_width + padding; + self.scroll_offset = cursor_x - self.layout_data.available_size.width + padding; } self.scroll_offset = self.scroll_offset.max(px(0.)); } /// Called internally during prepaint to layout the content into logical lines based on viewport bounds wrapping. - pub(crate) fn update_line_layouts(&mut self, wrap_width: Option, window: &mut Window) { - if !self.needs_layout && self.wrap_width == wrap_width { - return; - } - let Some(text_style) = &self.text_style else { - return; - }; - - self.logical_lines.clear(); - self.wrap_width = wrap_width; + pub(super) fn build_logical_lines( + content: &str, + window: &mut Window, + layout_data: &InputLayoutData, + ) -> Vec { + let text_style = &layout_data.text_style; + let mut logical_lines = Vec::new(); let text_color = text_style.color; let font_size = text_style.font_size.to_pixels(window.rem_size()); - if self.content.is_empty() { - self.logical_lines.push(InputLogicalLine { + if content.is_empty() { + logical_lines.push(InputLogicalLine { text_range: 0..0, wrapped_line: None, y_offset: px(0.), visual_line_count: 1, }); - self.needs_layout = false; - return; + return logical_lines; } let mut y_offset = px(0.); let mut current_pos = 0; - while current_pos < self.content.len() { - let line_end = self.content[current_pos..] + while current_pos < content.len() { + let line_end = content[current_pos..] .find('\n') .map(|pos| current_pos + pos) - .unwrap_or(self.content.len()); + .unwrap_or(content.len()); - let line_slice = &self.content[current_pos..line_end]; + let line_slice = &content[current_pos..line_end]; if line_slice.is_empty() { - self.logical_lines.push(InputLogicalLine { + logical_lines.push(InputLogicalLine { text_range: current_pos..current_pos, wrapped_line: None, y_offset, visual_line_count: 1, }); - y_offset += self.line_height; + y_offset += layout_data.line_height; } else { let run = TextRun { len: line_slice.len(), @@ -1141,16 +1150,16 @@ impl InputState { SharedString::from(line_slice.to_string()), font_size, &[run], - wrap_width, + layout_data.wrap_width, None, ) .unwrap_or_default(); for wrapped in wrapped_lines { let visual_line_count = wrapped.wrap_boundaries().len() + 1; - let line_height_total = self.line_height * visual_line_count as f32; + let line_height_total = layout_data.line_height * visual_line_count as f32; - self.logical_lines.push(InputLogicalLine { + logical_lines.push(InputLogicalLine { text_range: current_pos..line_end, wrapped_line: Some(Arc::new(wrapped)), y_offset, @@ -1161,30 +1170,29 @@ impl InputState { } } - current_pos = if line_end < self.content.len() { + current_pos = if line_end < content.len() { line_end + 1 } else { - self.content.len() + content.len() }; } - if self.content.ends_with('\n') { - self.logical_lines.push(InputLogicalLine { - text_range: self.content.len()..self.content.len(), + if content.ends_with('\n') { + logical_lines.push(InputLogicalLine { + text_range: content.len()..content.len(), wrapped_line: None, y_offset, visual_line_count: 1, }); } - self.needs_layout = false; - self.scroll_to_cursor(); + logical_lines } pub(crate) fn total_content_height(&self) -> Pixels { self.logical_lines .last() - .map(|last| last.y_offset + self.line_height * last.visual_line_count as f32) + .map(|last| last.y_offset + self.line_height() * last.visual_line_count as f32) .unwrap_or(px(0.)) } @@ -1196,7 +1204,7 @@ impl InputState { /// Returns true if the scroll position is at the bottom. pub fn at_bottom(&self) -> bool { let content_height = self.total_content_height(); - let visible_height = self.available_height; + let visible_height = self.layout_data.available_size.height; if content_height <= visible_height { return true; @@ -1208,7 +1216,7 @@ impl InputState { /// Returns the scroll progress as a value from 0.0 (top) to 1.0 (bottom). pub fn scroll_progress(&self) -> f32 { let content_height = self.total_content_height(); - let visible_height = self.available_height; + let visible_height = self.layout_data.available_size.height; let max_scroll = content_height - visible_height; if max_scroll <= px(0.) { @@ -1226,7 +1234,7 @@ impl InputState { /// Returns how far the content is from the bottom in pixels. pub fn distance_from_bottom(&self) -> Pixels { let content_height = self.total_content_height(); - let visible_height = self.available_height; + let visible_height = self.layout_data.available_size.height; let max_scroll = content_height - visible_height; if max_scroll <= px(0.) { @@ -1323,9 +1331,7 @@ impl InputState { #[cfg(test)] mod tests { use super::*; - use gpui::{ - AppContext, Entity, IntoElement, Render, TestAppContext, TextStyle, WindowHandle, div, - }; + use gpui::{AppContext, Entity, IntoElement, Render, TestAppContext, WindowHandle, div}; struct TestView { input: Entity, @@ -1353,6 +1359,7 @@ mod tests { }) } + #[allow(dead_code)] fn create_test_input_with_layout( cx: &mut TestAppContext, content: &str, @@ -1363,9 +1370,10 @@ mod tests { let mut input = InputState::new(cx).layout(InputLayout::MultiLine); input.content = content.to_string().into(); input.selected_range = range; - input.line_height = px(20.); - input.set_text_style(&TextStyle::default()); - input.update_line_layouts(Some(px(500.)), window); + input.layout_data.line_height = px(20.); + input.layout_data.wrap_width = Some(px(500.)); + input.logical_lines = + InputState::build_logical_lines(&input.content, window, &input.layout_data); input }); TestView { input } diff --git a/crates/gpui_elements/src/input/state_input_handler.rs b/crates/gpui_elements/src/input/state_input_handler.rs index 4fe80ee16b..c62a8aaaff 100644 --- a/crates/gpui_elements/src/input/state_input_handler.rs +++ b/crates/gpui_elements/src/input/state_input_handler.rs @@ -78,7 +78,7 @@ impl EntityInputHandler for super::InputState { self.selected_range = range.start + text_to_insert.len()..range.start + text_to_insert.len(); self.marked_range.take(); - self.needs_layout = true; + self.layout_data.dirty = true; self.pause_cursor_blink(cx); cx.emit(InputStateEvent::TextChanged); cx.notify(); @@ -128,7 +128,7 @@ impl EntityInputHandler for super::InputState { range.start + text_to_insert.len()..range.start + text_to_insert.len() }); - self.needs_layout = true; + self.layout_data.dirty = true; cx.emit(InputStateEvent::TextChanged); cx.notify(); } @@ -149,7 +149,7 @@ impl EntityInputHandler for super::InputState { point(bounds.left(), bounds.top() + line.y_offset), point( bounds.left() + px(4.), - bounds.top() + line.y_offset + self.line_height, + bounds.top() + line.y_offset + self.line_height(), ), )); } @@ -159,18 +159,18 @@ impl EntityInputHandler for super::InputState { let local_end = (range.end - line.text_range.start).min(wrapped.text.len()); let start_pos = wrapped - .position_for_index(local_start, self.line_height) + .position_for_index(local_start, self.line_height()) .unwrap_or(point(px(0.), px(0.))); let end_pos = wrapped - .position_for_index(local_end, self.line_height) + .position_for_index(local_end, self.line_height()) .unwrap_or_else(|| { let last_line_y = - self.line_height * (line.visual_line_count - 1) as f32; + self.line_height() * (line.visual_line_count - 1) as f32; point(wrapped.width(), last_line_y) }); - let start_visual_line = (start_pos.y / self.line_height).floor() as usize; - let end_visual_line = (end_pos.y / self.line_height).floor() as usize; + let start_visual_line = (start_pos.y / self.line_height()).floor() as usize; + let end_visual_line = (end_pos.y / self.line_height()).floor() as usize; if start_visual_line == end_visual_line { return Some(Bounds::from_corners( @@ -180,7 +180,7 @@ impl EntityInputHandler for super::InputState { ), point( bounds.left() + end_pos.x, - bounds.top() + line.y_offset + start_pos.y + self.line_height, + bounds.top() + line.y_offset + start_pos.y + self.line_height(), ), )); } else { @@ -191,7 +191,7 @@ impl EntityInputHandler for super::InputState { ), point( bounds.left() + wrapped.width(), - bounds.top() + line.y_offset + start_pos.y + self.line_height, + bounds.top() + line.y_offset + start_pos.y + self.line_height(), ), )); } From cfea213c4e56eacd0ae0f23edd33e54ca12d52ad Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 16:32:41 -0400 Subject: [PATCH 021/117] make InputState content more private --- crates/gpui_elements/src/input/state.rs | 13 +++++++------ .../gpui_elements/src/input/state_input_handler.rs | 5 +++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index 771d34ff93..8a9302dd52 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -1,5 +1,5 @@ use super::actions::*; -use crate::input::{InputLayout, unicode::UnicodeString}; +use crate::input::InputLayout; use gpui::{ App, AppContext, ClipboardItem, Context, Entity, EntityId, EntityInputHandler, EventEmitter, FocusHandle, Focusable, Pixels, Point, SharedString, Size, Subscription, TextRun, TextStyle, @@ -37,7 +37,7 @@ impl EventEmitter for InputState {} pub struct InputState { entity_id: EntityId, focus_handle: FocusHandle, - pub(super) content: SharedString, + content: SharedString, placeholder: SharedString, pub(super) selected_range: Range, pub(super) selection_reversed: bool, @@ -115,6 +115,7 @@ impl Focusable for InputState { } } +// External API impl InputState { /// Creates a new `Input` with the specified multiline setting. /// Cursor blinking is enabled by default. @@ -333,9 +334,8 @@ impl InputState { self.selection_reversed = false; } - /// Returns the selected text range in UTF-16 offsets (for IME). - pub fn selected_text_range_utf16(&self) -> Range { - self.utf_range_8to16(&self.selected_range) + pub(super) fn replace_range(&mut self, range: Range, text: &str) { + crate::input::replace_range(&mut self.content, range, &text); } /// Inserts text at the current cursor position, replacing any selection. @@ -361,7 +361,8 @@ impl InputState { self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); } - crate::input::replace_range(&mut self.content, range.clone(), &text_to_insert); + self.replace_range(range.clone(), &text_to_insert); + self.selected_range = range.start + text_to_insert.len()..range.start + text_to_insert.len(); self.marked_range.take(); diff --git a/crates/gpui_elements/src/input/state_input_handler.rs b/crates/gpui_elements/src/input/state_input_handler.rs index c62a8aaaff..b51f2a7992 100644 --- a/crates/gpui_elements/src/input/state_input_handler.rs +++ b/crates/gpui_elements/src/input/state_input_handler.rs @@ -74,7 +74,8 @@ impl EntityInputHandler for super::InputState { self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); } - crate::input::replace_range(&mut self.content, range.clone(), &text_to_insert); + self.replace_range(range.clone(), &text_to_insert); + self.selected_range = range.start + text_to_insert.len()..range.start + text_to_insert.len(); self.marked_range.take(); @@ -112,7 +113,7 @@ impl EntityInputHandler for super::InputState { self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); } - crate::input::replace_range(&mut self.content, range.clone(), &text_to_insert); + self.replace_range(range.clone(), &text_to_insert); if !text_to_insert.is_empty() { self.marked_range = Some(range.start..range.start + text_to_insert.len()); From 35c38bf470c8f7b2dded62c599f25d6cae413e40 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 17:13:01 -0400 Subject: [PATCH 022/117] reorganize InputState impls --- crates/gpui_elements/src/input/layout.rs | 4 +- crates/gpui_elements/src/input/paint.rs | 36 +- crates/gpui_elements/src/input/state.rs | 1754 ++++++++--------- .../src/input/state_input_handler.rs | 6 +- 4 files changed, 891 insertions(+), 909 deletions(-) diff --git a/crates/gpui_elements/src/input/layout.rs b/crates/gpui_elements/src/input/layout.rs index 8280d23cff..eb0b80378c 100644 --- a/crates/gpui_elements/src/input/layout.rs +++ b/crates/gpui_elements/src/input/layout.rs @@ -1,10 +1,10 @@ #[derive(Clone, Copy, Debug, PartialEq)] -pub enum InputLayout { +pub enum InputLayoutStyle { SingleLine, MultiLine, } -impl InputLayout { +impl InputLayoutStyle { pub(super) fn sanitize_content<'s>(&self, content: &'s str) -> std::borrow::Cow<'s, str> { match self { // Strip newlines for single-line input diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index 07ec6d210f..2a66149563 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -46,12 +46,12 @@ impl Element for Input { window, cx, |element_style, window, cx| { - let layout = self.input.read(cx).get_layout(); + let layout = self.input.read(cx).layout_style(); window.with_text_style(element_style.text_style().cloned(), |window| { resolved_text_style = Some(window.text_style()); let mut layout_style = element_style.clone(); - if matches!(layout, super::InputLayout::MultiLine) { + if matches!(layout, super::InputLayoutStyle::MultiLine) { if let Length::Auto = layout_style.size.width { layout_style.size.width = relative(1.).into(); } @@ -83,9 +83,9 @@ impl Element for Input { .text_style .line_height_in_pixels(window.rem_size()); - let wrap_width = match self.input.read(cx).get_layout() { - super::InputLayout::SingleLine => None, - super::InputLayout::MultiLine => Some(bounds.size.width), + let wrap_width = match self.input.read(cx).layout_style() { + super::InputLayoutStyle::SingleLine => None, + super::InputLayoutStyle::MultiLine => Some(bounds.size.width), }; self.input.update(cx, |input, _cx| { @@ -157,7 +157,7 @@ impl Element for Input { let perform_paint = |_style: &Style, window: &mut Window, cx: &mut App| { let precomputed_first_line = match (snapshot.layout, snapshot.logical_lines.first()) { ( - super::InputLayout::SingleLine, + super::InputLayoutStyle::SingleLine, Some(InputLogicalLine { wrapped_line: Some(wrapped_line), .. @@ -197,7 +197,7 @@ impl Element for Input { } struct InputStateSnapshot { - layout: super::InputLayout, + layout: super::InputLayoutStyle, content: SharedString, selected_range: Range, marked_range: Option>, @@ -211,12 +211,12 @@ impl InputStateSnapshot { let input_state = entity.read(cx); let selected_range = input_state.selected_range().clone(); let marked_range = input_state.marked_range().cloned(); - let cursor_offset = input_state.cursor_offset(); + let cursor_offset = input_state.cursor_position(); let logical_lines = input_state.logical_lines.clone(); let scroll_offset = input_state.scroll_offset; let line_height = input_state.line_height(); Self { - layout: input_state.get_layout(), + layout: input_state.layout_style(), content: input_state.content().clone(), selected_range, marked_range, @@ -378,7 +378,7 @@ impl<'app> PaintContext<'app> { fn paint_selection(&self, window: &mut Window) { match self.snapshot.layout { - super::InputLayout::MultiLine => { + super::InputLayoutStyle::MultiLine => { for line in &self.snapshot.logical_lines { let line_y = line.y_offset - self.snapshot.scroll_offset; @@ -412,7 +412,7 @@ impl<'app> PaintContext<'app> { } } } - super::InputLayout::SingleLine => { + super::InputLayoutStyle::SingleLine => { let precomputed = self .precomputed_first_line .as_ref() @@ -468,7 +468,7 @@ impl<'app> PaintContext<'app> { let line_height = self.text_style.line_height_in_pixels(window.rem_size()); let mut paint_origin = self.bounds.origin; - if matches!(self.snapshot.layout, super::InputLayout::SingleLine) { + if matches!(self.snapshot.layout, super::InputLayoutStyle::SingleLine) { let y_offset = (self.bounds.size.height - line_height).max(px(0.)) / 2.0; paint_origin.y += y_offset; } @@ -478,7 +478,7 @@ impl<'app> PaintContext<'app> { fn paint_text(&self, window: &mut Window, cx: &mut App) { match self.snapshot.layout { - super::InputLayout::MultiLine => { + super::InputLayoutStyle::MultiLine => { for line_layout in &self.snapshot.logical_lines { let line_y = line_layout.y_offset - self.snapshot.scroll_offset; @@ -499,7 +499,7 @@ impl<'app> PaintContext<'app> { } } } - super::InputLayout::SingleLine => { + super::InputLayoutStyle::SingleLine => { let Some(line_layout) = self.snapshot.logical_lines.first() else { return; }; @@ -537,7 +537,7 @@ impl<'app> PaintContext<'app> { let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); let underline_offset = self.snapshot.line_height - underline_thickness; match self.snapshot.layout { - super::InputLayout::MultiLine => { + super::InputLayoutStyle::MultiLine => { for line in &self.snapshot.logical_lines { if !self.is_line_visible(line) { continue; @@ -560,7 +560,7 @@ impl<'app> PaintContext<'app> { ); } } - super::InputLayout::SingleLine => { + super::InputLayoutStyle::SingleLine => { let Some(precomputed) = &self.precomputed_first_line else { return; }; @@ -627,8 +627,8 @@ impl<'app> PaintContext<'app> { fn paint_cursor(&self, window: &mut Window) { let cursor_pos = match self.snapshot.layout { - super::InputLayout::MultiLine => self.find_cursor_position_in_layouts(), - super::InputLayout::SingleLine => { + super::InputLayoutStyle::MultiLine => self.find_cursor_position_in_layouts(), + super::InputLayoutStyle::SingleLine => { let Some(precomputed) = &self.precomputed_first_line else { return; }; diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index 8a9302dd52..39275916e3 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -1,5 +1,5 @@ use super::actions::*; -use crate::input::InputLayout; +use crate::input::InputLayoutStyle; use gpui::{ App, AppContext, ClipboardItem, Context, Entity, EntityId, EntityInputHandler, EventEmitter, FocusHandle, Focusable, Pixels, Point, SharedString, Size, Subscription, TextRun, TextStyle, @@ -38,9 +38,8 @@ pub struct InputState { entity_id: EntityId, focus_handle: FocusHandle, content: SharedString, - placeholder: SharedString, pub(super) selected_range: Range, - pub(super) selection_reversed: bool, + pub(super) selection_reversed: bool, // TODO: NavigationDirection pub(super) marked_range: Option>, pub(super) logical_lines: Vec, // refreshed each update by the element, for conveinent access in mutations and painting @@ -50,7 +49,7 @@ pub struct InputState { click_count: usize, /// Scroll offset - vertical for multiline, horizontal for single-line pub(super) scroll_offset: Pixels, - pub(super) layout: InputLayout, + pub(super) layout_style: InputLayoutStyle, history_grouping_interval: Duration, /// Stack of previous states for undo. undo_stack: Vec, @@ -124,7 +123,6 @@ impl InputState { entity_id: cx.entity_id(), focus_handle: cx.focus_handle(), content: SharedString::default(), - placeholder: SharedString::default(), selected_range: 0..0, selection_reversed: false, marked_range: None, @@ -134,7 +132,7 @@ impl InputState { last_click_position: None, click_count: 0, scroll_offset: px(0.), - layout: InputLayout::SingleLine, + layout_style: InputLayoutStyle::SingleLine, history_grouping_interval: super::DEFAULT_GROUP_INTERVAL, undo_stack: Vec::new(), cached_utf16_len: None, @@ -149,6 +147,7 @@ impl InputState { this } + /// Configure how often the cursor should blink when the input element has focus. pub fn cursor_blink<'app>(mut self, args: CursorBlinkType<'app>) -> Self { self.cursor_blink = match args { CursorBlinkType::Disabled => None, @@ -163,42 +162,6 @@ impl InputState { self } - /// Processes a focus-flag update during window paint, returning whether the cursor should be visible in this frame. - /// Returns false if the cursor is blinking and not currently visible. - pub(super) fn toggle_cursor_on_focus_change( - &mut self, - is_focused: bool, - cx: &mut Context, - ) -> bool { - // Update cursor blink based on focus changes - let was_focused = self.was_focused; - self.was_focused = is_focused; - - match &self.cursor_blink { - None => true, - Some((cursor_blink, _)) => match (is_focused, was_focused) { - (true, false) => { - cursor_blink.update(cx, |cursor, cx| cursor.enable(cx)); - cx.emit(InputStateEvent::Focus); - true - } - (false, true) => { - cursor_blink.update(cx, |cursor, cx| cursor.disable(cx)); - cx.emit(InputStateEvent::Blur); - false - } - _ => cursor_blink.read(cx).visible(), - }, - } - } - - /// Pauses cursor blinking temporarily (e.g., during typing). - pub(super) fn pause_cursor_blink(&self, cx: &mut Context) { - if let Some((cursor_blink, _)) = &self.cursor_blink { - cursor_blink.update(cx, |cb, cx| cb.pause_blinking(cx)); - } - } - /// Returns the current text content. pub fn content(&self) -> &SharedString { &self.content @@ -207,7 +170,7 @@ impl InputState { /// Sets the text content, resetting selection to the beginning. /// This clears the undo/redo history. pub fn set_content(&mut self, content: impl AsRef, cx: &mut Context) { - let content = self.layout.sanitize_content(content.as_ref()); + let content = self.layout_style.sanitize_content(content.as_ref()); self.content = content.to_string().into(); self.selected_range = 0..0; self.selection_reversed = false; @@ -221,33 +184,642 @@ impl InputState { cx.notify(); } - pub fn layout(mut self, layout: InputLayout) -> Self { - self.layout = layout; + /// Sets the input's layout style (single-line or multi-line/area). + pub fn with_layout_style(mut self, layout_style: InputLayoutStyle) -> Self { + self.layout_style = layout_style; self } - pub fn get_layout(&self) -> InputLayout { - self.layout + /// Returns the input's layout style. + pub fn layout_style(&self) -> InputLayoutStyle { + self.layout_style } - pub fn line_height(&self) -> Pixels { - self.layout_data.line_height + /// Returns the utf-8 character range that is currently selected within the current state of the text. + pub fn selected_range(&self) -> &Range { + &self.selected_range } + /// Sets the selection range directly. + pub fn set_selected_range(&mut self, range: Range) { + let range = range.start.min(self.content.len())..range.end.min(self.content.len()); + self.selected_range = range; + self.selection_reversed = false; + } + + /// Returns the current position of the cursor within the utf-8 character range of the current state of the text. + pub fn cursor_position(&self) -> usize { + match self.selection_reversed { + true => self.selected_range.start, + false => self.selected_range.end, + } + } + + /// Returns the marked text range (for IME composition). Marked text range represents a collection of utf-8 characters that are treated as one group. (TBD need better explanation of marked text) + pub fn marked_range(&self) -> Option<&Range> { + self.marked_range.as_ref() + } + + /// Returns true if the scroll position is at the top. + pub fn at_top(&self) -> bool { + self.scroll_offset <= px(0.) + } + + /// Returns true if the scroll position is at the bottom. + pub fn at_bottom(&self) -> bool { + let content_height = self.total_content_height(); + let visible_height = self.layout_data.available_size.height; + + if content_height <= visible_height { + return true; + } + + self.scroll_offset + visible_height >= content_height + } + + /// Returns the scroll progress as a value from 0.0 (top) to 1.0 (bottom). + pub fn scroll_progress(&self) -> f32 { + let content_height = self.total_content_height(); + let visible_height = self.layout_data.available_size.height; + let max_scroll = content_height - visible_height; + + if max_scroll <= px(0.) { + return 0.0; + } + + (self.scroll_offset / max_scroll).clamp(0.0, 1.0) + } + + /// Returns how far the content is scrolled from the top in pixels. + pub fn distance_from_top(&self) -> Pixels { + self.scroll_offset.max(px(0.)) + } + + /// Returns how far the content is from the bottom in pixels. + pub fn distance_from_bottom(&self) -> Pixels { + let content_height = self.total_content_height(); + let visible_height = self.layout_data.available_size.height; + let max_scroll = content_height - visible_height; + + if max_scroll <= px(0.) { + return px(0.); + } + + (max_scroll - self.scroll_offset).max(px(0.)) + } + + /// Configures how long the input will wait between user-input changes to create new logs in the history for undo/redo. pub fn set_history_group_interval(&mut self, interval: Duration) { self.history_grouping_interval = interval; } - /// Returns whether undo is available. - pub fn can_undo(&self) -> bool { + /// Returns whether undo is available based on the recorded states. + pub fn is_undo_available(&self) -> bool { !self.undo_stack.is_empty() } - /// Returns whether redo is available. - pub fn can_redo(&self) -> bool { + /// Returns whether redo is currently available based on the recorded states. + pub fn is_redo_available(&self) -> bool { !self.redo_stack.is_empty() } + /// Inserts text at the current cursor position, replacing any content that is currently selected (i.e. `selection_range` is non-empty). + /// If any of the text is marked, that range will be replaced instead of the selected range. + pub fn insert_text(&mut self, text: &str, cx: &mut Context) { + let range = self + .marked_range + .clone() + .unwrap_or(self.selected_range.clone()); + let range = range.start.min(self.content.len())..range.end.min(self.content.len()); + + let text_to_insert = self.layout_style.sanitize_content(text); + + // Record patch for undo before modifying content + self.push_undo_patch(range.clone(), text_to_insert.len()); + + // Update cached UTF-16 length incrementally if available + if let Some(cached_len) = self.cached_utf16_len { + let removed_utf16_len: usize = self.content[range.clone()] + .chars() + .map(|c| c.len_utf16()) + .sum(); + let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum(); + self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); + } + + self.replace_range(range.clone(), &text_to_insert); + + self.selected_range = + range.start + text_to_insert.len()..range.start + text_to_insert.len(); + self.marked_range.take(); + self.layout_data.dirty = true; + self.pause_cursor_blink(cx); + cx.emit(InputStateEvent::TextChanged); + cx.notify(); + } + + /// Deletes the character before the cursor (convenience method for benchmarks). + pub fn delete_backward(&mut self, cx: &mut Context) { + if self.selected_range.is_empty() { + self.select_to(self.previous_boundary(self.cursor_position()), cx); + } + self.insert_text("", cx); + } + + /// Reverts the last edit. + pub fn undo_action(&mut self, cx: &mut Context) { + if let Some(entry) = self.undo_stack.pop() { + let selected_range = entry.selected_range.clone(); + let selection_reversed = entry.selection_reversed; + + let redo_entry = entry.apply_undo(&mut self.content); + self.redo_stack.push(redo_entry); + + self.selected_range = selected_range; + self.selection_reversed = selection_reversed; + self.layout_data.dirty = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Undo); + cx.notify(); + } + } + + /// Restores the last edit reverted by `undo_action` (or the undo action binding). + pub fn redo_action(&mut self, cx: &mut Context) { + if let Some(entry) = self.redo_stack.pop() { + let undo_entry = entry.apply_redo(&mut self.content); + + let cursor_pos = undo_entry.range.start; + self.selected_range = cursor_pos..cursor_pos; + self.selection_reversed = false; + + self.undo_stack.push(undo_entry); + self.layout_data.dirty = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Redo); + cx.notify(); + } + } +} + +// Action implementations +impl InputState { + pub(super) fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context) { + if let Some(entry) = self.undo_stack.pop() { + // Remember selection to restore + let selected_range = entry.selected_range.clone(); + let selection_reversed = entry.selection_reversed; + + // Apply the undo patch and get the redo patch + let redo_entry = entry.apply_undo(&mut self.content); + self.redo_stack.push(redo_entry); + + // Restore selection state + self.selected_range = selected_range; + self.selection_reversed = selection_reversed; + self.layout_data.dirty = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Undo); + cx.notify(); + } + } + + pub(super) fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context) { + if let Some(entry) = self.redo_stack.pop() { + // Apply the redo patch and get the undo patch + let undo_entry = entry.apply_redo(&mut self.content); + + // The undo entry contains the selection state after the original edit + // We need to restore cursor to end of inserted text + let cursor_pos = undo_entry.range.start; + self.selected_range = cursor_pos..cursor_pos; + self.selection_reversed = false; + + self.undo_stack.push(undo_entry); + self.layout_data.dirty = true; + self.cached_utf16_len = None; + self.scroll_to_cursor(); + cx.emit(InputStateEvent::Redo); + cx.notify(); + } + } + + pub(super) fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { + self.selected_range = 0..self.content.len(); + self.selection_reversed = false; + cx.notify(); + } + + pub(super) fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + let new_pos = self.previous_boundary(self.cursor_position()); + self.move_to(new_pos, cx); + } else { + self.move_to(self.selected_range.start, cx); + } + } + + pub(super) fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + let new_pos = self.next_boundary(self.cursor_position()); + self.move_to(new_pos, cx); + } else { + self.move_to(self.selected_range.end, cx); + } + } + + pub(super) fn up(&mut self, _: &Up, _window: &mut Window, cx: &mut Context) { + self.pause_cursor_blink(cx); + match self.layout_style { + InputLayoutStyle::SingleLine => { + // In single-line mode, up moves to start + self.selected_range = 0..0; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + InputLayoutStyle::MultiLine => { + if let Some(new_offset) = self.move_vertically(self.cursor_position(), -1) { + self.selected_range = new_offset..new_offset; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + } + } + } + + pub(super) fn down(&mut self, _: &Down, _window: &mut Window, cx: &mut Context) { + self.pause_cursor_blink(cx); + match self.layout_style { + InputLayoutStyle::SingleLine => { + // In single-line mode, down moves to end + let end = self.content.len(); + self.selected_range = end..end; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + InputLayoutStyle::MultiLine => { + if let Some(new_offset) = self.move_vertically(self.cursor_position(), 1) { + self.selected_range = new_offset..new_offset; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + } + } + } + + pub(super) fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { + self.select_to(self.previous_boundary(self.cursor_position()), cx); + } + + pub(super) fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { + self.select_to(self.next_boundary(self.cursor_position()), cx); + } + + pub(super) fn select_up(&mut self, _: &SelectUp, _window: &mut Window, cx: &mut Context) { + self.pause_cursor_blink(cx); + match self.layout_style { + InputLayoutStyle::SingleLine => { + // In single-line mode, select_up selects to start + self.select_to(0, cx); + } + InputLayoutStyle::MultiLine => { + if let Some(new_offset) = self.move_vertically(self.cursor_position(), -1) { + if self.selection_reversed { + self.selected_range.start = new_offset; + } else { + self.selected_range.end = new_offset; + } + if self.selected_range.end < self.selected_range.start { + self.selection_reversed = !self.selection_reversed; + self.selected_range = self.selected_range.end..self.selected_range.start; + } + self.scroll_to_cursor(); + cx.notify(); + } + } + } + } + + pub(super) fn select_down( + &mut self, + _: &SelectDown, + _window: &mut Window, + cx: &mut Context, + ) { + self.pause_cursor_blink(cx); + match self.layout_style { + InputLayoutStyle::SingleLine => { + // In single-line mode, select_down selects to end + self.select_to(self.content.len(), cx); + } + InputLayoutStyle::MultiLine => { + if let Some(new_offset) = self.move_vertically(self.cursor_position(), 1) { + if self.selection_reversed { + self.selected_range.start = new_offset; + } else { + self.selected_range.end = new_offset; + } + if self.selected_range.end < self.selected_range.start { + self.selection_reversed = !self.selection_reversed; + self.selected_range = self.selected_range.end..self.selected_range.start; + } + self.scroll_to_cursor(); + cx.notify(); + } + } + } + } + + pub(super) fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context) { + let line_start = self.find_line_start(self.cursor_position()); + self.move_to(line_start, cx); + } + + pub(super) fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context) { + let line_end = self.find_line_end(self.cursor_position()); + self.move_to(line_end, cx); + } + + pub(super) fn move_to_beginning( + &mut self, + _: &MoveToBeginning, + _: &mut Window, + cx: &mut Context, + ) { + self.move_to(0, cx); + } + + pub(super) fn move_to_end(&mut self, _: &MoveToEnd, _: &mut Window, cx: &mut Context) { + self.move_to(self.content.len(), cx); + } + + pub(super) fn select_to_beginning( + &mut self, + _: &SelectToBeginning, + _: &mut Window, + cx: &mut Context, + ) { + self.select_to(0, cx); + } + + pub(super) fn select_to_end( + &mut self, + _: &SelectToEnd, + _: &mut Window, + cx: &mut Context, + ) { + self.select_to(self.content.len(), cx); + } + + pub(super) fn word_left(&mut self, _: &WordLeft, _: &mut Window, cx: &mut Context) { + let new_pos = self.previous_word_boundary(self.cursor_position()); + self.move_to(new_pos, cx); + } + + pub(super) fn word_right(&mut self, _: &WordRight, _: &mut Window, cx: &mut Context) { + let new_pos = self.next_word_boundary(self.cursor_position()); + self.move_to(new_pos, cx); + } + + pub(super) fn select_word_left( + &mut self, + _: &SelectWordLeft, + _: &mut Window, + cx: &mut Context, + ) { + let new_pos = self.previous_word_boundary(self.cursor_position()); + self.select_to(new_pos, cx); + } + + pub(super) fn select_word_right( + &mut self, + _: &SelectWordRight, + _: &mut Window, + cx: &mut Context, + ) { + let new_pos = self.next_word_boundary(self.cursor_position()); + self.select_to(new_pos, cx); + } + + pub(super) fn enter(&mut self, _: &Enter, window: &mut Window, cx: &mut Context) { + if matches!(&self.layout_style, InputLayoutStyle::MultiLine) { + self.replace_text_in_range(None, "\n", window, cx); + } + } + + pub(super) fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { + self.replace_text_in_range(None, "\t", window, cx); + } + + pub(super) fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + self.select_to(self.previous_boundary(self.cursor_position()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(super) fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + self.select_to(self.next_boundary(self.cursor_position()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(super) fn delete_word_left( + &mut self, + _: &DeleteWordLeft, + window: &mut Window, + cx: &mut Context, + ) { + if self.selected_range.is_empty() { + self.select_to(self.previous_word_boundary(self.cursor_position()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(super) fn delete_word_right( + &mut self, + _: &DeleteWordRight, + window: &mut Window, + cx: &mut Context, + ) { + if self.selected_range.is_empty() { + self.select_to(self.next_word_boundary(self.cursor_position()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(super) fn delete_to_beginning_of_line( + &mut self, + _: &DeleteToBeginningOfLine, + window: &mut Window, + cx: &mut Context, + ) { + if self.selected_range.is_empty() { + self.select_to(self.find_line_start(self.cursor_position()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(super) fn delete_to_end_of_line( + &mut self, + _: &DeleteToEndOfLine, + window: &mut Window, + cx: &mut Context, + ) { + if self.selected_range.is_empty() { + self.select_to(self.find_line_end(self.cursor_position()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + pub(super) fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { + return; + }; + let text = self.layout_style.sanitize_content(&text); + self.replace_text_in_range(None, &text, window, cx); + } + + pub(super) fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { + if !self.selected_range.is_empty() { + cx.write_to_clipboard(ClipboardItem::new_string( + self.content[self.selected_range.clone()].to_string(), + )); + } + } + + pub(super) fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { + if !self.selected_range.is_empty() { + // Cut selected text + cx.write_to_clipboard(ClipboardItem::new_string( + self.content[self.selected_range.clone()].to_string(), + )); + self.replace_text_in_range(None, "", window, cx); + } else { + // No selection: cut the entire current line (including newline) + let cursor = self.cursor_position(); + let line_start = self.find_line_start(cursor); + let line_end = self.find_line_end(cursor); + + // Include the newline character if there is one after the line + let cut_end = if line_end < self.content.len() { + line_end + 1 // Include the newline + } else if line_start > 0 { + // Last line with no trailing newline - include preceding newline instead + line_end + } else { + line_end + }; + + // For last line, also remove the preceding newline if it exists + let cut_start = if line_end >= self.content.len() && line_start > 0 { + line_start - 1 // Include preceding newline for last line + } else { + line_start + }; + + let line_text = self.content[cut_start..cut_end].to_string(); + cx.write_to_clipboard(ClipboardItem::new_string(line_text)); + + self.selected_range = cut_start..cut_end; + self.replace_text_in_range(None, "", window, cx); + } + } + + pub(super) fn on_mouse_down( + &mut self, + position: Point, + click_count: usize, + shift: bool, + window: &mut Window, + cx: &mut Context, + ) { + window.focus(&self.focus_handle, cx); + self.is_selecting = true; + + let is_same_position = self + .last_click_position + .map(|last| { + let threshold = px(4.); + (position.x - last.x).abs() < threshold && (position.y - last.y).abs() < threshold + }) + .unwrap_or(false); + + if is_same_position && click_count > 1 { + self.click_count = click_count; + } else { + self.click_count = 1; + } + self.last_click_position = Some(position); + + let character_pos = self.index_for_pixel_point(position); + + match self.click_count { + 2 => { + let (word_start, word_end) = self.word_range_at(character_pos); + self.selected_range = word_start..word_end; + self.selection_reversed = false; + cx.notify(); + } + 3 => { + let line_start = self.find_line_start(character_pos); + let line_end = self.find_line_end(character_pos); + let line_end_with_newline = if line_end < self.content.len() { + line_end + 1 + } else { + line_end + }; + self.selected_range = line_start..line_end_with_newline; + self.selection_reversed = false; + cx.notify(); + } + _ => { + if shift { + self.select_to(character_pos, cx); + } else { + self.move_to(character_pos, cx); + } + } + } + } + + pub(super) fn on_mouse_up(&mut self, _cx: &mut Context) { + self.is_selecting = false; + } + + pub(super) fn on_mouse_move(&mut self, position: Point, cx: &mut Context) { + if self.is_selecting && self.click_count == 1 { + self.select_to(self.index_for_pixel_point(position), cx); + } + } +} + +// Internal implementations +impl InputState { + pub(super) fn line_height(&self) -> Pixels { + self.layout_data.line_height + } + + /// Replaces the provided utf-8 character range with the provided text + pub(super) fn replace_range(&mut self, range: Range, text: &str) { + crate::input::replace_range(&mut self.content, range, &text); + } + + /// Pauses cursor blinking temporarily (e.g., during typing). + pub(super) fn pause_cursor_blink(&self, cx: &mut Context) { + if let Some((cursor_blink, _)) = &self.cursor_blink { + cursor_blink.update(cx, |cb, cx| cb.pause_blinking(cx)); + } + } + /// Records a patch for undo. Called before making changes to content. /// Returns true if a new entry was created, false if grouped with previous. pub(super) fn push_undo_patch(&mut self, range: Range, new_text_len: usize) { @@ -288,690 +860,24 @@ impl InputState { self.redo_stack.clear(); } - /// Returns the placeholder text shown when content is empty. - pub fn placeholder(&self) -> &SharedString { - &self.placeholder - } - - /// Sets the placeholder text. - pub fn set_placeholder( - &mut self, - placeholder: impl Into, - cx: &mut Context, - ) { - self.placeholder = placeholder.into(); - cx.notify(); - } - - /// Returns the current selection range. - pub fn selected_range(&self) -> &Range { - &self.selected_range - } - - /// Returns true if the selection is reversed (cursor at start). - pub fn selection_reversed(&self) -> bool { - self.selection_reversed - } - - /// Returns the current cursor offset. - pub fn cursor_offset(&self) -> usize { - if self.selection_reversed { - self.selected_range.start - } else { - self.selected_range.end - } - } - - /// Returns the marked text range (for IME composition). - pub fn marked_range(&self) -> Option<&Range> { - self.marked_range.as_ref() - } - - /// Sets the selection range directly. - pub fn set_selected_range(&mut self, range: Range) { - let range = range.start.min(self.content.len())..range.end.min(self.content.len()); - self.selected_range = range; - self.selection_reversed = false; - } - - pub(super) fn replace_range(&mut self, range: Range, text: &str) { - crate::input::replace_range(&mut self.content, range, &text); - } - - /// Inserts text at the current cursor position, replacing any selection. - pub fn insert_text(&mut self, text: &str, cx: &mut Context) { - let range = self - .marked_range - .clone() - .unwrap_or(self.selected_range.clone()); - let range = range.start.min(self.content.len())..range.end.min(self.content.len()); - - let text_to_insert = self.layout.sanitize_content(text); - - // Record patch for undo before modifying content - self.push_undo_patch(range.clone(), text_to_insert.len()); - - // Update cached UTF-16 length incrementally if available - if let Some(cached_len) = self.cached_utf16_len { - let removed_utf16_len: usize = self.content[range.clone()] - .chars() - .map(|c| c.len_utf16()) - .sum(); - let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum(); - self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); - } - - self.replace_range(range.clone(), &text_to_insert); - - self.selected_range = - range.start + text_to_insert.len()..range.start + text_to_insert.len(); - self.marked_range.take(); - self.layout_data.dirty = true; - self.pause_cursor_blink(cx); - cx.emit(InputStateEvent::TextChanged); - cx.notify(); - } - - /// Deletes the character before the cursor (convenience method for benchmarks). - pub fn delete_backward(&mut self, cx: &mut Context) { - if self.selected_range.is_empty() { - self.select_to(self.previous_boundary(self.cursor_offset()), cx); - } - self.insert_text("", cx); - } - - /// Undoes the last edit (convenience method without Window). - pub fn undo_action(&mut self, cx: &mut Context) { - if let Some(entry) = self.undo_stack.pop() { - let selected_range = entry.selected_range.clone(); - let selection_reversed = entry.selection_reversed; - - let redo_entry = entry.apply_undo(&mut self.content); - self.redo_stack.push(redo_entry); - - self.selected_range = selected_range; - self.selection_reversed = selection_reversed; - self.layout_data.dirty = true; - self.cached_utf16_len = None; - self.scroll_to_cursor(); - cx.emit(InputStateEvent::Undo); - cx.notify(); - } - } - - /// Redoes the last undone edit (convenience method without Window). - pub fn redo_action(&mut self, cx: &mut Context) { - if let Some(entry) = self.redo_stack.pop() { - let undo_entry = entry.apply_redo(&mut self.content); - - let cursor_pos = undo_entry.range.start; - self.selected_range = cursor_pos..cursor_pos; - self.selection_reversed = false; - - self.undo_stack.push(undo_entry); - self.layout_data.dirty = true; - self.cached_utf16_len = None; - self.scroll_to_cursor(); - cx.emit(InputStateEvent::Redo); - cx.notify(); - } - } -} - -// Action implementations -impl InputState { - /// Undoes the last edit by applying the reverse patch. - pub(crate) fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context) { - if let Some(entry) = self.undo_stack.pop() { - // Remember selection to restore - let selected_range = entry.selected_range.clone(); - let selection_reversed = entry.selection_reversed; - - // Apply the undo patch and get the redo patch - let redo_entry = entry.apply_undo(&mut self.content); - self.redo_stack.push(redo_entry); - - // Restore selection state - self.selected_range = selected_range; - self.selection_reversed = selection_reversed; - self.layout_data.dirty = true; - self.cached_utf16_len = None; - self.scroll_to_cursor(); - cx.emit(InputStateEvent::Undo); - cx.notify(); - } - } - - /// Redoes the last undone edit by applying the forward patch. - pub(crate) fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context) { - if let Some(entry) = self.redo_stack.pop() { - // Apply the redo patch and get the undo patch - let undo_entry = entry.apply_redo(&mut self.content); - - // The undo entry contains the selection state after the original edit - // We need to restore cursor to end of inserted text - let cursor_pos = undo_entry.range.start; - self.selected_range = cursor_pos..cursor_pos; - self.selection_reversed = false; - - self.undo_stack.push(undo_entry); - self.layout_data.dirty = true; - self.cached_utf16_len = None; - self.scroll_to_cursor(); - cx.emit(InputStateEvent::Redo); - cx.notify(); - } - } - - /// Selects all text. - pub fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { - self.selected_range = 0..self.content.len(); - self.selection_reversed = false; - cx.notify(); - } - - pub(crate) fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context) { - if self.selected_range.is_empty() { - let new_pos = self.previous_boundary(self.cursor_offset()); - self.move_to(new_pos, cx); - } else { - self.move_to(self.selected_range.start, cx); - } - } - - pub(crate) fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context) { - if self.selected_range.is_empty() { - let new_pos = self.next_boundary(self.cursor_offset()); - self.move_to(new_pos, cx); - } else { - self.move_to(self.selected_range.end, cx); - } - } - - pub(crate) fn up(&mut self, _: &Up, _window: &mut Window, cx: &mut Context) { - self.pause_cursor_blink(cx); - match self.layout { - InputLayout::SingleLine => { - // In single-line mode, up moves to start - self.selected_range = 0..0; - self.selection_reversed = false; - self.scroll_to_cursor(); - cx.notify(); - } - InputLayout::MultiLine => { - if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) { - self.selected_range = new_offset..new_offset; - self.selection_reversed = false; - self.scroll_to_cursor(); - cx.notify(); - } - } - } - } - - pub(crate) fn down(&mut self, _: &Down, _window: &mut Window, cx: &mut Context) { - self.pause_cursor_blink(cx); - match self.layout { - InputLayout::SingleLine => { - // In single-line mode, down moves to end - let end = self.content.len(); - self.selected_range = end..end; - self.selection_reversed = false; - self.scroll_to_cursor(); - cx.notify(); - } - InputLayout::MultiLine => { - if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) { - self.selected_range = new_offset..new_offset; - self.selection_reversed = false; - self.scroll_to_cursor(); - cx.notify(); - } - } - } - } - - pub(crate) fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { - self.select_to(self.previous_boundary(self.cursor_offset()), cx); - } - - pub(crate) fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { - self.select_to(self.next_boundary(self.cursor_offset()), cx); - } - - pub(crate) fn select_up(&mut self, _: &SelectUp, _window: &mut Window, cx: &mut Context) { - self.pause_cursor_blink(cx); - match self.layout { - InputLayout::SingleLine => { - // In single-line mode, select_up selects to start - self.select_to(0, cx); - } - InputLayout::MultiLine => { - if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) { - if self.selection_reversed { - self.selected_range.start = new_offset; - } else { - self.selected_range.end = new_offset; - } - if self.selected_range.end < self.selected_range.start { - self.selection_reversed = !self.selection_reversed; - self.selected_range = self.selected_range.end..self.selected_range.start; - } - self.scroll_to_cursor(); - cx.notify(); - } - } - } - } - - pub(crate) fn select_down( - &mut self, - _: &SelectDown, - _window: &mut Window, - cx: &mut Context, - ) { - self.pause_cursor_blink(cx); - match self.layout { - InputLayout::SingleLine => { - // In single-line mode, select_down selects to end - self.select_to(self.content.len(), cx); - } - InputLayout::MultiLine => { - if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) { - if self.selection_reversed { - self.selected_range.start = new_offset; - } else { - self.selected_range.end = new_offset; - } - if self.selected_range.end < self.selected_range.start { - self.selection_reversed = !self.selection_reversed; - self.selected_range = self.selected_range.end..self.selected_range.start; - } - self.scroll_to_cursor(); - cx.notify(); - } - } - } - } - - pub(crate) fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context) { - let line_start = self.find_line_start(self.cursor_offset()); - self.move_to(line_start, cx); - } - - pub(crate) fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context) { - let line_end = self.find_line_end(self.cursor_offset()); - self.move_to(line_end, cx); - } - - pub(crate) fn move_to_beginning( - &mut self, - _: &MoveToBeginning, - _: &mut Window, - cx: &mut Context, - ) { - self.move_to(0, cx); - } - - pub(crate) fn move_to_end(&mut self, _: &MoveToEnd, _: &mut Window, cx: &mut Context) { - self.move_to(self.content.len(), cx); - } - - pub(crate) fn select_to_beginning( - &mut self, - _: &SelectToBeginning, - _: &mut Window, - cx: &mut Context, - ) { - self.select_to(0, cx); - } - - pub(crate) fn select_to_end( - &mut self, - _: &SelectToEnd, - _: &mut Window, - cx: &mut Context, - ) { - self.select_to(self.content.len(), cx); - } - - pub(crate) fn word_left(&mut self, _: &WordLeft, _: &mut Window, cx: &mut Context) { - let new_pos = self.previous_word_boundary(self.cursor_offset()); - self.move_to(new_pos, cx); - } - - pub(crate) fn word_right(&mut self, _: &WordRight, _: &mut Window, cx: &mut Context) { - let new_pos = self.next_word_boundary(self.cursor_offset()); - self.move_to(new_pos, cx); - } - - pub(crate) fn select_word_left( - &mut self, - _: &SelectWordLeft, - _: &mut Window, - cx: &mut Context, - ) { - let new_pos = self.previous_word_boundary(self.cursor_offset()); - self.select_to(new_pos, cx); - } - - pub(crate) fn select_word_right( - &mut self, - _: &SelectWordRight, - _: &mut Window, - cx: &mut Context, - ) { - let new_pos = self.next_word_boundary(self.cursor_offset()); - self.select_to(new_pos, cx); - } - - pub(crate) fn enter(&mut self, _: &Enter, window: &mut Window, cx: &mut Context) { - if matches!(&self.layout, InputLayout::MultiLine) { - self.replace_text_in_range(None, "\n", window, cx); - } - } - - pub(crate) fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { - self.replace_text_in_range(None, "\t", window, cx); - } - - pub(crate) fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { - if self.selected_range.is_empty() { - self.select_to(self.previous_boundary(self.cursor_offset()), cx); - } - self.replace_text_in_range(None, "", window, cx); - } - - pub(crate) fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { - if self.selected_range.is_empty() { - self.select_to(self.next_boundary(self.cursor_offset()), cx); - } - self.replace_text_in_range(None, "", window, cx); - } - - pub(crate) fn delete_word_left( - &mut self, - _: &DeleteWordLeft, - window: &mut Window, - cx: &mut Context, - ) { - if self.selected_range.is_empty() { - self.select_to(self.previous_word_boundary(self.cursor_offset()), cx); - } - self.replace_text_in_range(None, "", window, cx); - } - - pub(crate) fn delete_word_right( - &mut self, - _: &DeleteWordRight, - window: &mut Window, - cx: &mut Context, - ) { - if self.selected_range.is_empty() { - self.select_to(self.next_word_boundary(self.cursor_offset()), cx); - } - self.replace_text_in_range(None, "", window, cx); - } - - pub(crate) fn delete_to_beginning_of_line( - &mut self, - _: &DeleteToBeginningOfLine, - window: &mut Window, - cx: &mut Context, - ) { - if self.selected_range.is_empty() { - self.select_to(self.find_line_start(self.cursor_offset()), cx); - } - self.replace_text_in_range(None, "", window, cx); - } - - pub(crate) fn delete_to_end_of_line( - &mut self, - _: &DeleteToEndOfLine, - window: &mut Window, - cx: &mut Context, - ) { - if self.selected_range.is_empty() { - self.select_to(self.find_line_end(self.cursor_offset()), cx); - } - self.replace_text_in_range(None, "", window, cx); - } - - pub(crate) fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { - let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { - return; - }; - let text = self.layout.sanitize_content(&text); - self.replace_text_in_range(None, &text, window, cx); - } - - pub(crate) fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { - if !self.selected_range.is_empty() { - cx.write_to_clipboard(ClipboardItem::new_string( - self.content[self.selected_range.clone()].to_string(), - )); - } - } - - pub(crate) fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { - if !self.selected_range.is_empty() { - // Cut selected text - cx.write_to_clipboard(ClipboardItem::new_string( - self.content[self.selected_range.clone()].to_string(), - )); - self.replace_text_in_range(None, "", window, cx); - } else { - // No selection: cut the entire current line (including newline) - let cursor = self.cursor_offset(); - let line_start = self.find_line_start(cursor); - let line_end = self.find_line_end(cursor); - - // Include the newline character if there is one after the line - let cut_end = if line_end < self.content.len() { - line_end + 1 // Include the newline - } else if line_start > 0 { - // Last line with no trailing newline - include preceding newline instead - line_end - } else { - line_end - }; - - // For last line, also remove the preceding newline if it exists - let cut_start = if line_end >= self.content.len() && line_start > 0 { - line_start - 1 // Include preceding newline for last line - } else { - line_start - }; - - let line_text = self.content[cut_start..cut_end].to_string(); - cx.write_to_clipboard(ClipboardItem::new_string(line_text)); - - self.selected_range = cut_start..cut_end; - self.replace_text_in_range(None, "", window, cx); - } - } - - pub(crate) fn on_mouse_down( - &mut self, - position: Point, - click_count: usize, - shift: bool, - window: &mut Window, - cx: &mut Context, - ) { - window.focus(&self.focus_handle, cx); - self.is_selecting = true; - - let is_same_position = self - .last_click_position - .map(|last| { - let threshold = px(4.); - (position.x - last.x).abs() < threshold && (position.y - last.y).abs() < threshold - }) - .unwrap_or(false); - - if is_same_position && click_count > 1 { - self.click_count = click_count; - } else { - self.click_count = 1; - } - self.last_click_position = Some(position); - - let clicked_offset = self.index_for_position(position); - - match self.click_count { - 2 => { - let (word_start, word_end) = self.word_range_at(clicked_offset); - self.selected_range = word_start..word_end; - self.selection_reversed = false; - cx.notify(); - } - 3 => { - let line_start = self.find_line_start(clicked_offset); - let line_end = self.find_line_end(clicked_offset); - let line_end_with_newline = if line_end < self.content.len() { - line_end + 1 - } else { - line_end - }; - self.selected_range = line_start..line_end_with_newline; - self.selection_reversed = false; - cx.notify(); - } - _ => { - if shift { - self.select_to(clicked_offset, cx); - } else { - self.move_to(clicked_offset, cx); - } - } - } - } - - pub(crate) fn on_mouse_up(&mut self, _cx: &mut Context) { - self.is_selecting = false; - } - - pub(crate) fn on_mouse_move(&mut self, position: Point, cx: &mut Context) { - if self.is_selecting && self.click_count == 1 { - self.select_to(self.index_for_position(position), cx); - } - } - - fn move_to(&mut self, offset: usize, cx: &mut Context) { - self.pause_cursor_blink(cx); - let offset = offset.min(self.content.len()); - self.selected_range = offset..offset; - self.selection_reversed = false; - self.scroll_to_cursor(); - cx.notify(); - } - - fn select_to(&mut self, offset: usize, cx: &mut Context) { - self.pause_cursor_blink(cx); - let offset = offset.min(self.content.len()); - if self.selection_reversed { - self.selected_range.start = offset; - } else { - self.selected_range.end = offset; - } - if self.selected_range.end < self.selected_range.start { - self.selection_reversed = !self.selection_reversed; - self.selected_range = self.selected_range.end..self.selected_range.start; - } - self.scroll_to_cursor(); - cx.notify(); - } - - fn move_vertically(&self, offset: usize, direction: i32) -> Option { - let (visual_line_idx, x_pixels) = self.find_visual_line_and_x_offset(offset); - let target_visual_line_idx = (visual_line_idx as i32 + direction).max(0) as usize; - - let mut current_visual_line = 0; - for layout in self.logical_lines.iter() { - let visual_lines_in_layout = layout.visual_line_count; - - if target_visual_line_idx < current_visual_line + visual_lines_in_layout { - let visual_line_within_layout = target_visual_line_idx - current_visual_line; - - if layout.text_range.is_empty() { - return Some(layout.text_range.start); - } - - if let Some(wrapped) = &layout.wrapped_line { - let y_within_wrapped = self.line_height() * visual_line_within_layout as f32; - let target_point = point(px(x_pixels), y_within_wrapped); - - let closest_result = - wrapped.closest_index_for_position(target_point, self.line_height()); - - let closest_idx = closest_result.unwrap_or_else(|closest| closest); - let clamped = closest_idx.min(wrapped.text.len()); - let result = layout.text_range.start + clamped; - - return Some(result); - } - - return Some(layout.text_range.start); - } - - current_visual_line += visual_lines_in_layout; - } - - if direction > 0 { - Some(self.content.len()) - } else { - None - } - } -} - -impl InputState { - pub(crate) fn find_line_start(&self, offset: usize) -> usize { - self.content[..offset.min(self.content.len())] + /// Returns the utf-8 character position of first character after the first new-line preceeding the character at the provided utf-8 character position. + pub(super) fn find_line_start(&self, position: usize) -> usize { + self.content[..position.min(self.content.len())] .rfind('\n') .map(|pos| pos + 1) .unwrap_or(0) } - pub(crate) fn find_line_end(&self, offset: usize) -> usize { - self.content[offset.min(self.content.len())..] + /// Returns the utf-8 character position of the character immediately before the first new-line character after the character at the provided utf-8 character position. + pub(super) fn find_line_end(&self, position: usize) -> usize { + self.content[position.min(self.content.len())..] .find('\n') - .map(|pos| offset + pos) + .map(|pos| position + pos) .unwrap_or(self.content.len()) } - fn find_visual_line_and_x_offset(&self, offset: usize) -> (usize, f32) { - if self.logical_lines.is_empty() { - return (0, 0.0); - } - - let mut visual_line_idx = 0; - - for line in &self.logical_lines { - if line.text_range.is_empty() { - if offset == line.text_range.start { - return (visual_line_idx, 0.0); - } - } else if offset >= line.text_range.start && offset <= line.text_range.end { - if let Some(wrapped) = &line.wrapped_line { - let local_offset = (offset - line.text_range.start).min(wrapped.text.len()); - if let Some(position) = - wrapped.position_for_index(local_offset, self.line_height()) - { - let visual_line_within = (position.y / self.line_height()).floor() as usize; - return (visual_line_idx + visual_line_within, position.x.into()); - } - } - return (visual_line_idx, 0.0); - } - visual_line_idx += line.visual_line_count; - } - - (visual_line_idx.saturating_sub(1), 0.0) - } - - pub(crate) fn index_for_position(&self, position: Point) -> usize { + /// Returns the utf-8 character position of the start of the line that contains the provided pixel-point. + pub(super) fn index_for_pixel_point(&self, point: Point) -> usize { if self.content.is_empty() { return 0; } @@ -979,122 +885,120 @@ impl InputState { for line in self.logical_lines.iter() { let line_height_total = self.line_height() * line.visual_line_count as f32; - if position.y >= line.y_offset && position.y < line.y_offset + line_height_total { + if point.y >= line.y_offset && point.y < line.y_offset + line_height_total { if line.text_range.is_empty() { return line.text_range.start; } + let Some(wrapped) = &line.wrapped_line else { + return line.text_range.start; + }; - if let Some(wrapped) = &line.wrapped_line { - let relative_y = position.y - line.y_offset; - let relative_point = point(position.x, relative_y); + let relative_y = point.y - line.y_offset; + let relative_point = gpui::point(point.x, relative_y); - let closest_result = - wrapped.closest_index_for_position(relative_point, self.line_height()); + let closest_result = + wrapped.closest_index_for_position(relative_point, self.line_height()); - let local_idx = closest_result.unwrap_or_else(|closest| closest); - let clamped = local_idx.min(wrapped.text.len()); - return line.text_range.start + clamped; - } - return line.text_range.start; + let local_idx = closest_result.unwrap_or_else(|closest| closest); + let clamped = local_idx.min(wrapped.text.len()); + return line.text_range.start + clamped; } } self.content.len() } - pub(crate) fn scroll_to_cursor(&mut self) { + pub(super) fn scroll_to_cursor(&mut self) { if self.logical_lines.is_empty() { return; } - let cursor_offset = self.cursor_offset(); - match self.layout { - InputLayout::SingleLine => self.scroll_to_cursor_horizontal(cursor_offset), - InputLayout::MultiLine => self.scroll_to_cursor_vertical(cursor_offset), - } - } + let cursor_offset = self.cursor_position(); + match self.layout_style { + InputLayoutStyle::SingleLine => { + if self.layout_data.available_size.width <= px(0.) { + return; + } - fn scroll_to_cursor_vertical(&mut self, cursor_offset: usize) { - if self.layout_data.available_size.height <= px(0.) { - return; - } - - let line_height = self.line_height(); - - for line in &self.logical_lines { - let is_cursor_in_line = if line.text_range.is_empty() { - cursor_offset == line.text_range.start - } else { - line.text_range.contains(&cursor_offset) - || (cursor_offset == line.text_range.end && cursor_offset == self.content.len()) - }; - - if is_cursor_in_line { - let cursor_visual_y = if let Some(wrapped) = &line.wrapped_line { - let local_offset = cursor_offset.saturating_sub(line.text_range.start); - if let Some(position) = - wrapped.position_for_index(local_offset, self.line_height()) - { - line.y_offset + position.y - } else { - line.y_offset - } - } else { - line.y_offset + // For single-line input, get cursor x position from the first (only) line + let Some(line) = self.logical_lines.first() else { + return; }; - let visible_top = self.scroll_offset; - let visible_bottom = self.scroll_offset + self.layout_data.available_size.height; + let cursor_x = if let Some(wrapped) = &line.wrapped_line { + let local_offset = cursor_offset.saturating_sub(line.text_range.start); + wrapped + .position_for_index(local_offset, self.line_height()) + .map(|p| p.x) + .unwrap_or(px(0.)) + } else { + px(0.) + }; - if cursor_visual_y < visible_top { - self.scroll_offset = cursor_visual_y; - } else if cursor_visual_y + line_height > visible_bottom { - self.scroll_offset = - (cursor_visual_y + line_height) - self.layout_data.available_size.height; + let visible_left = self.scroll_offset; + let visible_right = self.scroll_offset + self.layout_data.available_size.width; + + // Add some padding so cursor isn't right at the edge + let padding = px(2.0); + + if cursor_x < visible_left + padding { + self.scroll_offset = (cursor_x - padding).max(px(0.)); + } else if cursor_x > visible_right - padding { + self.scroll_offset = cursor_x - self.layout_data.available_size.width + padding; } self.scroll_offset = self.scroll_offset.max(px(0.)); - break; + } + InputLayoutStyle::MultiLine => { + if self.layout_data.available_size.height <= px(0.) { + return; + } + + let line_height = self.line_height(); + + for line in &self.logical_lines { + let is_cursor_in_line = if line.text_range.is_empty() { + cursor_offset == line.text_range.start + } else { + line.text_range.contains(&cursor_offset) + || (cursor_offset == line.text_range.end + && cursor_offset == self.content.len()) + }; + + if is_cursor_in_line { + let cursor_visual_y = if let Some(wrapped) = &line.wrapped_line { + let local_offset = cursor_offset.saturating_sub(line.text_range.start); + if let Some(position) = + wrapped.position_for_index(local_offset, self.line_height()) + { + line.y_offset + position.y + } else { + line.y_offset + } + } else { + line.y_offset + }; + + let visible_top = self.scroll_offset; + let visible_bottom = + self.scroll_offset + self.layout_data.available_size.height; + + if cursor_visual_y < visible_top { + self.scroll_offset = cursor_visual_y; + } else if cursor_visual_y + line_height > visible_bottom { + self.scroll_offset = (cursor_visual_y + line_height) + - self.layout_data.available_size.height; + } + + self.scroll_offset = self.scroll_offset.max(px(0.)); + break; + } + } } } } - fn scroll_to_cursor_horizontal(&mut self, cursor_offset: usize) { - if self.layout_data.available_size.width <= px(0.) { - return; - } - - // For single-line input, get cursor x position from the first (only) line - let Some(line) = self.logical_lines.first() else { - return; - }; - - let cursor_x = if let Some(wrapped) = &line.wrapped_line { - let local_offset = cursor_offset.saturating_sub(line.text_range.start); - wrapped - .position_for_index(local_offset, self.line_height()) - .map(|p| p.x) - .unwrap_or(px(0.)) - } else { - px(0.) - }; - - let visible_left = self.scroll_offset; - let visible_right = self.scroll_offset + self.layout_data.available_size.width; - - // Add some padding so cursor isn't right at the edge - let padding = px(2.0); - - if cursor_x < visible_left + padding { - self.scroll_offset = (cursor_x - padding).max(px(0.)); - } else if cursor_x > visible_right - padding { - self.scroll_offset = cursor_x - self.layout_data.available_size.width + padding; - } - - self.scroll_offset = self.scroll_offset.max(px(0.)); - } - - /// Called internally during prepaint to layout the content into logical lines based on viewport bounds wrapping. + /// Called internally during window prepaint to layout the content into logical lines based on viewport bounds wrapping. pub(super) fn build_logical_lines( content: &str, window: &mut Window, @@ -1190,61 +1094,139 @@ impl InputState { logical_lines } - pub(crate) fn total_content_height(&self) -> Pixels { + /// Processes a focus-flag update during window paint, returning whether the cursor should be visible in this frame. + /// Returns false if the cursor is blinking and not currently visible. + pub(super) fn toggle_cursor_on_focus_change( + &mut self, + is_focused: bool, + cx: &mut Context, + ) -> bool { + // Update cursor blink based on focus changes + let was_focused = self.was_focused; + self.was_focused = is_focused; + + match &self.cursor_blink { + None => true, + Some((cursor_blink, _)) => match (is_focused, was_focused) { + (true, false) => { + cursor_blink.update(cx, |cursor, cx| cursor.enable(cx)); + cx.emit(InputStateEvent::Focus); + true + } + (false, true) => { + cursor_blink.update(cx, |cursor, cx| cursor.disable(cx)); + cx.emit(InputStateEvent::Blur); + false + } + _ => cursor_blink.read(cx).visible(), + }, + } + } + + fn move_to(&mut self, offset: usize, cx: &mut Context) { + self.pause_cursor_blink(cx); + let offset = offset.min(self.content.len()); + self.selected_range = offset..offset; + self.selection_reversed = false; + self.scroll_to_cursor(); + cx.notify(); + } + + fn select_to(&mut self, offset: usize, cx: &mut Context) { + self.pause_cursor_blink(cx); + let offset = offset.min(self.content.len()); + if self.selection_reversed { + self.selected_range.start = offset; + } else { + self.selected_range.end = offset; + } + if self.selected_range.end < self.selected_range.start { + self.selection_reversed = !self.selection_reversed; + self.selected_range = self.selected_range.end..self.selected_range.start; + } + self.scroll_to_cursor(); + cx.notify(); + } + + fn find_visual_line_and_x_offset(&self, offset: usize) -> (usize, f32) { + if self.logical_lines.is_empty() { + return (0, 0.0); + } + + let mut visual_line_idx = 0; + + for line in &self.logical_lines { + if line.text_range.is_empty() { + if offset == line.text_range.start { + return (visual_line_idx, 0.0); + } + } else if offset >= line.text_range.start && offset <= line.text_range.end { + if let Some(wrapped) = &line.wrapped_line { + let local_offset = (offset - line.text_range.start).min(wrapped.text.len()); + if let Some(position) = + wrapped.position_for_index(local_offset, self.line_height()) + { + let visual_line_within = (position.y / self.line_height()).floor() as usize; + return (visual_line_idx + visual_line_within, position.x.into()); + } + } + return (visual_line_idx, 0.0); + } + visual_line_idx += line.visual_line_count; + } + + (visual_line_idx.saturating_sub(1), 0.0) + } + + fn move_vertically(&self, offset: usize, direction: i32) -> Option { + let (visual_line_idx, x_pixels) = self.find_visual_line_and_x_offset(offset); + let target_visual_line_idx = (visual_line_idx as i32 + direction).max(0) as usize; + + let mut current_visual_line = 0; + for layout in self.logical_lines.iter() { + let visual_lines_in_layout = layout.visual_line_count; + + if target_visual_line_idx < current_visual_line + visual_lines_in_layout { + let visual_line_within_layout = target_visual_line_idx - current_visual_line; + + if layout.text_range.is_empty() { + return Some(layout.text_range.start); + } + + if let Some(wrapped) = &layout.wrapped_line { + let y_within_wrapped = self.line_height() * visual_line_within_layout as f32; + let target_point = point(px(x_pixels), y_within_wrapped); + + let closest_result = + wrapped.closest_index_for_position(target_point, self.line_height()); + + let closest_idx = closest_result.unwrap_or_else(|closest| closest); + let clamped = closest_idx.min(wrapped.text.len()); + let result = layout.text_range.start + clamped; + + return Some(result); + } + + return Some(layout.text_range.start); + } + + current_visual_line += visual_lines_in_layout; + } + + if direction > 0 { + Some(self.content.len()) + } else { + None + } + } + + pub(super) fn total_content_height(&self) -> Pixels { self.logical_lines .last() .map(|last| last.y_offset + self.line_height() * last.visual_line_count as f32) .unwrap_or(px(0.)) } - /// Returns true if the scroll position is at the top. - pub fn at_top(&self) -> bool { - self.scroll_offset <= px(0.) - } - - /// Returns true if the scroll position is at the bottom. - pub fn at_bottom(&self) -> bool { - let content_height = self.total_content_height(); - let visible_height = self.layout_data.available_size.height; - - if content_height <= visible_height { - return true; - } - - self.scroll_offset + visible_height >= content_height - } - - /// Returns the scroll progress as a value from 0.0 (top) to 1.0 (bottom). - pub fn scroll_progress(&self) -> f32 { - let content_height = self.total_content_height(); - let visible_height = self.layout_data.available_size.height; - let max_scroll = content_height - visible_height; - - if max_scroll <= px(0.) { - return 0.0; - } - - (self.scroll_offset / max_scroll).clamp(0.0, 1.0) - } - - /// Returns how far the content is scrolled from the top in pixels. - pub fn distance_from_top(&self) -> Pixels { - self.scroll_offset.max(px(0.)) - } - - /// Returns how far the content is from the bottom in pixels. - pub fn distance_from_bottom(&self) -> Pixels { - let content_height = self.total_content_height(); - let visible_height = self.layout_data.available_size.height; - let max_scroll = content_height - visible_height; - - if max_scroll <= px(0.) { - return px(0.); - } - - (max_scroll - self.scroll_offset).max(px(0.)) - } - fn previous_boundary(&self, offset: usize) -> usize { if offset == 0 { return 0; @@ -1351,7 +1333,7 @@ mod tests { ) -> WindowHandle { cx.add_window(|_window, cx| { let input = cx.new(|cx| { - let mut input = InputState::new(cx).layout(InputLayout::MultiLine); + let mut input = InputState::new(cx).with_layout_style(InputLayoutStyle::MultiLine); input.content = content.to_string().into(); input.selected_range = range; input @@ -1368,7 +1350,7 @@ mod tests { ) -> WindowHandle { let view = cx.add_window(|window, cx| { let input = cx.new(|cx| { - let mut input = InputState::new(cx).layout(InputLayout::MultiLine); + let mut input = InputState::new(cx).with_layout_style(InputLayoutStyle::MultiLine); input.content = content.to_string().into(); input.selected_range = range; input.layout_data.line_height = px(20.); @@ -2319,7 +2301,7 @@ mod tests { ) -> WindowHandle { cx.add_window(|_window, cx| { let input = cx.new(|cx| { - let mut input = InputState::new(cx).layout(InputLayout::SingleLine); + let mut input = InputState::new(cx).with_layout_style(InputLayoutStyle::SingleLine); input.content = content.to_string().into(); input.selected_range = selected_range; input @@ -2408,7 +2390,7 @@ mod tests { let view = create_single_line_input(cx, "hello", 0..0); view.update(cx, |view, _window, cx| { view.input.update(cx, |input, _cx| { - assert_eq!(input.get_layout(), InputLayout::SingleLine); + assert_eq!(input.layout_style(), InputLayoutStyle::SingleLine); }); }) .unwrap(); @@ -2417,7 +2399,7 @@ mod tests { multiline_view .update(cx, |view, _window, cx| { view.input.update(cx, |input, _cx| { - assert_eq!(input.get_layout(), InputLayout::MultiLine); + assert_eq!(input.layout_style(), InputLayoutStyle::MultiLine); }); }) .unwrap(); @@ -2472,7 +2454,7 @@ mod tests { let view = create_test_input(cx, "hello", 0..0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { - assert!(!input.can_undo()); + assert!(!input.is_undo_available()); input.undo(&Undo, window, cx); assert_eq!(input.content(), "hello"); }); @@ -2485,7 +2467,7 @@ mod tests { let view = create_test_input(cx, "hello", 0..0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { - assert!(!input.can_redo()); + assert!(!input.is_redo_available()); input.redo(&Redo, window, cx); assert_eq!(input.content(), "hello"); }); @@ -2560,12 +2542,12 @@ mod tests { input.undo(&Undo, window, cx); assert_eq!(input.content(), "hello"); - assert!(input.can_redo()); + assert!(input.is_redo_available()); // New edit should clear redo stack input.replace_text_in_range(None, "!", window, cx); assert_eq!(input.content(), "hello!"); - assert!(!input.can_redo()); + assert!(!input.is_redo_available()); }); }) .unwrap(); @@ -2579,11 +2561,11 @@ mod tests { input.set_history_group_interval(Duration::from_secs(0)); input.replace_text_in_range(None, " world", window, cx); - assert!(input.can_undo()); + assert!(input.is_undo_available()); input.set_content("new content", cx); - assert!(!input.can_undo()); - assert!(!input.can_redo()); + assert!(!input.is_undo_available()); + assert!(!input.is_redo_available()); }); }) .unwrap(); @@ -2596,20 +2578,20 @@ mod tests { view.input.update(cx, |input, cx| { input.set_history_group_interval(Duration::from_secs(0)); - assert!(!input.can_undo()); - assert!(!input.can_redo()); + assert!(!input.is_undo_available()); + assert!(!input.is_redo_available()); input.replace_text_in_range(None, "!", window, cx); - assert!(input.can_undo()); - assert!(!input.can_redo()); + assert!(input.is_undo_available()); + assert!(!input.is_redo_available()); input.undo(&Undo, window, cx); - assert!(!input.can_undo()); - assert!(input.can_redo()); + assert!(!input.is_undo_available()); + assert!(input.is_redo_available()); input.redo(&Redo, window, cx); - assert!(input.can_undo()); - assert!(!input.can_redo()); + assert!(input.is_undo_available()); + assert!(!input.is_redo_available()); }); }) .unwrap(); diff --git a/crates/gpui_elements/src/input/state_input_handler.rs b/crates/gpui_elements/src/input/state_input_handler.rs index b51f2a7992..aeb1f76bb8 100644 --- a/crates/gpui_elements/src/input/state_input_handler.rs +++ b/crates/gpui_elements/src/input/state_input_handler.rs @@ -59,7 +59,7 @@ impl EntityInputHandler for super::InputState { let range = range.start.min(self.content().len())..range.end.min(self.content().len()); - let text_to_insert = self.layout.sanitize_content(new_text); + let text_to_insert = self.layout_style.sanitize_content(new_text); // Record patch for undo before modifying content self.push_undo_patch(range.clone(), text_to_insert.len()); @@ -101,7 +101,7 @@ impl EntityInputHandler for super::InputState { let range = range.start.min(self.content().len())..range.end.min(self.content().len()); - let text_to_insert = self.layout.sanitize_content(new_text); + let text_to_insert = self.layout_style.sanitize_content(new_text); // Update cached UTF-16 length incrementally if available if let Some(cached_len) = self.cached_utf16_len { @@ -208,7 +208,7 @@ impl EntityInputHandler for super::InputState { _window: &mut Window, _cx: &mut Context, ) -> Option { - let index = self.index_for_position(point); + let index = self.index_for_pixel_point(point); Some(self.utf_offset_8to16(index)) } } From 06dd56bda6ff179150728ec33ae58879db3219c4 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 17:23:20 -0400 Subject: [PATCH 023/117] replace selection_reversed with a NavigationDirection backed selection_direction --- crates/gpui_elements/src/input/history.rs | 8 +- crates/gpui_elements/src/input/state.rs | 126 ++++++++---------- .../src/input/state_input_handler.rs | 7 +- 3 files changed, 68 insertions(+), 73 deletions(-) diff --git a/crates/gpui_elements/src/input/history.rs b/crates/gpui_elements/src/input/history.rs index 40128d21fc..cc7fdcd02e 100644 --- a/crates/gpui_elements/src/input/history.rs +++ b/crates/gpui_elements/src/input/history.rs @@ -3,7 +3,7 @@ use std::{ time::{Duration, Instant}, }; -use gpui::SharedString; +use gpui::{NavigationDirection, SharedString}; /// Maximum number of history entries to keep. pub const MAX_HISTORY_LEN: usize = 1000; @@ -23,8 +23,8 @@ pub struct HistoryEntry { pub new_text_len: usize, /// The selection range before the edit. pub selected_range: Range, - /// Whether the selection was reversed before the edit. - pub selection_reversed: bool, + /// The direction of the selection before the edit. + pub selection_direction: NavigationDirection, /// Timestamp for grouping consecutive edits. pub timestamp: Instant, } @@ -47,7 +47,7 @@ impl HistoryEntry { old_text: removed_text, new_text_len: self.old_text.len(), selected_range: self.selected_range.clone(), - selection_reversed: self.selection_reversed, + selection_direction: self.selection_direction, timestamp: self.timestamp, } } diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index 39275916e3..815482ef43 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -2,8 +2,8 @@ use super::actions::*; use crate::input::InputLayoutStyle; use gpui::{ App, AppContext, ClipboardItem, Context, Entity, EntityId, EntityInputHandler, EventEmitter, - FocusHandle, Focusable, Pixels, Point, SharedString, Size, Subscription, TextRun, TextStyle, - Window, WrappedLine, point, px, + FocusHandle, Focusable, NavigationDirection, Pixels, Point, SharedString, Size, Subscription, + TextRun, TextStyle, Window, WrappedLine, point, px, }; use std::{ ops::Range, @@ -39,7 +39,7 @@ pub struct InputState { focus_handle: FocusHandle, content: SharedString, pub(super) selected_range: Range, - pub(super) selection_reversed: bool, // TODO: NavigationDirection + pub(super) selection_direction: NavigationDirection, pub(super) marked_range: Option>, pub(super) logical_lines: Vec, // refreshed each update by the element, for conveinent access in mutations and painting @@ -124,7 +124,7 @@ impl InputState { focus_handle: cx.focus_handle(), content: SharedString::default(), selected_range: 0..0, - selection_reversed: false, + selection_direction: NavigationDirection::Forward, marked_range: None, layout_data: InputLayoutData::default(), logical_lines: Vec::new(), @@ -173,7 +173,7 @@ impl InputState { let content = self.layout_style.sanitize_content(content.as_ref()); self.content = content.to_string().into(); self.selected_range = 0..0; - self.selection_reversed = false; + self.selection_direction = NavigationDirection::Forward; self.marked_range = None; self.layout_data.dirty = true; self.undo_stack.clear(); @@ -204,14 +204,14 @@ impl InputState { pub fn set_selected_range(&mut self, range: Range) { let range = range.start.min(self.content.len())..range.end.min(self.content.len()); self.selected_range = range; - self.selection_reversed = false; + self.selection_direction = NavigationDirection::Forward; } /// Returns the current position of the cursor within the utf-8 character range of the current state of the text. pub fn cursor_position(&self) -> usize { - match self.selection_reversed { - true => self.selected_range.start, - false => self.selected_range.end, + match self.selection_direction { + NavigationDirection::Back => self.selected_range.start, + NavigationDirection::Forward => self.selected_range.end, } } @@ -330,13 +330,13 @@ impl InputState { pub fn undo_action(&mut self, cx: &mut Context) { if let Some(entry) = self.undo_stack.pop() { let selected_range = entry.selected_range.clone(); - let selection_reversed = entry.selection_reversed; + let selection_direction = entry.selection_direction; let redo_entry = entry.apply_undo(&mut self.content); self.redo_stack.push(redo_entry); self.selected_range = selected_range; - self.selection_reversed = selection_reversed; + self.selection_direction = selection_direction; self.layout_data.dirty = true; self.cached_utf16_len = None; self.scroll_to_cursor(); @@ -352,7 +352,7 @@ impl InputState { let cursor_pos = undo_entry.range.start; self.selected_range = cursor_pos..cursor_pos; - self.selection_reversed = false; + self.selection_direction = NavigationDirection::Forward; self.undo_stack.push(undo_entry); self.layout_data.dirty = true; @@ -370,7 +370,7 @@ impl InputState { if let Some(entry) = self.undo_stack.pop() { // Remember selection to restore let selected_range = entry.selected_range.clone(); - let selection_reversed = entry.selection_reversed; + let selection_direction = entry.selection_direction; // Apply the undo patch and get the redo patch let redo_entry = entry.apply_undo(&mut self.content); @@ -378,7 +378,7 @@ impl InputState { // Restore selection state self.selected_range = selected_range; - self.selection_reversed = selection_reversed; + self.selection_direction = selection_direction; self.layout_data.dirty = true; self.cached_utf16_len = None; self.scroll_to_cursor(); @@ -396,7 +396,7 @@ impl InputState { // We need to restore cursor to end of inserted text let cursor_pos = undo_entry.range.start; self.selected_range = cursor_pos..cursor_pos; - self.selection_reversed = false; + self.selection_direction = NavigationDirection::Forward; self.undo_stack.push(undo_entry); self.layout_data.dirty = true; @@ -409,7 +409,7 @@ impl InputState { pub(super) fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { self.selected_range = 0..self.content.len(); - self.selection_reversed = false; + self.selection_direction = NavigationDirection::Forward; cx.notify(); } @@ -437,14 +437,14 @@ impl InputState { InputLayoutStyle::SingleLine => { // In single-line mode, up moves to start self.selected_range = 0..0; - self.selection_reversed = false; + self.selection_direction = NavigationDirection::Forward; self.scroll_to_cursor(); cx.notify(); } InputLayoutStyle::MultiLine => { if let Some(new_offset) = self.move_vertically(self.cursor_position(), -1) { self.selected_range = new_offset..new_offset; - self.selection_reversed = false; + self.selection_direction = NavigationDirection::Forward; self.scroll_to_cursor(); cx.notify(); } @@ -459,14 +459,14 @@ impl InputState { // In single-line mode, down moves to end let end = self.content.len(); self.selected_range = end..end; - self.selection_reversed = false; + self.selection_direction = NavigationDirection::Forward; self.scroll_to_cursor(); cx.notify(); } InputLayoutStyle::MultiLine => { if let Some(new_offset) = self.move_vertically(self.cursor_position(), 1) { self.selected_range = new_offset..new_offset; - self.selection_reversed = false; + self.selection_direction = NavigationDirection::Forward; self.scroll_to_cursor(); cx.notify(); } @@ -490,19 +490,12 @@ impl InputState { self.select_to(0, cx); } InputLayoutStyle::MultiLine => { - if let Some(new_offset) = self.move_vertically(self.cursor_position(), -1) { - if self.selection_reversed { - self.selected_range.start = new_offset; - } else { - self.selected_range.end = new_offset; - } - if self.selected_range.end < self.selected_range.start { - self.selection_reversed = !self.selection_reversed; - self.selected_range = self.selected_range.end..self.selected_range.start; - } - self.scroll_to_cursor(); - cx.notify(); - } + let Some(new_offset) = self.move_vertically(self.cursor_position(), -1) else { + return; + }; + self.apply_selection_offset(new_offset); + self.scroll_to_cursor(); + cx.notify(); } } } @@ -520,19 +513,12 @@ impl InputState { self.select_to(self.content.len(), cx); } InputLayoutStyle::MultiLine => { - if let Some(new_offset) = self.move_vertically(self.cursor_position(), 1) { - if self.selection_reversed { - self.selected_range.start = new_offset; - } else { - self.selected_range.end = new_offset; - } - if self.selected_range.end < self.selected_range.start { - self.selection_reversed = !self.selection_reversed; - self.selected_range = self.selected_range.end..self.selected_range.start; - } - self.scroll_to_cursor(); - cx.notify(); - } + let Some(new_offset) = self.move_vertically(self.cursor_position(), 1) else { + return; + }; + self.apply_selection_offset(new_offset); + self.scroll_to_cursor(); + cx.notify(); } } } @@ -766,7 +752,7 @@ impl InputState { 2 => { let (word_start, word_end) = self.word_range_at(character_pos); self.selected_range = word_start..word_end; - self.selection_reversed = false; + self.selection_direction = NavigationDirection::Forward; cx.notify(); } 3 => { @@ -778,7 +764,7 @@ impl InputState { line_end }; self.selected_range = line_start..line_end_with_newline; - self.selection_reversed = false; + self.selection_direction = NavigationDirection::Forward; cx.notify(); } _ => { @@ -847,7 +833,7 @@ impl InputState { old_text, new_text_len, selected_range: self.selected_range.clone(), - selection_reversed: self.selection_reversed, + selection_direction: self.selection_direction, timestamp: now, }); @@ -1127,7 +1113,7 @@ impl InputState { self.pause_cursor_blink(cx); let offset = offset.min(self.content.len()); self.selected_range = offset..offset; - self.selection_reversed = false; + self.selection_direction = NavigationDirection::Forward; self.scroll_to_cursor(); cx.notify(); } @@ -1135,19 +1121,25 @@ impl InputState { fn select_to(&mut self, offset: usize, cx: &mut Context) { self.pause_cursor_blink(cx); let offset = offset.min(self.content.len()); - if self.selection_reversed { - self.selected_range.start = offset; - } else { - self.selected_range.end = offset; - } - if self.selected_range.end < self.selected_range.start { - self.selection_reversed = !self.selection_reversed; - self.selected_range = self.selected_range.end..self.selected_range.start; - } + self.apply_selection_offset(offset); self.scroll_to_cursor(); cx.notify(); } + fn apply_selection_offset(&mut self, offset: usize) { + match self.selection_direction { + NavigationDirection::Forward => self.selected_range.end = offset, + NavigationDirection::Back => self.selected_range.start = offset, + } + if self.selected_range.end < self.selected_range.start { + self.selection_direction = match self.selection_direction { + NavigationDirection::Forward => NavigationDirection::Back, + NavigationDirection::Back => NavigationDirection::Forward, + }; + self.selected_range = self.selected_range.end..self.selected_range.start; + } + } + fn find_visual_line_and_x_offset(&self, offset: usize) -> (usize, f32) { if self.logical_lines.is_empty() { return (0, 0.0); @@ -1607,7 +1599,7 @@ mod tests { view.input.update(cx, |input, cx| { input.select_left(&SelectLeft, window, cx); assert_eq!(input.selected_range, 2..3); - assert!(input.selection_reversed); + assert_eq!(input.selection_direction, NavigationDirection::Back); }); }) .unwrap(); @@ -1620,7 +1612,7 @@ mod tests { view.input.update(cx, |input, cx| { input.select_right(&SelectRight, window, cx); assert_eq!(input.selected_range, 2..3); - assert!(!input.selection_reversed); + assert_eq!(input.selection_direction, NavigationDirection::Forward); }); }) .unwrap(); @@ -1645,7 +1637,7 @@ mod tests { view.input.update(cx, |input, cx| { input.select_to_beginning(&SelectToBeginning, window, cx); assert_eq!(input.selected_range, 0..6); - assert!(input.selection_reversed); + assert_eq!(input.selection_direction, NavigationDirection::Back); }); }) .unwrap(); @@ -1658,7 +1650,7 @@ mod tests { view.input.update(cx, |input, cx| { input.select_to_end(&SelectToEnd, window, cx); assert_eq!(input.selected_range, 6..11); - assert!(!input.selection_reversed); + assert_eq!(input.selection_direction, NavigationDirection::Forward); }); }) .unwrap(); @@ -1951,12 +1943,12 @@ mod tests { let view = create_test_input(cx, "hello world", 3..8); view.update(cx, |view, _window, cx| { view.input.update(cx, |input, cx| { - input.selection_reversed = true; + input.selection_direction = NavigationDirection::Back; input.marked_range = Some(5..7); input.set_content("new content", cx); assert_eq!(input.content(), "new content"); assert_eq!(input.selected_range, 0..0); - assert!(!input.selection_reversed); + assert_eq!(input.selection_direction, NavigationDirection::Forward); assert_eq!(input.marked_range, None); }); }) @@ -2366,7 +2358,7 @@ mod tests { view.input.update(cx, |input, cx| { input.select_up(&SelectUp, window, cx); assert_eq!(input.selected_range, 0..5); - assert!(input.selection_reversed); + assert_eq!(input.selection_direction, NavigationDirection::Back); }); }) .unwrap(); @@ -2379,7 +2371,7 @@ mod tests { view.input.update(cx, |input, cx| { input.select_down(&SelectDown, window, cx); assert_eq!(input.selected_range, 5..11); // "hello world".len() == 11 - assert!(!input.selection_reversed); + assert_eq!(input.selection_direction, NavigationDirection::Forward); }); }) .unwrap(); diff --git a/crates/gpui_elements/src/input/state_input_handler.rs b/crates/gpui_elements/src/input/state_input_handler.rs index aeb1f76bb8..93b69f1162 100644 --- a/crates/gpui_elements/src/input/state_input_handler.rs +++ b/crates/gpui_elements/src/input/state_input_handler.rs @@ -1,6 +1,9 @@ use super::unicode::UnicodeString; use crate::input::InputStateEvent; -use gpui::{Bounds, Context, EntityInputHandler, Pixels, Point, UTF16Selection, Window, point, px}; +use gpui::{ + Bounds, Context, EntityInputHandler, NavigationDirection, Pixels, Point, UTF16Selection, + Window, point, px, +}; use std::ops::Range; impl EntityInputHandler for super::InputState { @@ -26,7 +29,7 @@ impl EntityInputHandler for super::InputState { ) -> Option { Some(UTF16Selection { range: self.utf_range_8to16(&self.selected_range), - reversed: self.selection_reversed, + reversed: self.selection_direction == NavigationDirection::Back, }) } From a2e3b5f09ee50d513bacda8af6e7768424127f9c Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 17:33:43 -0400 Subject: [PATCH 024/117] remove theoretically unnecessary single-line paint optimizations --- crates/gpui_elements/src/input/paint.rs | 276 +++++------------------- 1 file changed, 59 insertions(+), 217 deletions(-) diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index 2a66149563..63eef37b9a 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -4,7 +4,7 @@ use gpui::{ ElementInputHandler, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, LayoutId, Length, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollWheelEvent, SharedString, Style, TextAlign, TextRun, - TextStyle, Window, WrappedLine, fill, point, px, relative, size, + TextStyle, Window, fill, point, px, relative, size, }; use std::ops::Range; @@ -155,20 +155,6 @@ impl Element for Input { }); let perform_paint = |_style: &Style, window: &mut Window, cx: &mut App| { - let precomputed_first_line = match (snapshot.layout, snapshot.logical_lines.first()) { - ( - super::InputLayoutStyle::SingleLine, - Some(InputLogicalLine { - wrapped_line: Some(wrapped_line), - .. - }), - ) => Some(PrecomputedLinePosition::new( - &snapshot.content, - &**wrapped_line, - snapshot.line_height, - )), - _ => None, - }; let context = PaintContext { snapshot, is_focused, @@ -177,7 +163,6 @@ impl Element for Input { placeholder: placeholder.as_ref(), colors: &colors, cursor_visible: is_cursor_visible, - precomputed_first_line, }; context.process_mouse_events(&self.input, window, cx); window.with_content_mask(Some(ContentMask { bounds }), |window| { @@ -236,7 +221,6 @@ struct PaintContext<'app> { placeholder: Option<&'app SharedString>, colors: &'app PaintColors, cursor_visible: bool, - precomputed_first_line: Option, } impl<'app> PaintContext<'app> { @@ -377,67 +361,38 @@ impl<'app> PaintContext<'app> { } fn paint_selection(&self, window: &mut Window) { - match self.snapshot.layout { - super::InputLayoutStyle::MultiLine => { - for line in &self.snapshot.logical_lines { - let line_y = line.y_offset - self.snapshot.scroll_offset; + let one_line = self.snapshot.logical_lines.len() == 1; + for line in &self.snapshot.logical_lines { + let line_y = line.y_offset - self.snapshot.scroll_offset; - if !self.is_line_visible(line) { - continue; - } + if !one_line { + if !self.is_line_visible(line) { + continue; + } - if !line_intersects_range(&line.text_range, &self.snapshot.selected_range) { - continue; - } - - if line.text_range.is_empty() { - const EMPTY_LINE_SELECTION_WIDTH: Pixels = px(6.); - self.paint_bounds_quad( - window, - self.colors.selection, - point(px(0.), line_y), - point( - EMPTY_LINE_SELECTION_WIDTH, - line_y + self.snapshot.line_height, - ), - ); - } else { - self.paint_line_range( - window, - line, - &self.snapshot.selected_range, - self.colors.selection, - px(0.), - ); - } + if !line_intersects_range(&line.text_range, &self.snapshot.selected_range) { + continue; } } - super::InputLayoutStyle::SingleLine => { - let precomputed = self - .precomputed_first_line - .as_ref() - .expect("missing precomputed single-line"); - let start_x = pos_in_string_for_char_index( - &self.snapshot.content, - &precomputed.char_positions, - self.snapshot.selected_range.start, - &precomputed.text_width, - ) - self.snapshot.scroll_offset; - let end_x = pos_in_string_for_char_index( - &self.snapshot.content, - &precomputed.char_positions, - self.snapshot.selected_range.end, - &precomputed.text_width, - ) - self.snapshot.scroll_offset; - - let y_offset = - (self.bounds.size.height - self.snapshot.line_height).max(px(0.)) / 2.0; + if line.text_range.is_empty() { + const EMPTY_LINE_SELECTION_WIDTH: Pixels = px(6.); self.paint_bounds_quad( window, self.colors.selection, - point(start_x, y_offset), - point(end_x, y_offset + self.snapshot.line_height), + point(px(0.), line_y), + point( + EMPTY_LINE_SELECTION_WIDTH, + line_y + self.snapshot.line_height, + ), + ); + } else { + self.paint_line_range( + window, + line, + &self.snapshot.selected_range, + self.colors.selection, + px(0.), ); } } @@ -477,52 +432,26 @@ impl<'app> PaintContext<'app> { } fn paint_text(&self, window: &mut Window, cx: &mut App) { - match self.snapshot.layout { - super::InputLayoutStyle::MultiLine => { - for line_layout in &self.snapshot.logical_lines { - let line_y = line_layout.y_offset - self.snapshot.scroll_offset; + for line_layout in &self.snapshot.logical_lines { + let line_y = line_layout.y_offset - self.snapshot.scroll_offset; - if !self.is_line_visible(line_layout) { - continue; - } - - if let Some(wrapped) = &line_layout.wrapped_line { - let paint_pos = point(self.bounds.left(), self.bounds.top() + line_y); - let _ = wrapped.paint( - paint_pos, - self.snapshot.line_height, - TextAlign::Left, - Some(self.bounds), - window, - cx, - ); - } - } + if !self.is_line_visible(line_layout) { + continue; } - super::InputLayoutStyle::SingleLine => { - let Some(line_layout) = self.snapshot.logical_lines.first() else { - return; - }; - let Some(wrapped_line) = &line_layout.wrapped_line else { - return; - }; - let y_offset = - (self.bounds.size.height - self.snapshot.line_height).max(px(0.)) / 2.0; - let paint_origin = point( - self.bounds.origin.x - self.snapshot.scroll_offset, - self.bounds.origin.y + y_offset, - ); + let Some(wrapped) = &line_layout.wrapped_line else { + continue; + }; - let _ = wrapped_line.paint( - paint_origin, - self.snapshot.line_height, - TextAlign::Left, - Some(self.bounds), - window, - cx, - ); - } + let paint_pos = point(self.bounds.left(), self.bounds.top() + line_y); + let _ = wrapped.paint( + paint_pos, + self.snapshot.line_height, + TextAlign::Left, + Some(self.bounds), + window, + cx, + ); } } @@ -536,57 +465,26 @@ impl<'app> PaintContext<'app> { let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); let underline_offset = self.snapshot.line_height - underline_thickness; - match self.snapshot.layout { - super::InputLayoutStyle::MultiLine => { - for line in &self.snapshot.logical_lines { - if !self.is_line_visible(line) { - continue; - } - - if !line_intersects_range(&line.text_range, marked_range) { - continue; - } - - if line.text_range.is_empty() { - continue; - } - - self.paint_line_range( - window, - line, - marked_range, - self.colors.cursor, - underline_offset, - ); - } + for line in &self.snapshot.logical_lines { + if !self.is_line_visible(line) { + continue; } - super::InputLayoutStyle::SingleLine => { - let Some(precomputed) = &self.precomputed_first_line else { - return; - }; - let start_x = pos_in_string_for_char_index( - &self.snapshot.content, - &precomputed.char_positions, - marked_range.start, - &precomputed.text_width, - ) - self.snapshot.scroll_offset; - let end_x = pos_in_string_for_char_index( - &self.snapshot.content, - &precomputed.char_positions, - marked_range.end, - &precomputed.text_width, - ) - self.snapshot.scroll_offset; - let y_offset = - (self.bounds.size.height - self.snapshot.line_height).max(px(0.)) / 2.0; - - self.paint_bounds_quad( - window, - self.colors.cursor, - point(start_x, y_offset + underline_offset), - point(end_x, y_offset + self.snapshot.line_height), - ); + if !line_intersects_range(&line.text_range, marked_range) { + continue; } + + if line.text_range.is_empty() { + continue; + } + + self.paint_line_range( + window, + line, + marked_range, + self.colors.cursor, + underline_offset, + ); } } @@ -626,26 +524,7 @@ impl<'app> PaintContext<'app> { } fn paint_cursor(&self, window: &mut Window) { - let cursor_pos = match self.snapshot.layout { - super::InputLayoutStyle::MultiLine => self.find_cursor_position_in_layouts(), - super::InputLayoutStyle::SingleLine => { - let Some(precomputed) = &self.precomputed_first_line else { - return; - }; - let cursor_x = pos_in_string_for_char_index( - &self.snapshot.content, - &precomputed.char_positions, - self.snapshot.cursor_offset, - &precomputed.text_width, - ) - self.snapshot.scroll_offset; - - let y_offset = - (self.bounds.size.height - self.snapshot.line_height).max(px(0.)) / 2.0; - - point(cursor_x, y_offset) - } - }; - + let cursor_pos = self.find_cursor_position_in_layouts(); window.paint_quad(fill( Bounds::new( point(self.bounds.left(), self.bounds.top()) + cursor_pos, @@ -748,40 +627,3 @@ fn line_intersects_range( selected_range.end > text_range.start && selected_range.start < text_range.end } } - -struct PrecomputedLinePosition { - text_width: Pixels, - char_positions: Vec, -} -impl PrecomputedLinePosition { - fn new(string: &str, line: &WrappedLine, line_height: Pixels) -> Self { - let text_width = line.width(); - let mut char_positions = Vec::new(); - - let mut idx = 0; - for ch in string.chars() { - if let Some(pos) = line.position_for_index(idx, line_height) { - char_positions.push(pos.x); - } else { - char_positions.push(text_width); - } - idx += ch.len_utf8(); - } - char_positions.push(text_width); - - Self { - text_width, - char_positions, - } - } -} - -fn pos_in_string_for_char_index<'chars>( - content: &SharedString, - char_positions: &'chars Vec, - index: usize, - default: &Pixels, -) -> Pixels { - let char_index = content[..index.min(content.len())].chars().count(); - char_positions.get(char_index).unwrap_or(default).clone() -} From 4b23b30357ee968eff2471d38950191ba5677321 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 17:42:45 -0400 Subject: [PATCH 025/117] optimize InputStateSnapshot --- crates/gpui_elements/src/input/paint.rs | 39 +++++++++++++++---------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index 63eef37b9a..ed4fdeff32 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -1,6 +1,6 @@ use crate::input::{Input, InputLayoutData, InputLogicalLine, InputState, PaintColors}; use gpui::{ - Along, App, Bounds, ContentMask, CursorStyle, DispatchPhase, Element, ElementId, + Along, App, Axis, Bounds, ContentMask, CursorStyle, DispatchPhase, Element, ElementId, ElementInputHandler, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, LayoutId, Length, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollWheelEvent, SharedString, Style, TextAlign, TextRun, @@ -181,12 +181,15 @@ impl Element for Input { } } +/// A minimal copy of InputState that is used during paint operations without needing to read from the entity in App multiple times in a single paint. +/// Ideally this struct is quite small. struct InputStateSnapshot { - layout: super::InputLayoutStyle, - content: SharedString, + layout_axis: Axis, + should_center_placeholder: bool, + show_placeholder: bool, selected_range: Range, marked_range: Option>, - cursor_offset: usize, + cursor_position: usize, logical_lines: Vec, scroll_offset: Pixels, line_height: Pixels, @@ -196,16 +199,22 @@ impl InputStateSnapshot { let input_state = entity.read(cx); let selected_range = input_state.selected_range().clone(); let marked_range = input_state.marked_range().cloned(); - let cursor_offset = input_state.cursor_position(); + let cursor_position = input_state.cursor_position(); let logical_lines = input_state.logical_lines.clone(); let scroll_offset = input_state.scroll_offset; let line_height = input_state.line_height(); + let layout_axis = input_state.layout_style().axis(); + let should_center_placeholder = matches!( + input_state.layout_style(), + super::InputLayoutStyle::SingleLine + ); Self { - layout: input_state.layout_style(), - content: input_state.content().clone(), + layout_axis, + should_center_placeholder, + show_placeholder: input_state.content().is_empty(), selected_range, marked_range, - cursor_offset, + cursor_position, logical_lines, scroll_offset, line_height, @@ -230,7 +239,7 @@ impl<'app> PaintContext<'app> { window: &mut Window, cx: &mut App, ) { - let axis = self.snapshot.layout.axis(); + let axis = self.snapshot.layout_axis; let bounds = self.bounds; window.on_mouse_event({ let input = entity.clone(); @@ -347,7 +356,7 @@ impl<'app> PaintContext<'app> { self.paint_selection(window); } - if self.snapshot.content.is_empty() { + if self.snapshot.show_placeholder { self.paint_placeholder(window, cx); } else { self.paint_text(window, cx); @@ -423,7 +432,7 @@ impl<'app> PaintContext<'app> { let line_height = self.text_style.line_height_in_pixels(window.rem_size()); let mut paint_origin = self.bounds.origin; - if matches!(self.snapshot.layout, super::InputLayoutStyle::SingleLine) { + if self.snapshot.should_center_placeholder { let y_offset = (self.bounds.size.height - line_height).max(px(0.)) / 2.0; paint_origin.y += y_offset; } @@ -498,10 +507,10 @@ impl<'app> PaintContext<'app> { // Since range is non-inclusive of the end value we need to check for it explicitly let is_cursor_in_line = if line.text_range.is_empty() { - self.snapshot.cursor_offset == line.text_range.start + self.snapshot.cursor_position == line.text_range.start } else { - line.text_range.contains(&self.snapshot.cursor_offset) - || self.snapshot.cursor_offset == line.text_range.end + line.text_range.contains(&self.snapshot.cursor_position) + || self.snapshot.cursor_position == line.text_range.end }; if !is_cursor_in_line { @@ -513,7 +522,7 @@ impl<'app> PaintContext<'app> { }; let local_offset = self .snapshot - .cursor_offset + .cursor_position .saturating_sub(line.text_range.start); let cursor_pos = wrapped .position_for_index(local_offset, self.snapshot.line_height) From c37593343a6368f55aad7c55d094e2b1858058a5 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 18:07:53 -0400 Subject: [PATCH 026/117] expose api for users to configure styling colors for input elements --- crates/gpui_elements/src/input/colors.rs | 10 +++--- crates/gpui_elements/src/input/element.rs | 42 ++++++++++++++++++++--- crates/gpui_elements/src/input/paint.rs | 6 ++-- crates/gpui_elements/src/input/state.rs | 2 +- 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/crates/gpui_elements/src/input/colors.rs b/crates/gpui_elements/src/input/colors.rs index f21737034c..210db77453 100644 --- a/crates/gpui_elements/src/input/colors.rs +++ b/crates/gpui_elements/src/input/colors.rs @@ -1,18 +1,20 @@ use gpui::Hsla; #[derive(Clone, Copy, Debug)] -pub struct PaintColors { +pub struct InputColors { pub selection: Hsla, pub cursor: Hsla, pub placeholder: Hsla, + pub marked: Hsla, } -impl Default for PaintColors { +impl Default for InputColors { fn default() -> Self { Self { - selection: Hsla::blue().opacity(0.2), + selection: gpui::hsla(0.583, 0.519, 0.31, 1.0), cursor: Hsla::white().opacity(0.8), - placeholder: gpui::hsla(0.6, 0.6, 0.6, 1.0), + marked: Hsla::white().opacity(0.6), + placeholder: gpui::hsla(0., 0., 0.5, 1.0), } } } diff --git a/crates/gpui_elements/src/input/element.rs b/crates/gpui_elements/src/input/element.rs index f016ee4292..31f7eabfaf 100644 --- a/crates/gpui_elements/src/input/element.rs +++ b/crates/gpui_elements/src/input/element.rs @@ -1,6 +1,6 @@ -use crate::input::{InputState, PaintColors}; +use crate::input::{InputColors, InputState}; use gpui::{ - Action, App, Context, Entity, FocusHandle, Focusable, InteractiveElement, Interactivity, + Action, App, Context, Entity, FocusHandle, Focusable, Hsla, InteractiveElement, Interactivity, IntoElement, SharedString, StyleRefinement, Styled, Window, }; @@ -14,7 +14,7 @@ pub struct Input { pub(super) input: Entity, pub(super) interactivity: Interactivity, pub(super) placeholder: Option, - pub(super) colors: PaintColors, + pub(super) colors: InputColors, } impl Input { @@ -25,7 +25,7 @@ impl Input { input: input_state.clone(), interactivity: Interactivity::new(), placeholder: None, - colors: PaintColors::default(), + colors: InputColors::default(), }; input.register_actions(); input @@ -129,6 +129,40 @@ impl Input { self.placeholder = Some(placeholder.into()); self } + + /// Sets the styling colors for the input element + pub fn colors(mut self, colors: InputColors) -> Self { + self.colors = colors; + self + } + + /// Sets the "selection" color for the input element. + /// This is the background color applied to the range of text that is currently selected by the user. + pub fn selection_color(mut self, color: Hsla) -> Self { + self.colors.selection = color; + self + } + + /// Sets the "cursor" color for the input element. + /// This is the color of the user's text cursor. + pub fn cursor_color(mut self, color: Hsla) -> Self { + self.colors.cursor = color; + self + } + + /// Sets the "placeholder" color for the input element. + /// This is the color of the placeholder string, when one is assigned and the text field is empty. + pub fn placeholder_color(mut self, color: Hsla) -> Self { + self.colors.placeholder = color; + self + } + + /// Sets the "marked" color for the input element. + /// Marking text comes from IME and needs further doc clarification. + pub fn marked_color(mut self, color: Hsla) -> Self { + self.colors.marked = color; + self + } } fn register_action( diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index ed4fdeff32..6a86ae721a 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -1,4 +1,4 @@ -use crate::input::{Input, InputLayoutData, InputLogicalLine, InputState, PaintColors}; +use crate::input::{Input, InputColors, InputLayoutData, InputLogicalLine, InputState}; use gpui::{ Along, App, Axis, Bounds, ContentMask, CursorStyle, DispatchPhase, Element, ElementId, ElementInputHandler, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla, @@ -228,7 +228,7 @@ struct PaintContext<'app> { bounds: Bounds, text_style: &'app TextStyle, placeholder: Option<&'app SharedString>, - colors: &'app PaintColors, + colors: &'app InputColors, cursor_visible: bool, } @@ -491,7 +491,7 @@ impl<'app> PaintContext<'app> { window, line, marked_range, - self.colors.cursor, + self.colors.marked, underline_offset, ); } diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index 815482ef43..f882dfd0b9 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -95,7 +95,7 @@ pub(super) struct InputLogicalLine { /// The shaped and wrapped text for this line, if available. pub wrapped_line: Option>, /// The vertical offset from the top of the text area in pixels. - pub y_offset: Pixels, + pub y_offset: Pixels, // TODO: replace with a counter such that the offset is determined by multipling the counter by the line_height /// The number of visual lines this logical line spans (due to wrapping). pub visual_line_count: usize, } From edf7cba153ebcc8ce710d427435aafe25c7a6e50 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 18:17:59 -0400 Subject: [PATCH 027/117] undo SharedString being the backbone for InputState in favor of retaining mutablity of std::String. Safer now that painting doesnt need actual access to the string itself. --- crates/gpui_elements/src/input.rs | 11 ----------- crates/gpui_elements/src/input/history.rs | 9 ++++----- crates/gpui_elements/src/input/state.rs | 16 ++++++++++++---- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/crates/gpui_elements/src/input.rs b/crates/gpui_elements/src/input.rs index e4828739c5..ae67ff0e12 100644 --- a/crates/gpui_elements/src/input.rs +++ b/crates/gpui_elements/src/input.rs @@ -15,14 +15,3 @@ pub use element::*; pub(self) use history::*; pub use layout::*; pub use state::*; - -pub(self) fn replace_range( - string: &mut gpui::SharedString, - range: std::ops::Range, - replace_with: &str, -) { - // NOTE: reallocates the SharedString bc SharedString is immutable - let mut content = string.to_string(); - content.replace_range(range, replace_with); - *string = content.into(); -} diff --git a/crates/gpui_elements/src/input/history.rs b/crates/gpui_elements/src/input/history.rs index cc7fdcd02e..4e2f36f2a8 100644 --- a/crates/gpui_elements/src/input/history.rs +++ b/crates/gpui_elements/src/input/history.rs @@ -1,10 +1,9 @@ +use gpui::NavigationDirection; use std::{ ops::Range, time::{Duration, Instant}, }; -use gpui::{NavigationDirection, SharedString}; - /// Maximum number of history entries to keep. pub const MAX_HISTORY_LEN: usize = 1000; @@ -31,7 +30,7 @@ pub struct HistoryEntry { impl HistoryEntry { /// Apply this patch to undo an edit, returning the reverse patch for redo. - pub fn apply_undo(&self, content: &mut SharedString) -> HistoryEntry { + pub fn apply_undo(&self, content: &mut String) -> HistoryEntry { let undo_start = self.range.start; let undo_end = (self.range.start + self.new_text_len).min(content.len()); @@ -39,7 +38,7 @@ impl HistoryEntry { let removed_text = content[undo_start..undo_end].to_string(); // Replace with the old text - crate::input::replace_range(content, undo_start..undo_end, &self.old_text); + content.replace_range(undo_start..undo_end, &self.old_text); // Return reverse patch for redo HistoryEntry { @@ -53,7 +52,7 @@ impl HistoryEntry { } /// Apply this patch to redo an edit, returning the reverse patch for undo. - pub fn apply_redo(&self, content: &mut SharedString) -> HistoryEntry { + pub fn apply_redo(&self, content: &mut String) -> HistoryEntry { // Redo is the same operation as undo - we're reversing the undo self.apply_undo(content) } diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index f882dfd0b9..e3173e8dc7 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -37,7 +37,7 @@ impl EventEmitter for InputState {} pub struct InputState { entity_id: EntityId, focus_handle: FocusHandle, - content: SharedString, + content: String, pub(super) selected_range: Range, pub(super) selection_direction: NavigationDirection, pub(super) marked_range: Option>, @@ -122,7 +122,7 @@ impl InputState { let mut this = Self { entity_id: cx.entity_id(), focus_handle: cx.focus_handle(), - content: SharedString::default(), + content: String::default(), selected_range: 0..0, selection_direction: NavigationDirection::Forward, marked_range: None, @@ -163,7 +163,7 @@ impl InputState { } /// Returns the current text content. - pub fn content(&self) -> &SharedString { + pub fn content(&self) -> &String { &self.content } @@ -269,10 +269,18 @@ impl InputState { } /// Configures how long the input will wait between user-input changes to create new logs in the history for undo/redo. + /// The interval by default is 300ms (defined by `DEFAULT_GROUP_INTERVAL`). pub fn set_history_group_interval(&mut self, interval: Duration) { self.history_grouping_interval = interval; } + /// Configures how long the input will wait between user-input changes to create new logs in the history for undo/redo. + /// The interval by default is 300ms (defined by `DEFAULT_GROUP_INTERVAL`). + pub fn with_history_group_interval(mut self, interval: Duration) -> Self { + self.set_history_group_interval(interval); + self + } + /// Returns whether undo is available based on the recorded states. pub fn is_undo_available(&self) -> bool { !self.undo_stack.is_empty() @@ -796,7 +804,7 @@ impl InputState { /// Replaces the provided utf-8 character range with the provided text pub(super) fn replace_range(&mut self, range: Range, text: &str) { - crate::input::replace_range(&mut self.content, range, &text); + self.content.replace_range(range, &text); } /// Pauses cursor blinking temporarily (e.g., during typing). From 4c7b51273cd77b128dcd2273c1b0c55535a5f8c7 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 18:29:55 -0400 Subject: [PATCH 028/117] add documentation --- crates/gpui_elements/src/input/cursor.rs | 15 ++- crates/gpui_elements/src/input/state.rs | 111 +++++++++++++---------- 2 files changed, 77 insertions(+), 49 deletions(-) diff --git a/crates/gpui_elements/src/input/cursor.rs b/crates/gpui_elements/src/input/cursor.rs index 8017ed9842..359bf76b5b 100644 --- a/crates/gpui_elements/src/input/cursor.rs +++ b/crates/gpui_elements/src/input/cursor.rs @@ -4,11 +4,24 @@ use std::time::Duration; /// Default interval for cursor blinking. pub const DEFAULT_BLINK_INTERVAL: Duration = Duration::from_millis(500); +/// Configuration for cursor blinking, to be provided to InputState. +pub enum CursorBlinkType<'app> { + /// The cursor will not blink. + Disabled, + /// The cursor will blink at some interval. + Enabled { + /// Provide the app so that the internal state to track cursor blinking can be created. + app: &'app mut gpui::App, + /// The interval to blink at. If none, the default value of 500ms is used (defined by `DEFAULT_BLINK_INTERVAL`). + interval: Option, + }, +} + /// Manages the blinking state of a text cursor. /// /// The cursor blinks at a configurable interval when enabled. Blinking can be /// temporarily paused (e.g., during typing) to provide immediate visual feedback. -pub struct CursorBlink { +pub(super) struct CursorBlink { interval: Duration, generation: usize, visible: bool, diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index e3173e8dc7..0537abe5c4 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -1,5 +1,5 @@ use super::actions::*; -use crate::input::InputLayoutStyle; +use crate::input::{CursorBlinkType, InputLayoutStyle}; use gpui::{ App, AppContext, ClipboardItem, Context, Entity, EntityId, EntityInputHandler, EventEmitter, FocusHandle, Focusable, NavigationDirection, Pixels, Point, SharedString, Size, Subscription, @@ -35,33 +35,50 @@ impl EventEmitter for InputState {} /// - Keyboard navigation and editing actions /// - IME (Input Method Editor) support via `EntityInputHandler` pub struct InputState { + /// The id of this entity (for app notifies when self context is unavailable) entity_id: EntityId, focus_handle: FocusHandle, + /// The true internal text content: String, + /// Cached UTF-16 length of content for faster IME operations. Lazily computed when queried. + pub(super) cached_utf16_len: Option, + + /// The style of layout (single or multiline). + pub(super) layout_style: InputLayoutStyle, + /// The utf-8 character range that is currently selected by the user. + /// NOTE: because each input has its own selection state, its trivial for users to have multiple selections active across multiple inputs at the same time. + /// This could be considered undesirable behavior, and doing so would prompt the question of should there be a mechanism to clear selection when focus is lost. pub(super) selected_range: Range, + /// The direction of the selection_range. Forward means providing in iteration order along `content`. Back means reverse iteration order. pub(super) selection_direction: NavigationDirection, + /// The utf-8 character range of `content` that is currently marked/highlighted. pub(super) marked_range: Option>, - pub(super) logical_lines: Vec, + // refreshed each update by the element, for conveinent access in mutations and painting pub(super) layout_data: InputLayoutData, - is_selecting: bool, - last_click_position: Option>, - click_count: usize, - /// Scroll offset - vertical for multiline, horizontal for single-line - pub(super) scroll_offset: Pixels, - pub(super) layout_style: InputLayoutStyle, - history_grouping_interval: Duration, - /// Stack of previous states for undo. - undo_stack: Vec, - /// Stack of undone states for redo. - redo_stack: Vec, - /// Optional entity and subscription tracking the blinking of the text cursor. - cursor_blink: Option<(Entity, Subscription)>, + /// A reinterpretation of `content` as wrapped lines with layout information. Regenerated when content changes or the layout changes during element painting. + pub(super) logical_lines: Vec, /// Tracks whether we were focused on the last update. was_focused: bool, - /// Cached UTF-16 length of content for faster IME operations. - /// Lazily computed when None. - pub(super) cached_utf16_len: Option, + + /// True while the user is in the act of highlighting a section of the text (e.g. during mouse pressed & dragging). + is_selecting: bool, + /// The last ui location relative to the element that the user clicked. Used to filter when a user clicks multiple times in the same area. + last_click_position: Option>, + /// The number of times the user has clicked `last_click_position`. Used to determine which click behavior to trigger, depending on single, double, or triple clicks. + click_count: usize, + /// The distance in pixels from the start of the text a user has scrolled along the layout_style axis (singleline is horizontal, multiline is vertical). + pub(super) scroll_offset: Pixels, + + /// The maximum duration between changes to `content` that can be grouped together as a single entry in the history log. + history_grouping_interval: Duration, + /// Stack of previous states for undo. + history_undo_stack: Vec, + /// Stack of undone states for redo. + history_redo_stack: Vec, + + /// Optional entity and subscription tracking the blinking of the text cursor. + cursor_blink: Option<(Entity, Subscription)>, } /// Data built during element prepaint that is stored in InputState for conveinence @@ -100,14 +117,6 @@ pub(super) struct InputLogicalLine { pub visual_line_count: usize, } -pub enum CursorBlinkType<'app> { - Disabled, - Enabled { - app: &'app mut App, - interval: Option, - }, -} - impl Focusable for InputState { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() @@ -123,23 +132,29 @@ impl InputState { entity_id: cx.entity_id(), focus_handle: cx.focus_handle(), content: String::default(), + cached_utf16_len: None, + + layout_style: InputLayoutStyle::SingleLine, selected_range: 0..0, selection_direction: NavigationDirection::Forward, marked_range: None, + layout_data: InputLayoutData::default(), logical_lines: Vec::new(), + was_focused: false, + is_selecting: false, last_click_position: None, click_count: 0, scroll_offset: px(0.), - layout_style: InputLayoutStyle::SingleLine, + history_grouping_interval: super::DEFAULT_GROUP_INTERVAL, - undo_stack: Vec::new(), - cached_utf16_len: None, - redo_stack: Vec::new(), + history_undo_stack: Vec::new(), + history_redo_stack: Vec::new(), + cursor_blink: None, - was_focused: false, }; + // TODO: This is unoptimal for non-blinking cases, since the entity is generated and then discarded. this = this.cursor_blink(CursorBlinkType::Enabled { app: cx, interval: None, @@ -176,8 +191,8 @@ impl InputState { self.selection_direction = NavigationDirection::Forward; self.marked_range = None; self.layout_data.dirty = true; - self.undo_stack.clear(); - self.redo_stack.clear(); + self.history_undo_stack.clear(); + self.history_redo_stack.clear(); self.cached_utf16_len = None; self.pause_cursor_blink(cx); cx.emit(InputStateEvent::TextChanged); @@ -283,12 +298,12 @@ impl InputState { /// Returns whether undo is available based on the recorded states. pub fn is_undo_available(&self) -> bool { - !self.undo_stack.is_empty() + !self.history_undo_stack.is_empty() } /// Returns whether redo is currently available based on the recorded states. pub fn is_redo_available(&self) -> bool { - !self.redo_stack.is_empty() + !self.history_redo_stack.is_empty() } /// Inserts text at the current cursor position, replacing any content that is currently selected (i.e. `selection_range` is non-empty). @@ -336,12 +351,12 @@ impl InputState { /// Reverts the last edit. pub fn undo_action(&mut self, cx: &mut Context) { - if let Some(entry) = self.undo_stack.pop() { + if let Some(entry) = self.history_undo_stack.pop() { let selected_range = entry.selected_range.clone(); let selection_direction = entry.selection_direction; let redo_entry = entry.apply_undo(&mut self.content); - self.redo_stack.push(redo_entry); + self.history_redo_stack.push(redo_entry); self.selected_range = selected_range; self.selection_direction = selection_direction; @@ -355,14 +370,14 @@ impl InputState { /// Restores the last edit reverted by `undo_action` (or the undo action binding). pub fn redo_action(&mut self, cx: &mut Context) { - if let Some(entry) = self.redo_stack.pop() { + if let Some(entry) = self.history_redo_stack.pop() { let undo_entry = entry.apply_redo(&mut self.content); let cursor_pos = undo_entry.range.start; self.selected_range = cursor_pos..cursor_pos; self.selection_direction = NavigationDirection::Forward; - self.undo_stack.push(undo_entry); + self.history_undo_stack.push(undo_entry); self.layout_data.dirty = true; self.cached_utf16_len = None; self.scroll_to_cursor(); @@ -375,14 +390,14 @@ impl InputState { // Action implementations impl InputState { pub(super) fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context) { - if let Some(entry) = self.undo_stack.pop() { + if let Some(entry) = self.history_undo_stack.pop() { // Remember selection to restore let selected_range = entry.selected_range.clone(); let selection_direction = entry.selection_direction; // Apply the undo patch and get the redo patch let redo_entry = entry.apply_undo(&mut self.content); - self.redo_stack.push(redo_entry); + self.history_redo_stack.push(redo_entry); // Restore selection state self.selected_range = selected_range; @@ -396,7 +411,7 @@ impl InputState { } pub(super) fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context) { - if let Some(entry) = self.redo_stack.pop() { + if let Some(entry) = self.history_redo_stack.pop() { // Apply the redo patch and get the undo patch let undo_entry = entry.apply_redo(&mut self.content); @@ -406,7 +421,7 @@ impl InputState { self.selected_range = cursor_pos..cursor_pos; self.selection_direction = NavigationDirection::Forward; - self.undo_stack.push(undo_entry); + self.history_undo_stack.push(undo_entry); self.layout_data.dirty = true; self.cached_utf16_len = None; self.scroll_to_cursor(); @@ -825,7 +840,7 @@ impl InputState { let now = Instant::now(); // Check if we should group with the last entry - if let Some(last) = self.undo_stack.last() { + if let Some(last) = self.history_undo_stack.last() { if now.duration_since(last.timestamp) < self.history_grouping_interval { // Within group interval - extend the existing patch // We need to merge this edit with the previous one @@ -836,7 +851,7 @@ impl InputState { // Capture the text that will be replaced let old_text = self.content[range.clone()].to_string(); - self.undo_stack.push(super::HistoryEntry { + self.history_undo_stack.push(super::HistoryEntry { range: range.start..range.start + new_text_len, old_text, new_text_len, @@ -846,12 +861,12 @@ impl InputState { }); // Limit history size - if self.undo_stack.len() > super::MAX_HISTORY_LEN { - self.undo_stack.remove(0); + if self.history_undo_stack.len() > super::MAX_HISTORY_LEN { + self.history_undo_stack.remove(0); } // New edit invalidates redo stack - self.redo_stack.clear(); + self.history_redo_stack.clear(); } /// Returns the utf-8 character position of first character after the first new-line preceeding the character at the provided utf-8 character position. From 6ce4ebbb2a019950c1908d5060ff983ae99b9eba Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 18:54:21 -0400 Subject: [PATCH 029/117] make cached_utf16_len private --- crates/gpui_elements/src/input/state.rs | 29 ++++++++++++++++++- .../src/input/state_input_handler.rs | 27 +++-------------- crates/gpui_elements/src/input/unicode.rs | 16 ---------- 3 files changed, 32 insertions(+), 40 deletions(-) diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index 0537abe5c4..d94b38c3b1 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -41,7 +41,7 @@ pub struct InputState { /// The true internal text content: String, /// Cached UTF-16 length of content for faster IME operations. Lazily computed when queried. - pub(super) cached_utf16_len: Option, + cached_utf16_len: Option, /// The style of layout (single or multiline). pub(super) layout_style: InputLayoutStyle, @@ -123,6 +123,23 @@ impl Focusable for InputState { } } +impl super::unicode::UnicodeString for InputState { + fn len_utf16_cached(&self) -> Option { + self.cached_utf16_len + } + + fn content_utf8(&self) -> &str { + &self.content + } + + fn len_utf16(&self) -> usize { + if let Some(len) = self.cached_utf16_len { + return len; + } + self.content.chars().map(|c| c.len_utf16()).sum() + } +} + // External API impl InputState { /// Creates a new `Input` with the specified multiline setting. @@ -822,6 +839,16 @@ impl InputState { self.content.replace_range(range, &text); } + // Update cached UTF-16 length incrementally if available + pub(super) fn update_utf16_len(&mut self, range: Range, text_to_insert: &str) { + let Some(cached_len) = self.cached_utf16_len else { + return; + }; + let removed_utf16_len: usize = self.content[range].chars().map(|c| c.len_utf16()).sum(); + let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum(); + self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); + } + /// Pauses cursor blinking temporarily (e.g., during typing). pub(super) fn pause_cursor_blink(&self, cx: &mut Context) { if let Some((cursor_blink, _)) = &self.cursor_blink { diff --git a/crates/gpui_elements/src/input/state_input_handler.rs b/crates/gpui_elements/src/input/state_input_handler.rs index 93b69f1162..0af1b83792 100644 --- a/crates/gpui_elements/src/input/state_input_handler.rs +++ b/crates/gpui_elements/src/input/state_input_handler.rs @@ -59,30 +59,21 @@ impl EntityInputHandler for super::InputState { .map(|range_utf16| self.utf_range_16to8(range_utf16)) .or(self.marked_range.clone()) .unwrap_or(self.selected_range.clone()); - let range = range.start.min(self.content().len())..range.end.min(self.content().len()); let text_to_insert = self.layout_style.sanitize_content(new_text); // Record patch for undo before modifying content self.push_undo_patch(range.clone(), text_to_insert.len()); - - // Update cached UTF-16 length incrementally if available - if let Some(cached_len) = self.cached_utf16_len { - let removed_utf16_len: usize = self.content()[range.clone()] - .chars() - .map(|c| c.len_utf16()) - .sum(); - let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum(); - self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); - } - + self.push_undo_patch(range.clone(), text_to_insert.len()); + self.update_utf16_len(range.clone(), &text_to_insert); self.replace_range(range.clone(), &text_to_insert); self.selected_range = range.start + text_to_insert.len()..range.start + text_to_insert.len(); self.marked_range.take(); self.layout_data.dirty = true; + self.pause_cursor_blink(cx); cx.emit(InputStateEvent::TextChanged); cx.notify(); @@ -101,21 +92,11 @@ impl EntityInputHandler for super::InputState { .map(|range_utf16| self.utf_range_16to8(range_utf16)) .or(self.marked_range.clone()) .unwrap_or(self.selected_range.clone()); - let range = range.start.min(self.content().len())..range.end.min(self.content().len()); let text_to_insert = self.layout_style.sanitize_content(new_text); - // Update cached UTF-16 length incrementally if available - if let Some(cached_len) = self.cached_utf16_len { - let removed_utf16_len: usize = self.content()[range.clone()] - .chars() - .map(|c| c.len_utf16()) - .sum(); - let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum(); - self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); - } - + self.update_utf16_len(range.clone(), &text_to_insert); self.replace_range(range.clone(), &text_to_insert); if !text_to_insert.is_empty() { diff --git a/crates/gpui_elements/src/input/unicode.rs b/crates/gpui_elements/src/input/unicode.rs index dc8c56b5e7..f632415883 100644 --- a/crates/gpui_elements/src/input/unicode.rs +++ b/crates/gpui_elements/src/input/unicode.rs @@ -67,19 +67,3 @@ pub trait UnicodeString { self.utf_offset_16to8(range_utf16.start)..self.utf_offset_16to8(range_utf16.end) } } -impl UnicodeString for super::InputState { - fn len_utf16_cached(&self) -> Option { - self.cached_utf16_len - } - - fn content_utf8(&self) -> &str { - self.content() - } - - fn len_utf16(&self) -> usize { - if let Some(len) = self.cached_utf16_len { - return len; - } - self.content_utf8().chars().map(|c| c.len_utf16()).sum() - } -} From 344804c232e6b5d8fac2d36e97af16eaabccfe7a Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 18:54:45 -0400 Subject: [PATCH 030/117] make more InputState properties private --- crates/gpui_elements/src/input/paint.rs | 23 ++++---- crates/gpui_elements/src/input/state.rs | 59 +++++++++++-------- .../src/input/state_input_handler.rs | 59 +++++++++---------- 3 files changed, 77 insertions(+), 64 deletions(-) diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index 6a86ae721a..28ca4846a2 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -191,7 +191,7 @@ struct InputStateSnapshot { marked_range: Option>, cursor_position: usize, logical_lines: Vec, - scroll_offset: Pixels, + scroll_distance: Pixels, line_height: Pixels, } impl InputStateSnapshot { @@ -201,7 +201,7 @@ impl InputStateSnapshot { let marked_range = input_state.marked_range().cloned(); let cursor_position = input_state.cursor_position(); let logical_lines = input_state.logical_lines.clone(); - let scroll_offset = input_state.scroll_offset; + let scroll_distance = input_state.distance_from_top(); let line_height = input_state.line_height(); let layout_axis = input_state.layout_style().axis(); let should_center_placeholder = matches!( @@ -216,7 +216,7 @@ impl InputStateSnapshot { marked_range, cursor_position, logical_lines, - scroll_offset, + scroll_distance, line_height, } } @@ -241,6 +241,7 @@ impl<'app> PaintContext<'app> { ) { let axis = self.snapshot.layout_axis; let bounds = self.bounds; + let scroll_distance = self.snapshot.scroll_distance; window.on_mouse_event({ let input = entity.clone(); move |event: &MouseDownEvent, phase, window, cx| { @@ -257,7 +258,7 @@ impl<'app> PaintContext<'app> { input.update(cx, |input, cx| { // Converts a screen position to a position relative to the text area origin, adjusted for scroll offset. let text_position = (event.position - bounds.origin) - .apply_along(axis, |pos| pos + input.scroll_offset); + .apply_along(axis, |pos| pos + scroll_distance); input.on_mouse_down( text_position, event.click_count, @@ -293,7 +294,7 @@ impl<'app> PaintContext<'app> { input.update(cx, |input, cx| { // Converts a screen position to a position relative to the text area origin, adjusted for scroll offset. let text_position = (event.position - bounds.origin) - .apply_along(axis, |pos| pos + input.scroll_offset); + .apply_along(axis, |pos| pos + scroll_distance); input.on_mouse_move(text_position, cx); }); } @@ -330,7 +331,7 @@ impl<'app> PaintContext<'app> { } } }; - input.scroll_offset = (input.scroll_offset - delta).clamp(px(0.), max_scroll); + input.apply_scroll_delta(delta, max_scroll); cx.notify(); }); } @@ -372,7 +373,7 @@ impl<'app> PaintContext<'app> { fn paint_selection(&self, window: &mut Window) { let one_line = self.snapshot.logical_lines.len() == 1; for line in &self.snapshot.logical_lines { - let line_y = line.y_offset - self.snapshot.scroll_offset; + let line_y = line.y_offset - self.snapshot.scroll_distance; if !one_line { if !self.is_line_visible(line) { @@ -442,7 +443,7 @@ impl<'app> PaintContext<'app> { fn paint_text(&self, window: &mut Window, cx: &mut App) { for line_layout in &self.snapshot.logical_lines { - let line_y = line_layout.y_offset - self.snapshot.scroll_offset; + let line_y = line_layout.y_offset - self.snapshot.scroll_distance; if !self.is_line_visible(line_layout) { continue; @@ -499,7 +500,7 @@ impl<'app> PaintContext<'app> { fn find_cursor_position_in_layouts(&self) -> Point { for line in &self.snapshot.logical_lines { - let line_y = line.y_offset - self.snapshot.scroll_offset; + let line_y = line.y_offset - self.snapshot.scroll_distance; if !self.is_line_visible(line) { continue; @@ -544,7 +545,7 @@ impl<'app> PaintContext<'app> { } fn is_line_visible(&self, line: &InputLogicalLine) -> bool { - let line_y = line.y_offset - self.snapshot.scroll_offset; + let line_y = line.y_offset - self.snapshot.scroll_distance; let line_bottom = line_y + self.snapshot.line_height * line.visual_line_count as f32; line_bottom >= px(0.) && line_y <= self.bounds.size.height } @@ -565,7 +566,7 @@ impl<'app> PaintContext<'app> { return; }; - let line_y = line.y_offset - self.snapshot.scroll_offset; + let line_y = line.y_offset - self.snapshot.scroll_distance; let line_start = line.text_range.start; let line_end = line.text_range.end; diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index d94b38c3b1..d3346d4da4 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -44,15 +44,15 @@ pub struct InputState { cached_utf16_len: Option, /// The style of layout (single or multiline). - pub(super) layout_style: InputLayoutStyle, + layout_style: InputLayoutStyle, /// The utf-8 character range that is currently selected by the user. /// NOTE: because each input has its own selection state, its trivial for users to have multiple selections active across multiple inputs at the same time. /// This could be considered undesirable behavior, and doing so would prompt the question of should there be a mechanism to clear selection when focus is lost. - pub(super) selected_range: Range, + selected_range: Range, /// The direction of the selection_range. Forward means providing in iteration order along `content`. Back means reverse iteration order. - pub(super) selection_direction: NavigationDirection, + selection_direction: NavigationDirection, /// The utf-8 character range of `content` that is currently marked/highlighted. - pub(super) marked_range: Option>, + marked_range: Option>, // refreshed each update by the element, for conveinent access in mutations and painting pub(super) layout_data: InputLayoutData, @@ -68,7 +68,7 @@ pub struct InputState { /// The number of times the user has clicked `last_click_position`. Used to determine which click behavior to trigger, depending on single, double, or triple clicks. click_count: usize, /// The distance in pixels from the start of the text a user has scrolled along the layout_style axis (singleline is horizontal, multiline is vertical). - pub(super) scroll_offset: Pixels, + scroll_distance: Pixels, /// The maximum duration between changes to `content` that can be grouped together as a single entry in the history log. history_grouping_interval: Duration, @@ -163,7 +163,7 @@ impl InputState { is_selecting: false, last_click_position: None, click_count: 0, - scroll_offset: px(0.), + scroll_distance: px(0.), history_grouping_interval: super::DEFAULT_GROUP_INTERVAL, history_undo_stack: Vec::new(), @@ -232,6 +232,10 @@ impl InputState { &self.selected_range } + pub fn selection_direction(&self) -> NavigationDirection { + self.selection_direction + } + /// Sets the selection range directly. pub fn set_selected_range(&mut self, range: Range) { let range = range.start.min(self.content.len())..range.end.min(self.content.len()); @@ -254,7 +258,7 @@ impl InputState { /// Returns true if the scroll position is at the top. pub fn at_top(&self) -> bool { - self.scroll_offset <= px(0.) + self.scroll_distance <= px(0.) } /// Returns true if the scroll position is at the bottom. @@ -266,7 +270,7 @@ impl InputState { return true; } - self.scroll_offset + visible_height >= content_height + self.scroll_distance + visible_height >= content_height } /// Returns the scroll progress as a value from 0.0 (top) to 1.0 (bottom). @@ -279,12 +283,12 @@ impl InputState { return 0.0; } - (self.scroll_offset / max_scroll).clamp(0.0, 1.0) + (self.scroll_distance / max_scroll).clamp(0.0, 1.0) } /// Returns how far the content is scrolled from the top in pixels. pub fn distance_from_top(&self) -> Pixels { - self.scroll_offset.max(px(0.)) + self.scroll_distance.max(px(0.)) } /// Returns how far the content is from the bottom in pixels. @@ -297,7 +301,7 @@ impl InputState { return px(0.); } - (max_scroll - self.scroll_offset).max(px(0.)) + (max_scroll - self.scroll_distance).max(px(0.)) } /// Configures how long the input will wait between user-input changes to create new logs in the history for undo/redo. @@ -347,7 +351,7 @@ impl InputState { self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); } - self.replace_range(range.clone(), &text_to_insert); + self.replace_text_at_range(range.clone(), &text_to_insert); self.selected_range = range.start + text_to_insert.len()..range.start + text_to_insert.len(); @@ -834,8 +838,12 @@ impl InputState { self.layout_data.line_height } + pub(super) fn set_marked_range(&mut self, range: Option>) { + self.marked_range = range; + } + /// Replaces the provided utf-8 character range with the provided text - pub(super) fn replace_range(&mut self, range: Range, text: &str) { + pub(super) fn replace_text_at_range(&mut self, range: Range, text: &str) { self.content.replace_range(range, &text); } @@ -944,6 +952,10 @@ impl InputState { self.content.len() } + pub(super) fn apply_scroll_delta(&mut self, delta: Pixels, max: Pixels) { + self.scroll_distance = (self.scroll_distance - delta).clamp(px(0.), max); + } + pub(super) fn scroll_to_cursor(&mut self) { if self.logical_lines.is_empty() { return; @@ -971,19 +983,20 @@ impl InputState { px(0.) }; - let visible_left = self.scroll_offset; - let visible_right = self.scroll_offset + self.layout_data.available_size.width; + let visible_left = self.scroll_distance; + let visible_right = self.scroll_distance + self.layout_data.available_size.width; // Add some padding so cursor isn't right at the edge let padding = px(2.0); if cursor_x < visible_left + padding { - self.scroll_offset = (cursor_x - padding).max(px(0.)); + self.scroll_distance = (cursor_x - padding).max(px(0.)); } else if cursor_x > visible_right - padding { - self.scroll_offset = cursor_x - self.layout_data.available_size.width + padding; + self.scroll_distance = + cursor_x - self.layout_data.available_size.width + padding; } - self.scroll_offset = self.scroll_offset.max(px(0.)); + self.scroll_distance = self.scroll_distance.max(px(0.)); } InputLayoutStyle::MultiLine => { if self.layout_data.available_size.height <= px(0.) { @@ -1015,18 +1028,18 @@ impl InputState { line.y_offset }; - let visible_top = self.scroll_offset; + let visible_top = self.scroll_distance; let visible_bottom = - self.scroll_offset + self.layout_data.available_size.height; + self.scroll_distance + self.layout_data.available_size.height; if cursor_visual_y < visible_top { - self.scroll_offset = cursor_visual_y; + self.scroll_distance = cursor_visual_y; } else if cursor_visual_y + line_height > visible_bottom { - self.scroll_offset = (cursor_visual_y + line_height) + self.scroll_distance = (cursor_visual_y + line_height) - self.layout_data.available_size.height; } - self.scroll_offset = self.scroll_offset.max(px(0.)); + self.scroll_distance = self.scroll_distance.max(px(0.)); break; } } diff --git a/crates/gpui_elements/src/input/state_input_handler.rs b/crates/gpui_elements/src/input/state_input_handler.rs index 0af1b83792..855772916f 100644 --- a/crates/gpui_elements/src/input/state_input_handler.rs +++ b/crates/gpui_elements/src/input/state_input_handler.rs @@ -28,8 +28,8 @@ impl EntityInputHandler for super::InputState { _cx: &mut Context, ) -> Option { Some(UTF16Selection { - range: self.utf_range_8to16(&self.selected_range), - reversed: self.selection_direction == NavigationDirection::Back, + range: self.utf_range_8to16(self.selected_range()), + reversed: self.selection_direction() == NavigationDirection::Back, }) } @@ -38,13 +38,13 @@ impl EntityInputHandler for super::InputState { _window: &mut Window, _cx: &mut Context, ) -> Option> { - self.marked_range + self.marked_range() .as_ref() .map(|range| self.utf_range_8to16(range)) } fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { - self.marked_range = None; + self.set_marked_range(None); } fn replace_text_in_range( @@ -57,21 +57,21 @@ impl EntityInputHandler for super::InputState { let range = range_utf16 .as_ref() .map(|range_utf16| self.utf_range_16to8(range_utf16)) - .or(self.marked_range.clone()) - .unwrap_or(self.selected_range.clone()); + .or(self.marked_range().cloned()) + .unwrap_or(self.selected_range().clone()); let range = range.start.min(self.content().len())..range.end.min(self.content().len()); - let text_to_insert = self.layout_style.sanitize_content(new_text); + let text_to_insert = self.layout_style().sanitize_content(new_text); // Record patch for undo before modifying content self.push_undo_patch(range.clone(), text_to_insert.len()); - self.push_undo_patch(range.clone(), text_to_insert.len()); - self.update_utf16_len(range.clone(), &text_to_insert); - self.replace_range(range.clone(), &text_to_insert); - self.selected_range = - range.start + text_to_insert.len()..range.start + text_to_insert.len(); - self.marked_range.take(); + self.update_utf16_len(range.clone(), &text_to_insert); + self.replace_text_at_range(range.clone(), &text_to_insert); + self.set_selected_range( + range.start + text_to_insert.len()..range.start + text_to_insert.len(), + ); + self.set_marked_range(None); self.layout_data.dirty = true; self.pause_cursor_blink(cx); @@ -90,28 +90,27 @@ impl EntityInputHandler for super::InputState { let range = range_utf16 .as_ref() .map(|range_utf16| self.utf_range_16to8(range_utf16)) - .or(self.marked_range.clone()) - .unwrap_or(self.selected_range.clone()); + .or(self.marked_range().cloned()) + .unwrap_or(self.selected_range().clone()); let range = range.start.min(self.content().len())..range.end.min(self.content().len()); - let text_to_insert = self.layout_style.sanitize_content(new_text); + let text_to_insert = self.layout_style().sanitize_content(new_text); self.update_utf16_len(range.clone(), &text_to_insert); - self.replace_range(range.clone(), &text_to_insert); - - if !text_to_insert.is_empty() { - self.marked_range = Some(range.start..range.start + text_to_insert.len()); - } else { - self.marked_range = None; - } - - self.selected_range = new_selected_range_utf16 - .as_ref() - .map(|range_utf16| self.utf_range_16to8(range_utf16)) - .map(|new_range| new_range.start + range.start..new_range.end + range.start) - .unwrap_or_else(|| { + self.replace_text_at_range(range.clone(), &text_to_insert); + self.set_marked_range(match text_to_insert.is_empty() { + true => None, + false => Some(range.start..range.start + text_to_insert.len()), + }); + self.set_selected_range({ + let new_range = new_selected_range_utf16.as_ref(); + let new_range = new_range.map(|range_utf16| self.utf_range_16to8(range_utf16)); + let new_range = new_range + .map(|new_range| new_range.start + range.start..new_range.end + range.start); + new_range.unwrap_or_else(|| { range.start + text_to_insert.len()..range.start + text_to_insert.len() - }); + }) + }); self.layout_data.dirty = true; cx.emit(InputStateEvent::TextChanged); From 2a53c80ab7157c390ca03d7862be694547dcd017 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 19:10:26 -0400 Subject: [PATCH 031/117] move layout update into InputState --- crates/gpui_elements/src/input/paint.rs | 10 +--------- crates/gpui_elements/src/input/state.rs | 14 +++++++++++++- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index 28ca4846a2..e9f0c09523 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -89,9 +89,6 @@ impl Element for Input { }; self.input.update(cx, |input, _cx| { - let dirty = input.layout_data.dirty - || input.layout_data.wrap_width != wrap_width - || input.layout_data.text_style != layout_state.text_style; let layout_data = InputLayoutData { text_style: layout_state.text_style.clone(), line_height, @@ -99,12 +96,7 @@ impl Element for Input { available_size: bounds.size, dirty: false, }; - input.layout_data = layout_data; - if dirty { - input.logical_lines = - InputState::build_logical_lines(input.content(), window, &input.layout_data); - input.scroll_to_cursor(); - } + input.apply_layout_update(layout_data, window); }); let hitbox = self.interactivity.prepaint( diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index d3346d4da4..84f9481ce7 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -1047,10 +1047,22 @@ impl InputState { } } + pub(super) fn apply_layout_update(&mut self, layout_data: InputLayoutData, window: &Window) { + let dirty = self.layout_data.dirty + || self.layout_data.wrap_width != layout_data.wrap_width + || self.layout_data.text_style != layout_data.text_style; + self.layout_data = layout_data; + if dirty { + self.logical_lines = + InputState::build_logical_lines(&self.content, window, &self.layout_data); + self.scroll_to_cursor(); + } + } + /// Called internally during window prepaint to layout the content into logical lines based on viewport bounds wrapping. pub(super) fn build_logical_lines( content: &str, - window: &mut Window, + window: &Window, layout_data: &InputLayoutData, ) -> Vec { let text_style = &layout_data.text_style; From e68eef433b75dd6b5b016d17737226a0411a2f73 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 19:14:58 -0400 Subject: [PATCH 032/117] make layout information private --- crates/gpui_elements/src/input/paint.rs | 4 +-- crates/gpui_elements/src/input/state.rs | 34 +++++++++++++------ .../src/input/state_input_handler.rs | 6 ++-- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index e9f0c09523..3d706fce83 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -192,7 +192,7 @@ impl InputStateSnapshot { let selected_range = input_state.selected_range().clone(); let marked_range = input_state.marked_range().cloned(); let cursor_position = input_state.cursor_position(); - let logical_lines = input_state.logical_lines.clone(); + let logical_lines = input_state.lines().clone(); let scroll_distance = input_state.distance_from_top(); let line_height = input_state.line_height(); let layout_axis = input_state.layout_style().axis(); @@ -296,7 +296,7 @@ impl<'app> PaintContext<'app> { let content_size = match axis { gpui::Axis::Horizontal => { let state = input.read(cx); - let line = state.logical_lines.first(); + let line = state.lines().first(); let line = line.and_then(|l| l.wrapped_line.as_ref()); line.map(|w| w.width()).unwrap_or(px(0.)) } diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index 84f9481ce7..fd0bbc5392 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -55,9 +55,9 @@ pub struct InputState { marked_range: Option>, // refreshed each update by the element, for conveinent access in mutations and painting - pub(super) layout_data: InputLayoutData, + layout_data: InputLayoutData, /// A reinterpretation of `content` as wrapped lines with layout information. Regenerated when content changes or the layout changes during element painting. - pub(super) logical_lines: Vec, + logical_lines: Vec, /// Tracks whether we were focused on the last update. was_focused: bool, @@ -204,13 +204,13 @@ impl InputState { pub fn set_content(&mut self, content: impl AsRef, cx: &mut Context) { let content = self.layout_style.sanitize_content(content.as_ref()); self.content = content.to_string().into(); + self.cached_utf16_len = None; self.selected_range = 0..0; self.selection_direction = NavigationDirection::Forward; self.marked_range = None; - self.layout_data.dirty = true; self.history_undo_stack.clear(); self.history_redo_stack.clear(); - self.cached_utf16_len = None; + self.mark_layout_dirty(); self.pause_cursor_blink(cx); cx.emit(InputStateEvent::TextChanged); cx.notify(); @@ -356,7 +356,8 @@ impl InputState { self.selected_range = range.start + text_to_insert.len()..range.start + text_to_insert.len(); self.marked_range.take(); - self.layout_data.dirty = true; + self.mark_layout_dirty(); + self.pause_cursor_blink(cx); cx.emit(InputStateEvent::TextChanged); cx.notify(); @@ -379,10 +380,11 @@ impl InputState { let redo_entry = entry.apply_undo(&mut self.content); self.history_redo_stack.push(redo_entry); + self.cached_utf16_len = None; self.selected_range = selected_range; self.selection_direction = selection_direction; - self.layout_data.dirty = true; - self.cached_utf16_len = None; + self.mark_layout_dirty(); + self.scroll_to_cursor(); cx.emit(InputStateEvent::Undo); cx.notify(); @@ -399,8 +401,10 @@ impl InputState { self.selection_direction = NavigationDirection::Forward; self.history_undo_stack.push(undo_entry); - self.layout_data.dirty = true; + self.cached_utf16_len = None; + self.mark_layout_dirty(); + self.scroll_to_cursor(); cx.emit(InputStateEvent::Redo); cx.notify(); @@ -423,8 +427,9 @@ impl InputState { // Restore selection state self.selected_range = selected_range; self.selection_direction = selection_direction; - self.layout_data.dirty = true; self.cached_utf16_len = None; + self.mark_layout_dirty(); + self.scroll_to_cursor(); cx.emit(InputStateEvent::Undo); cx.notify(); @@ -443,8 +448,9 @@ impl InputState { self.selection_direction = NavigationDirection::Forward; self.history_undo_stack.push(undo_entry); - self.layout_data.dirty = true; self.cached_utf16_len = None; + self.mark_layout_dirty(); + self.scroll_to_cursor(); cx.emit(InputStateEvent::Redo); cx.notify(); @@ -838,6 +844,10 @@ impl InputState { self.layout_data.line_height } + pub(super) fn lines(&self) -> &Vec { + &self.logical_lines + } + pub(super) fn set_marked_range(&mut self, range: Option>) { self.marked_range = range; } @@ -1047,6 +1057,10 @@ impl InputState { } } + pub(super) fn mark_layout_dirty(&mut self) { + self.layout_data.dirty = true; + } + pub(super) fn apply_layout_update(&mut self, layout_data: InputLayoutData, window: &Window) { let dirty = self.layout_data.dirty || self.layout_data.wrap_width != layout_data.wrap_width diff --git a/crates/gpui_elements/src/input/state_input_handler.rs b/crates/gpui_elements/src/input/state_input_handler.rs index 855772916f..b181b78f88 100644 --- a/crates/gpui_elements/src/input/state_input_handler.rs +++ b/crates/gpui_elements/src/input/state_input_handler.rs @@ -72,7 +72,7 @@ impl EntityInputHandler for super::InputState { range.start + text_to_insert.len()..range.start + text_to_insert.len(), ); self.set_marked_range(None); - self.layout_data.dirty = true; + self.mark_layout_dirty(); self.pause_cursor_blink(cx); cx.emit(InputStateEvent::TextChanged); @@ -111,8 +111,8 @@ impl EntityInputHandler for super::InputState { range.start + text_to_insert.len()..range.start + text_to_insert.len() }) }); + self.mark_layout_dirty(); - self.layout_data.dirty = true; cx.emit(InputStateEvent::TextChanged); cx.notify(); } @@ -126,7 +126,7 @@ impl EntityInputHandler for super::InputState { ) -> Option> { let range = self.utf_range_16to8(&range_utf16); - for line in &self.logical_lines { + for line in self.lines() { if line.text_range.is_empty() { if range.start == line.text_range.start { return Some(Bounds::from_corners( From 75a2fcb25c897d05e410033fc96d1c9834453a9e Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 19:20:47 -0400 Subject: [PATCH 033/117] add documentation to input colors --- crates/gpui_elements/src/input/colors.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/gpui_elements/src/input/colors.rs b/crates/gpui_elements/src/input/colors.rs index 210db77453..f60f765280 100644 --- a/crates/gpui_elements/src/input/colors.rs +++ b/crates/gpui_elements/src/input/colors.rs @@ -1,9 +1,13 @@ use gpui::Hsla; +/// Style colors applied to the Input element #[derive(Clone, Copy, Debug)] pub struct InputColors { + /// This is the background color applied to the range of text that is currently selected by the user. pub selection: Hsla, + /// This is the color of the user's text cursor. pub cursor: Hsla, + /// This is the color of the placeholder string, when one is assigned and the text field is empty. pub placeholder: Hsla, pub marked: Hsla, } From 08043295bd6fab0530702bdb99325790007cd85e Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 6 Jun 2026 19:30:15 -0400 Subject: [PATCH 034/117] refine cursor-blink documentation --- crates/gpui_elements/src/input/cursor.rs | 34 +++++++++--------------- crates/gpui_elements/src/input/state.rs | 4 +-- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/crates/gpui_elements/src/input/cursor.rs b/crates/gpui_elements/src/input/cursor.rs index 359bf76b5b..cdf58be6ea 100644 --- a/crates/gpui_elements/src/input/cursor.rs +++ b/crates/gpui_elements/src/input/cursor.rs @@ -17,10 +17,8 @@ pub enum CursorBlinkType<'app> { }, } -/// Manages the blinking state of a text cursor. -/// -/// The cursor blinks at a configurable interval when enabled. Blinking can be -/// temporarily paused (e.g., during typing) to provide immediate visual feedback. +/// The state of an input's cursor blinking. While active, the cursor's visibility changes at some interval. +/// This blinking can be temporarily paused (e.g. during typing). pub(super) struct CursorBlink { interval: Duration, generation: usize, @@ -30,10 +28,9 @@ pub(super) struct CursorBlink { } impl CursorBlink { - /// Creates a new cursor blink manager with the given interval. - /// - /// The cursor starts in a disabled state with visibility set to true. - pub fn new(interval: Duration, _cx: &mut Context) -> Self { + /// Initializes the cursor blinking with the cursor already being visible. + #[track_caller] + pub fn new(interval: Duration) -> Self { Self { interval, generation: 0, @@ -50,8 +47,7 @@ impl CursorBlink { /// Activates cursor blinking. /// - /// When activated, the cursor will alternate between visible and hidden - /// states at the configured interval. Has no effect if already active. + /// While active, the cursor will alternate between visible and hidden states at the configured interval. Has no effect if already active. pub fn enable(&mut self, cx: &mut Context) { if self.active { return; @@ -60,14 +56,13 @@ impl CursorBlink { self.active = true; self.visible = false; self.paused = false; - self.tick(cx); + self.spawn_ticker(cx); } /// Deactivates cursor blinking. /// - /// The cursor visibility is set to false when disabled. Call - /// `pause_blinking` instead if you want to temporarily stop blinking - /// while keeping the cursor visible. + /// Marks the cursor as invisible and pauses blinking indefinitely. `enable` must be called explicitly to resume visibility and blinking. + /// Call `pause_blinking` instead if you want to temporarily stop blinking while keeping the cursor visible. pub fn disable(&mut self, cx: &mut Context) { self.active = false; self.visible = false; @@ -75,10 +70,7 @@ impl CursorBlink { cx.notify(); } - /// Temporarily pauses blinking and shows the cursor. - /// - /// This is useful during user input to provide immediate feedback. - /// Blinking resumes automatically after the blink interval elapses. + /// Temporarily pauses blinking and leaves the cursor visible. Blinking will resume after the pre-established interval elapses from the time this is called. pub fn pause_blinking(&mut self, cx: &mut Context) { if !self.visible { self.visible = true; @@ -96,14 +88,14 @@ impl CursorBlink { this.update(cx, |this, cx| { if this.generation == generation { this.paused = false; - this.tick(cx); + this.spawn_ticker(cx); } }) }) .detach(); } - fn tick(&mut self, cx: &mut Context) { + fn spawn_ticker(&mut self, cx: &mut Context) { if !self.active || self.paused { return; } @@ -120,7 +112,7 @@ impl CursorBlink { if let Some(this) = this.upgrade() { this.update(cx, |this, cx| { if this.generation == generation { - this.tick(cx); + this.spawn_ticker(cx); } }); } diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index fd0bbc5392..eb9222bcb3 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -185,7 +185,7 @@ impl InputState { CursorBlinkType::Disabled => None, CursorBlinkType::Enabled { app: cx, interval } => { let interval = interval.unwrap_or(super::DEFAULT_BLINK_INTERVAL); - let cursor_blink = cx.new(|cx| super::CursorBlink::new(interval, cx)); + let cursor_blink = cx.new(|_cx| super::CursorBlink::new(interval)); let entity_id = self.entity_id; let subscription = cx.observe(&cursor_blink, move |_, cx| cx.notify(entity_id)); Some((cursor_blink, subscription)) @@ -867,7 +867,7 @@ impl InputState { self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); } - /// Pauses cursor blinking temporarily (e.g., during typing). + /// Temporarily pauses blinking and leaves the cursor visible. Blinking will resume after the pre-established interval elapses from the time this is called. pub(super) fn pause_cursor_blink(&self, cx: &mut Context) { if let Some((cursor_blink, _)) = &self.cursor_blink { cursor_blink.update(cx, |cb, cx| cb.pause_blinking(cx)); From 7affa0393f54b0c5b6668be1f46301034bda17fc Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 13 Jun 2026 18:30:01 -0400 Subject: [PATCH 035/117] explore storage backend abstraction --- crates/gpui_elements/src/input.rs | 2 + crates/gpui_elements/src/input/history.rs | 8 +- crates/gpui_elements/src/input/paint.rs | 2 +- crates/gpui_elements/src/input/state.rs | 258 ++++++++---------- .../src/input/state_input_handler.rs | 22 +- crates/gpui_elements/src/input/storage.rs | 77 ++++++ crates/gpui_elements/src/input/unicode.rs | 2 + 7 files changed, 212 insertions(+), 159 deletions(-) create mode 100644 crates/gpui_elements/src/input/storage.rs diff --git a/crates/gpui_elements/src/input.rs b/crates/gpui_elements/src/input.rs index ae67ff0e12..a0d130d737 100644 --- a/crates/gpui_elements/src/input.rs +++ b/crates/gpui_elements/src/input.rs @@ -7,6 +7,7 @@ mod layout; mod paint; mod state; mod state_input_handler; +mod storage; pub(self) mod unicode; pub use colors::*; @@ -15,3 +16,4 @@ pub use element::*; pub(self) use history::*; pub use layout::*; pub use state::*; +pub use storage::*; diff --git a/crates/gpui_elements/src/input/history.rs b/crates/gpui_elements/src/input/history.rs index 4e2f36f2a8..7f8b44ad9c 100644 --- a/crates/gpui_elements/src/input/history.rs +++ b/crates/gpui_elements/src/input/history.rs @@ -4,6 +4,8 @@ use std::{ time::{Duration, Instant}, }; +use crate::input::InputStorage; + /// Maximum number of history entries to keep. pub const MAX_HISTORY_LEN: usize = 1000; @@ -30,12 +32,12 @@ pub struct HistoryEntry { impl HistoryEntry { /// Apply this patch to undo an edit, returning the reverse patch for redo. - pub fn apply_undo(&self, content: &mut String) -> HistoryEntry { + pub fn apply_undo(&self, content: &mut Box) -> HistoryEntry { let undo_start = self.range.start; let undo_end = (self.range.start + self.new_text_len).min(content.len()); // Capture what we're about to remove (the "new" text that was inserted) - let removed_text = content[undo_start..undo_end].to_string(); + let removed_text = content.as_str()[undo_start..undo_end].to_string(); // Replace with the old text content.replace_range(undo_start..undo_end, &self.old_text); @@ -52,7 +54,7 @@ impl HistoryEntry { } /// Apply this patch to redo an edit, returning the reverse patch for undo. - pub fn apply_redo(&self, content: &mut String) -> HistoryEntry { + pub fn apply_redo(&self, content: &mut Box) -> HistoryEntry { // Redo is the same operation as undo - we're reversing the undo self.apply_undo(content) } diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index 3d706fce83..f93f1cee5d 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -203,7 +203,7 @@ impl InputStateSnapshot { Self { layout_axis, should_center_placeholder, - show_placeholder: input_state.content().is_empty(), + show_placeholder: input_state.content().as_str().is_empty(), selected_range, marked_range, cursor_position, diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index eb9222bcb3..8c53f6b386 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -1,5 +1,5 @@ use super::actions::*; -use crate::input::{CursorBlinkType, InputLayoutStyle}; +use crate::input::{CursorBlinkType, InputLayoutStyle, InputStorage}; use gpui::{ App, AppContext, ClipboardItem, Context, Entity, EntityId, EntityInputHandler, EventEmitter, FocusHandle, Focusable, NavigationDirection, Pixels, Point, SharedString, Size, Subscription, @@ -39,9 +39,7 @@ pub struct InputState { entity_id: EntityId, focus_handle: FocusHandle, /// The true internal text - content: String, - /// Cached UTF-16 length of content for faster IME operations. Lazily computed when queried. - cached_utf16_len: Option, + content: Box, /// The style of layout (single or multiline). layout_style: InputLayoutStyle, @@ -123,23 +121,6 @@ impl Focusable for InputState { } } -impl super::unicode::UnicodeString for InputState { - fn len_utf16_cached(&self) -> Option { - self.cached_utf16_len - } - - fn content_utf8(&self) -> &str { - &self.content - } - - fn len_utf16(&self) -> usize { - if let Some(len) = self.cached_utf16_len { - return len; - } - self.content.chars().map(|c| c.len_utf16()).sum() - } -} - // External API impl InputState { /// Creates a new `Input` with the specified multiline setting. @@ -148,8 +129,7 @@ impl InputState { let mut this = Self { entity_id: cx.entity_id(), focus_handle: cx.focus_handle(), - content: String::default(), - cached_utf16_len: None, + content: Box::new(super::Standard::default()), layout_style: InputLayoutStyle::SingleLine, selected_range: 0..0, @@ -195,16 +175,15 @@ impl InputState { } /// Returns the current text content. - pub fn content(&self) -> &String { - &self.content + pub fn content(&self) -> &dyn InputStorage { + self.content.as_ref() } /// Sets the text content, resetting selection to the beginning. /// This clears the undo/redo history. pub fn set_content(&mut self, content: impl AsRef, cx: &mut Context) { let content = self.layout_style.sanitize_content(content.as_ref()); - self.content = content.to_string().into(); - self.cached_utf16_len = None; + self.content.emplace(content.as_ref()); self.selected_range = 0..0; self.selection_direction = NavigationDirection::Forward; self.marked_range = None; @@ -342,14 +321,8 @@ impl InputState { self.push_undo_patch(range.clone(), text_to_insert.len()); // Update cached UTF-16 length incrementally if available - if let Some(cached_len) = self.cached_utf16_len { - let removed_utf16_len: usize = self.content[range.clone()] - .chars() - .map(|c| c.len_utf16()) - .sum(); - let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum(); - self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); - } + self.content + .update_utf8(range.clone(), text_to_insert.as_ref()); self.replace_text_at_range(range.clone(), &text_to_insert); @@ -380,7 +353,7 @@ impl InputState { let redo_entry = entry.apply_undo(&mut self.content); self.history_redo_stack.push(redo_entry); - self.cached_utf16_len = None; + self.content.clear_utf16_cache(); self.selected_range = selected_range; self.selection_direction = selection_direction; self.mark_layout_dirty(); @@ -402,7 +375,7 @@ impl InputState { self.history_undo_stack.push(undo_entry); - self.cached_utf16_len = None; + self.content.clear_utf16_cache(); self.mark_layout_dirty(); self.scroll_to_cursor(); @@ -427,7 +400,7 @@ impl InputState { // Restore selection state self.selected_range = selected_range; self.selection_direction = selection_direction; - self.cached_utf16_len = None; + self.content.clear_utf16_cache(); self.mark_layout_dirty(); self.scroll_to_cursor(); @@ -448,7 +421,7 @@ impl InputState { self.selection_direction = NavigationDirection::Forward; self.history_undo_stack.push(undo_entry); - self.cached_utf16_len = None; + self.content.clear_utf16_cache(); self.mark_layout_dirty(); self.scroll_to_cursor(); @@ -726,18 +699,16 @@ impl InputState { pub(super) fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { if !self.selected_range.is_empty() { - cx.write_to_clipboard(ClipboardItem::new_string( - self.content[self.selected_range.clone()].to_string(), - )); + let slice = &self.content.as_str()[self.selected_range.clone()]; + cx.write_to_clipboard(ClipboardItem::new_string(slice.to_string())); } } pub(super) fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { if !self.selected_range.is_empty() { // Cut selected text - cx.write_to_clipboard(ClipboardItem::new_string( - self.content[self.selected_range.clone()].to_string(), - )); + let slice = &self.content.as_str()[self.selected_range.clone()]; + cx.write_to_clipboard(ClipboardItem::new_string(slice.to_string())); self.replace_text_in_range(None, "", window, cx); } else { // No selection: cut the entire current line (including newline) @@ -762,10 +733,11 @@ impl InputState { line_start }; - let line_text = self.content[cut_start..cut_end].to_string(); - cx.write_to_clipboard(ClipboardItem::new_string(line_text)); - self.selected_range = cut_start..cut_end; + + let slice = &self.content.as_str()[self.selected_range.clone()]; + cx.write_to_clipboard(ClipboardItem::new_string(slice.to_string())); + self.replace_text_in_range(None, "", window, cx); } } @@ -859,12 +831,7 @@ impl InputState { // Update cached UTF-16 length incrementally if available pub(super) fn update_utf16_len(&mut self, range: Range, text_to_insert: &str) { - let Some(cached_len) = self.cached_utf16_len else { - return; - }; - let removed_utf16_len: usize = self.content[range].chars().map(|c| c.len_utf16()).sum(); - let added_utf16_len: usize = text_to_insert.chars().map(|c| c.len_utf16()).sum(); - self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); + self.content.update_utf8(range, text_to_insert); } /// Temporarily pauses blinking and leaves the cursor visible. Blinking will resume after the pre-established interval elapses from the time this is called. @@ -894,7 +861,7 @@ impl InputState { } // Capture the text that will be replaced - let old_text = self.content[range.clone()].to_string(); + let old_text = self.content.as_str()[range.clone()].to_string(); self.history_undo_stack.push(super::HistoryEntry { range: range.start..range.start + new_text_len, @@ -916,7 +883,7 @@ impl InputState { /// Returns the utf-8 character position of first character after the first new-line preceeding the character at the provided utf-8 character position. pub(super) fn find_line_start(&self, position: usize) -> usize { - self.content[..position.min(self.content.len())] + self.content.as_str()[..position.min(self.content.len())] .rfind('\n') .map(|pos| pos + 1) .unwrap_or(0) @@ -924,7 +891,7 @@ impl InputState { /// Returns the utf-8 character position of the character immediately before the first new-line character after the character at the provided utf-8 character position. pub(super) fn find_line_end(&self, position: usize) -> usize { - self.content[position.min(self.content.len())..] + self.content.as_str()[position.min(self.content.len())..] .find('\n') .map(|pos| position + pos) .unwrap_or(self.content.len()) @@ -932,7 +899,7 @@ impl InputState { /// Returns the utf-8 character position of the start of the line that contains the provided pixel-point. pub(super) fn index_for_pixel_point(&self, point: Point) -> usize { - if self.content.is_empty() { + if self.content.as_str().is_empty() { return 0; } @@ -1068,7 +1035,7 @@ impl InputState { self.layout_data = layout_data; if dirty { self.logical_lines = - InputState::build_logical_lines(&self.content, window, &self.layout_data); + InputState::build_logical_lines(self.content.as_str(), window, &self.layout_data); self.scroll_to_cursor(); } } @@ -1313,7 +1280,7 @@ impl InputState { return 0; } - let text_before = &self.content[..offset.min(self.content.len())]; + let text_before = &self.content.as_str()[..offset.min(self.content.len())]; text_before .grapheme_indices(true) .map(|(i, _)| i) @@ -1326,7 +1293,7 @@ impl InputState { return self.content.len(); } - let text_after = &self.content[offset..]; + let text_after = &self.content.as_str()[offset..]; text_after .grapheme_indices(true) .nth(1) @@ -1339,7 +1306,7 @@ impl InputState { return 0; } - let text_before = &self.content[..offset.min(self.content.len())]; + let text_before = &self.content.as_str()[..offset.min(self.content.len())]; let mut last_word_start = 0; for (idx, _) in text_before.unicode_word_indices() { @@ -1366,7 +1333,7 @@ impl InputState { return self.content.len(); } - let text_after = &self.content[offset..]; + let text_after = &self.content.as_str()[offset..]; for (idx, word) in text_after.unicode_word_indices() { let word_end = offset + idx + word.len(); @@ -1381,7 +1348,7 @@ impl InputState { fn word_range_at(&self, offset: usize) -> (usize, usize) { let offset = offset.min(self.content.len()); - for (idx, word) in self.content.unicode_word_indices() { + for (idx, word) in self.content.as_str().unicode_word_indices() { let word_end = idx + word.len(); if offset >= idx && offset <= word_end { return (idx, word_end); @@ -1415,7 +1382,7 @@ mod tests { cx.add_window(|_window, cx| { let input = cx.new(|cx| { let mut input = InputState::new(cx).with_layout_style(InputLayoutStyle::MultiLine); - input.content = content.to_string().into(); + input.content.emplace(content); input.selected_range = range; input }); @@ -1432,12 +1399,15 @@ mod tests { let view = cx.add_window(|window, cx| { let input = cx.new(|cx| { let mut input = InputState::new(cx).with_layout_style(InputLayoutStyle::MultiLine); - input.content = content.to_string().into(); + input.content.emplace(content); input.selected_range = range; input.layout_data.line_height = px(20.); input.layout_data.wrap_width = Some(px(500.)); - input.logical_lines = - InputState::build_logical_lines(&input.content, window, &input.layout_data); + input.logical_lines = InputState::build_logical_lines( + input.content.as_str(), + window, + &input.layout_data, + ); input }); TestView { input } @@ -1755,7 +1725,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.backspace(&Backspace, window, cx); - assert_eq!(input.content(), "hello "); + assert_eq!(input.content().as_str(), "hello "); assert_eq!(input.selected_range, 6..6); }); }) @@ -1768,7 +1738,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.backspace(&Backspace, window, cx); - assert_eq!(input.content(), "hell"); + assert_eq!(input.content().as_str(), "hell"); assert_eq!(input.selected_range, 4..4); }); }) @@ -1781,7 +1751,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.backspace(&Backspace, window, cx); - assert_eq!(input.content(), "hello"); + assert_eq!(input.content().as_str(), "hello"); assert_eq!(input.selected_range, 0..0); }); }) @@ -1794,7 +1764,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.backspace(&Backspace, window, cx); - assert_eq!(input.content(), "Hi "); + assert_eq!(input.content().as_str(), "Hi "); assert_eq!(input.selected_range, 3..3); }); }) @@ -1811,7 +1781,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete(&Delete, window, cx); - assert_eq!(input.content(), " world"); + assert_eq!(input.content().as_str(), " world"); assert_eq!(input.selected_range, 0..0); }); }) @@ -1824,7 +1794,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete(&Delete, window, cx); - assert_eq!(input.content(), "ello"); + assert_eq!(input.content().as_str(), "ello"); assert_eq!(input.selected_range, 0..0); }); }) @@ -1837,7 +1807,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete(&Delete, window, cx); - assert_eq!(input.content(), "hello"); + assert_eq!(input.content().as_str(), "hello"); assert_eq!(input.selected_range, 5..5); }); }) @@ -1854,7 +1824,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.enter(&Enter, window, cx); - assert_eq!(input.content(), "hello\n world"); + assert_eq!(input.content().as_str(), "hello\n world"); assert_eq!(input.selected_range, 6..6); }); }) @@ -1867,7 +1837,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.enter(&Enter, window, cx); - assert_eq!(input.content(), "hello\nworld"); + assert_eq!(input.content().as_str(), "hello\nworld"); assert_eq!(input.selected_range, 6..6); }); }) @@ -1899,7 +1869,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.cut(&Cut, window, cx); - assert_eq!(input.content(), " world"); + assert_eq!(input.content().as_str(), " world"); assert_eq!(input.selected_range, 0..0); }); }) @@ -1916,7 +1886,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.paste(&Paste, window, cx); - assert_eq!(input.content(), "hello there world"); + assert_eq!(input.content().as_str(), "hello there world"); assert_eq!(input.selected_range, 11..11); }); }) @@ -2015,10 +1985,10 @@ mod tests { assert_eq!(input.selected_range, 0..0); input.backspace(&Backspace, window, cx); - assert_eq!(input.content(), ""); + assert_eq!(input.content().as_str(), ""); input.delete(&Delete, window, cx); - assert_eq!(input.content(), ""); + assert_eq!(input.content().as_str(), ""); input.select_all(&SelectAll, window, cx); assert_eq!(input.selected_range, 0..0); @@ -2035,7 +2005,7 @@ mod tests { input.selection_direction = NavigationDirection::Back; input.marked_range = Some(5..7); input.set_content("new content", cx); - assert_eq!(input.content(), "new content"); + assert_eq!(input.content().as_str(), "new content"); assert_eq!(input.selected_range, 0..0); assert_eq!(input.selection_direction, NavigationDirection::Forward); assert_eq!(input.marked_range, None); @@ -2183,7 +2153,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.backspace(&Backspace, window, cx); - assert_eq!(input.content(), "ab"); + assert_eq!(input.content().as_str(), "ab"); assert_eq!(input.selected_range.start, 1); }); }) @@ -2200,7 +2170,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.backspace(&Backspace, window, cx); - assert_eq!(input.content(), "ab"); + assert_eq!(input.content().as_str(), "ab"); assert_eq!(input.selected_range.start, 1); }); }) @@ -2213,7 +2183,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete(&Delete, window, cx); - assert_eq!(input.content(), "ab"); + assert_eq!(input.content().as_str(), "ab"); assert_eq!(input.selected_range.start, 1); }); }) @@ -2383,7 +2353,7 @@ mod tests { cx.add_window(|_window, cx| { let input = cx.new(|cx| { let mut input = InputState::new(cx).with_layout_style(InputLayoutStyle::SingleLine); - input.content = content.to_string().into(); + input.content.emplace(content); input.selected_range = selected_range; input }); @@ -2397,7 +2367,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.enter(&Enter, window, cx); - assert_eq!(input.content(), "hello"); + assert_eq!(input.content().as_str(), "hello"); assert_eq!(input.selected_range, 5..5); }); }) @@ -2410,7 +2380,7 @@ mod tests { view.update(cx, |view, _window, cx| { view.input.update(cx, |input, cx| { input.set_content("hello\nworld\r\nfoo", cx); - assert_eq!(input.content(), "hello world foo"); + assert_eq!(input.content().as_str(), "hello world foo"); }); }) .unwrap(); @@ -2500,11 +2470,11 @@ mod tests { // Make an edit input.replace_text_in_range(None, " world", window, cx); - assert_eq!(input.content(), "hello world"); + assert_eq!(input.content().as_str(), "hello world"); // Undo should restore original content input.undo(&Undo, window, cx); - assert_eq!(input.content(), "hello"); + assert_eq!(input.content().as_str(), "hello"); }); }) .unwrap(); @@ -2518,13 +2488,13 @@ mod tests { input.set_history_group_interval(Duration::from_secs(0)); input.replace_text_in_range(None, " world", window, cx); - assert_eq!(input.content(), "hello world"); + assert_eq!(input.content().as_str(), "hello world"); input.undo(&Undo, window, cx); - assert_eq!(input.content(), "hello"); + assert_eq!(input.content().as_str(), "hello"); input.redo(&Redo, window, cx); - assert_eq!(input.content(), "hello world"); + assert_eq!(input.content().as_str(), "hello world"); }); }) .unwrap(); @@ -2537,7 +2507,7 @@ mod tests { view.input.update(cx, |input, cx| { assert!(!input.is_undo_available()); input.undo(&Undo, window, cx); - assert_eq!(input.content(), "hello"); + assert_eq!(input.content().as_str(), "hello"); }); }) .unwrap(); @@ -2550,7 +2520,7 @@ mod tests { view.input.update(cx, |input, cx| { assert!(!input.is_redo_available()); input.redo(&Redo, window, cx); - assert_eq!(input.content(), "hello"); + assert_eq!(input.content().as_str(), "hello"); }); }) .unwrap(); @@ -2565,12 +2535,12 @@ mod tests { // Delete selection input.replace_text_in_range(None, "", window, cx); - assert_eq!(input.content(), " world"); + assert_eq!(input.content().as_str(), " world"); assert_eq!(input.selected_range, 0..0); // Undo should restore content and selection input.undo(&Undo, window, cx); - assert_eq!(input.content(), "hello world"); + assert_eq!(input.content().as_str(), "hello world"); assert_eq!(input.selected_range, 0..5); }); }) @@ -2587,25 +2557,25 @@ mod tests { input.replace_text_in_range(None, "a", window, cx); input.replace_text_in_range(None, "b", window, cx); input.replace_text_in_range(None, "c", window, cx); - assert_eq!(input.content(), "abc"); + assert_eq!(input.content().as_str(), "abc"); input.undo(&Undo, window, cx); - assert_eq!(input.content(), "ab"); + assert_eq!(input.content().as_str(), "ab"); input.undo(&Undo, window, cx); - assert_eq!(input.content(), "a"); + assert_eq!(input.content().as_str(), "a"); input.undo(&Undo, window, cx); - assert_eq!(input.content(), ""); + assert_eq!(input.content().as_str(), ""); input.redo(&Redo, window, cx); - assert_eq!(input.content(), "a"); + assert_eq!(input.content().as_str(), "a"); input.redo(&Redo, window, cx); - assert_eq!(input.content(), "ab"); + assert_eq!(input.content().as_str(), "ab"); input.redo(&Redo, window, cx); - assert_eq!(input.content(), "abc"); + assert_eq!(input.content().as_str(), "abc"); }); }) .unwrap(); @@ -2619,15 +2589,15 @@ mod tests { input.set_history_group_interval(Duration::from_secs(0)); input.replace_text_in_range(None, " world", window, cx); - assert_eq!(input.content(), "hello world"); + assert_eq!(input.content().as_str(), "hello world"); input.undo(&Undo, window, cx); - assert_eq!(input.content(), "hello"); + assert_eq!(input.content().as_str(), "hello"); assert!(input.is_redo_available()); // New edit should clear redo stack input.replace_text_in_range(None, "!", window, cx); - assert_eq!(input.content(), "hello!"); + assert_eq!(input.content().as_str(), "hello!"); assert!(!input.is_redo_available()); }); }) @@ -2686,10 +2656,10 @@ mod tests { input.set_history_group_interval(Duration::from_secs(0)); input.backspace(&Backspace, window, cx); - assert_eq!(input.content(), "hell"); + assert_eq!(input.content().as_str(), "hell"); input.undo(&Undo, window, cx); - assert_eq!(input.content(), "hello"); + assert_eq!(input.content().as_str(), "hello"); }); }) .unwrap(); @@ -2703,10 +2673,10 @@ mod tests { input.set_history_group_interval(Duration::from_secs(0)); input.delete(&Delete, window, cx); - assert_eq!(input.content(), "ello"); + assert_eq!(input.content().as_str(), "ello"); input.undo(&Undo, window, cx); - assert_eq!(input.content(), "hello"); + assert_eq!(input.content().as_str(), "hello"); }); }) .unwrap(); @@ -2720,10 +2690,10 @@ mod tests { input.set_history_group_interval(Duration::from_secs(0)); input.cut(&Cut, window, cx); - assert_eq!(input.content(), " world"); + assert_eq!(input.content().as_str(), " world"); input.undo(&Undo, window, cx); - assert_eq!(input.content(), "hello world"); + assert_eq!(input.content().as_str(), "hello world"); }); }) .unwrap(); @@ -2736,7 +2706,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.cut(&Cut, window, cx); - assert_eq!(input.content(), "line1\nline3"); + assert_eq!(input.content().as_str(), "line1\nline3"); }); }) .unwrap(); @@ -2752,7 +2722,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.cut(&Cut, window, cx); - assert_eq!(input.content(), "line2\nline3"); + assert_eq!(input.content().as_str(), "line2\nline3"); }); }) .unwrap(); @@ -2768,7 +2738,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.cut(&Cut, window, cx); - assert_eq!(input.content(), "line1\nline2"); + assert_eq!(input.content().as_str(), "line1\nline2"); }); }) .unwrap(); @@ -2784,7 +2754,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.cut(&Cut, window, cx); - assert_eq!(input.content(), "line1\nline3"); + assert_eq!(input.content().as_str(), "line1\nline3"); }); }) .unwrap(); @@ -2800,7 +2770,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.cut(&Cut, window, cx); - assert_eq!(input.content(), ""); + assert_eq!(input.content().as_str(), ""); }); }) .unwrap(); @@ -2817,10 +2787,10 @@ mod tests { input.set_history_group_interval(Duration::from_secs(0)); input.cut(&Cut, window, cx); - assert_eq!(input.content(), "line1\nline3"); + assert_eq!(input.content().as_str(), "line1\nline3"); input.undo(&Undo, window, cx); - assert_eq!(input.content(), "line1\nline2\nline3"); + assert_eq!(input.content().as_str(), "line1\nline2\nline3"); }); }) .unwrap(); @@ -2835,10 +2805,10 @@ mod tests { input.set_history_group_interval(Duration::from_secs(0)); input.paste(&Paste, window, cx); - assert_eq!(input.content(), "hello world"); + assert_eq!(input.content().as_str(), "hello world"); input.undo(&Undo, window, cx); - assert_eq!(input.content(), "hello"); + assert_eq!(input.content().as_str(), "hello"); }); }) .unwrap(); @@ -2852,10 +2822,10 @@ mod tests { input.set_history_group_interval(Duration::from_secs(0)); input.enter(&Enter, window, cx); - assert_eq!(input.content(), "hello\n world"); + assert_eq!(input.content().as_str(), "hello\n world"); input.undo(&Undo, window, cx); - assert_eq!(input.content(), "hello world"); + assert_eq!(input.content().as_str(), "hello world"); }); }) .unwrap(); @@ -2868,7 +2838,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_word_left(&DeleteWordLeft, window, cx); - assert_eq!(input.content(), " world"); + assert_eq!(input.content().as_str(), " world"); assert_eq!(input.selected_range, 0..0); }); }) @@ -2882,7 +2852,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_word_left(&DeleteWordLeft, window, cx); - assert_eq!(input.content(), " world"); + assert_eq!(input.content().as_str(), " world"); }); }) .unwrap(); @@ -2894,7 +2864,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_word_left(&DeleteWordLeft, window, cx); - assert_eq!(input.content(), "hello world"); + assert_eq!(input.content().as_str(), "hello world"); }); }) .unwrap(); @@ -2907,7 +2877,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_word_right(&DeleteWordRight, window, cx); - assert_eq!(input.content(), " world"); + assert_eq!(input.content().as_str(), " world"); assert_eq!(input.selected_range, 0..0); }); }) @@ -2920,7 +2890,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_word_right(&DeleteWordRight, window, cx); - assert_eq!(input.content(), " world"); + assert_eq!(input.content().as_str(), " world"); }); }) .unwrap(); @@ -2932,7 +2902,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_word_right(&DeleteWordRight, window, cx); - assert_eq!(input.content(), "hello world"); + assert_eq!(input.content().as_str(), "hello world"); }); }) .unwrap(); @@ -2944,7 +2914,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_to_beginning_of_line(&DeleteToBeginningOfLine, window, cx); - assert_eq!(input.content(), " world"); + assert_eq!(input.content().as_str(), " world"); assert_eq!(input.selected_range, 0..0); }); }) @@ -2958,7 +2928,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_to_beginning_of_line(&DeleteToBeginningOfLine, window, cx); - assert_eq!(input.content(), "line1\nne2\nline3"); + assert_eq!(input.content().as_str(), "line1\nne2\nline3"); }); }) .unwrap(); @@ -2970,7 +2940,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_to_beginning_of_line(&DeleteToBeginningOfLine, window, cx); - assert_eq!(input.content(), "hello world"); + assert_eq!(input.content().as_str(), "hello world"); }); }) .unwrap(); @@ -2982,7 +2952,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_to_end_of_line(&DeleteToEndOfLine, window, cx); - assert_eq!(input.content(), "hello"); + assert_eq!(input.content().as_str(), "hello"); assert_eq!(input.selected_range, 5..5); }); }) @@ -2996,7 +2966,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_to_end_of_line(&DeleteToEndOfLine, window, cx); - assert_eq!(input.content(), "line1\nli\nline3"); + assert_eq!(input.content().as_str(), "line1\nli\nline3"); }); }) .unwrap(); @@ -3008,7 +2978,7 @@ mod tests { view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_to_end_of_line(&DeleteToEndOfLine, window, cx); - assert_eq!(input.content(), "hello world"); + assert_eq!(input.content().as_str(), "hello world"); }); }) .unwrap(); @@ -3022,10 +2992,10 @@ mod tests { input.set_history_group_interval(Duration::from_secs(0)); input.delete_word_left(&DeleteWordLeft, window, cx); - assert_eq!(input.content(), " world"); + assert_eq!(input.content().as_str(), " world"); input.undo(&Undo, window, cx); - assert_eq!(input.content(), "hello world"); + assert_eq!(input.content().as_str(), "hello world"); }); }) .unwrap(); @@ -3039,10 +3009,10 @@ mod tests { input.set_history_group_interval(Duration::from_secs(0)); input.delete_word_right(&DeleteWordRight, window, cx); - assert_eq!(input.content(), "hello "); + assert_eq!(input.content().as_str(), "hello "); input.undo(&Undo, window, cx); - assert_eq!(input.content(), "hello world"); + assert_eq!(input.content().as_str(), "hello world"); }); }) .unwrap(); @@ -3056,10 +3026,10 @@ mod tests { input.set_history_group_interval(Duration::from_secs(0)); input.delete_to_beginning_of_line(&DeleteToBeginningOfLine, window, cx); - assert_eq!(input.content(), " world"); + assert_eq!(input.content().as_str(), " world"); input.undo(&Undo, window, cx); - assert_eq!(input.content(), "hello world"); + assert_eq!(input.content().as_str(), "hello world"); }); }) .unwrap(); @@ -3073,10 +3043,10 @@ mod tests { input.set_history_group_interval(Duration::from_secs(0)); input.delete_to_end_of_line(&DeleteToEndOfLine, window, cx); - assert_eq!(input.content(), "hello"); + assert_eq!(input.content().as_str(), "hello"); input.undo(&Undo, window, cx); - assert_eq!(input.content(), "hello world"); + assert_eq!(input.content().as_str(), "hello world"); }); }) .unwrap(); diff --git a/crates/gpui_elements/src/input/state_input_handler.rs b/crates/gpui_elements/src/input/state_input_handler.rs index b181b78f88..cf866bd8d5 100644 --- a/crates/gpui_elements/src/input/state_input_handler.rs +++ b/crates/gpui_elements/src/input/state_input_handler.rs @@ -1,4 +1,3 @@ -use super::unicode::UnicodeString; use crate::input::InputStateEvent; use gpui::{ Bounds, Context, EntityInputHandler, NavigationDirection, Pixels, Point, UTF16Selection, @@ -14,11 +13,11 @@ impl EntityInputHandler for super::InputState { _window: &mut Window, _cx: &mut Context, ) -> Option { - let range = self.utf_range_16to8(&range_utf16); + let range = self.content().utf_range_16to8(&range_utf16); let clamped_range = range.start.min(self.content().len())..range.end.min(self.content().len()); - adjusted_range.replace(self.utf_range_8to16(&clamped_range)); - Some(self.content()[clamped_range].to_string()) + adjusted_range.replace(self.content().utf_range_8to16(&clamped_range)); + Some(self.content().as_str()[clamped_range].to_string()) } fn selected_text_range( @@ -28,7 +27,7 @@ impl EntityInputHandler for super::InputState { _cx: &mut Context, ) -> Option { Some(UTF16Selection { - range: self.utf_range_8to16(self.selected_range()), + range: self.content().utf_range_8to16(self.selected_range()), reversed: self.selection_direction() == NavigationDirection::Back, }) } @@ -40,7 +39,7 @@ impl EntityInputHandler for super::InputState { ) -> Option> { self.marked_range() .as_ref() - .map(|range| self.utf_range_8to16(range)) + .map(|range| self.content().utf_range_8to16(range)) } fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { @@ -56,7 +55,7 @@ impl EntityInputHandler for super::InputState { ) { let range = range_utf16 .as_ref() - .map(|range_utf16| self.utf_range_16to8(range_utf16)) + .map(|range_utf16| self.content().utf_range_16to8(range_utf16)) .or(self.marked_range().cloned()) .unwrap_or(self.selected_range().clone()); let range = range.start.min(self.content().len())..range.end.min(self.content().len()); @@ -89,7 +88,7 @@ impl EntityInputHandler for super::InputState { ) { let range = range_utf16 .as_ref() - .map(|range_utf16| self.utf_range_16to8(range_utf16)) + .map(|range_utf16| self.content().utf_range_16to8(range_utf16)) .or(self.marked_range().cloned()) .unwrap_or(self.selected_range().clone()); let range = range.start.min(self.content().len())..range.end.min(self.content().len()); @@ -104,7 +103,8 @@ impl EntityInputHandler for super::InputState { }); self.set_selected_range({ let new_range = new_selected_range_utf16.as_ref(); - let new_range = new_range.map(|range_utf16| self.utf_range_16to8(range_utf16)); + let new_range = + new_range.map(|range_utf16| self.content().utf_range_16to8(range_utf16)); let new_range = new_range .map(|new_range| new_range.start + range.start..new_range.end + range.start); new_range.unwrap_or_else(|| { @@ -124,7 +124,7 @@ impl EntityInputHandler for super::InputState { _window: &mut Window, _cx: &mut Context, ) -> Option> { - let range = self.utf_range_16to8(&range_utf16); + let range = self.content().utf_range_16to8(&range_utf16); for line in self.lines() { if line.text_range.is_empty() { @@ -192,6 +192,6 @@ impl EntityInputHandler for super::InputState { _cx: &mut Context, ) -> Option { let index = self.index_for_pixel_point(point); - Some(self.utf_offset_8to16(index)) + Some(self.content().utf_offset_8to16(index)) } } diff --git a/crates/gpui_elements/src/input/storage.rs b/crates/gpui_elements/src/input/storage.rs new file mode 100644 index 0000000000..fa3f33f251 --- /dev/null +++ b/crates/gpui_elements/src/input/storage.rs @@ -0,0 +1,77 @@ +use crate::input::unicode::UnicodeString; +use std::ops::Range; + +pub trait InputStorage: UnicodeString { + fn len(&self) -> usize; + fn as_str(&self) -> &str; + fn emplace(&mut self, s: &str); + fn update_utf8(&mut self, range_utf8: Range, text: &str); + fn replace_range(&mut self, range: Range, text: &str); +} + +/// A light wrapper around std String as a storage medium for `InputState`. +#[derive(Default)] +pub struct Standard { + inner: String, + /// Cached UTF-16 length of content for faster IME operations. Lazily computed when queried. + cached_utf16_len: Option, +} +impl std::ops::Deref for Standard { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} +impl std::ops::DerefMut for Standard { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} +impl UnicodeString for Standard { + fn len_utf16_cached(&self) -> Option { + self.cached_utf16_len + } + + fn content_utf8(&self) -> &str { + &self.inner + } + + fn len_utf16(&self) -> usize { + if let Some(len) = self.cached_utf16_len { + return len; + } + self.inner.chars().map(|c| c.len_utf16()).sum() + } + + fn clear_utf16_cache(&mut self) { + self.cached_utf16_len = None; + } +} +impl InputStorage for Standard { + fn len(&self) -> usize { + self.inner.len() + } + + fn as_str(&self) -> &str { + self.inner.as_str() + } + + fn emplace(&mut self, s: &str) { + self.inner = s.to_owned(); + self.cached_utf16_len = None; + } + + fn update_utf8(&mut self, range_utf8: Range, text: &str) { + if let Some(cached_len) = self.cached_utf16_len { + let removed_utf16_len: usize = + self.inner[range_utf8].chars().map(|c| c.len_utf16()).sum(); + let added_utf16_len: usize = text.chars().map(|c| c.len_utf16()).sum(); + self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len); + } + } + + fn replace_range(&mut self, range: Range, text: &str) { + self.inner.replace_range(range, &text); + } +} diff --git a/crates/gpui_elements/src/input/unicode.rs b/crates/gpui_elements/src/input/unicode.rs index f632415883..586154d397 100644 --- a/crates/gpui_elements/src/input/unicode.rs +++ b/crates/gpui_elements/src/input/unicode.rs @@ -7,6 +7,8 @@ pub trait UnicodeString { /// Returns the UTF-16 length of the content. fn len_utf16(&self) -> usize; + fn clear_utf16_cache(&mut self) {} + fn utf_offset_8to16(&self, pos_uft8: usize) -> usize { // Fast path: if offset is 0, return 0 if pos_uft8 == 0 { From 454b3210fbf67359806b203927a09e542b5f2056 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Mon, 15 Jun 2026 14:11:32 -0400 Subject: [PATCH 036/117] Move cursor entity to input element and set it up as an Element so it can render independently --- Cargo.lock | 1 + crates/gpui_elements/Cargo.toml | 1 + crates/gpui_elements/src/input/cursor.rs | 2 + crates/gpui_elements/src/input/element.rs | 17 +++++++-- crates/gpui_elements/src/input/paint.rs | 46 +++++++++++++++++++---- crates/gpui_elements/src/input/state.rs | 34 ++--------------- 6 files changed, 61 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d78049525a..e01de885c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2427,6 +2427,7 @@ version = "0.1.0" dependencies = [ "async-io", "gpui", + "smallvec", "unicode-segmentation", ] diff --git a/crates/gpui_elements/Cargo.toml b/crates/gpui_elements/Cargo.toml index b0c8b016a1..7220c878bb 100644 --- a/crates/gpui_elements/Cargo.toml +++ b/crates/gpui_elements/Cargo.toml @@ -15,6 +15,7 @@ ignored = ["gpui"] gpui.workspace = true async-io.workspace = true unicode-segmentation.workspace = true +smallvec.workspace = true [dev-dependencies] gpui = { path = "../gpui", features = ["test-support"] } diff --git a/crates/gpui_elements/src/input/cursor.rs b/crates/gpui_elements/src/input/cursor.rs index cdf58be6ea..83620e5f5c 100644 --- a/crates/gpui_elements/src/input/cursor.rs +++ b/crates/gpui_elements/src/input/cursor.rs @@ -4,6 +4,8 @@ use std::time::Duration; /// Default interval for cursor blinking. pub const DEFAULT_BLINK_INTERVAL: Duration = Duration::from_millis(500); +pub trait Cursor {} + /// Configuration for cursor blinking, to be provided to InputState. pub enum CursorBlinkType<'app> { /// The cursor will not blink. diff --git a/crates/gpui_elements/src/input/element.rs b/crates/gpui_elements/src/input/element.rs index 31f7eabfaf..0c3e314567 100644 --- a/crates/gpui_elements/src/input/element.rs +++ b/crates/gpui_elements/src/input/element.rs @@ -1,7 +1,7 @@ -use crate::input::{InputColors, InputState}; +use crate::input::{Cursor, InputColors, InputState}; use gpui::{ - Action, App, Context, Entity, FocusHandle, Focusable, Hsla, InteractiveElement, Interactivity, - IntoElement, SharedString, StyleRefinement, Styled, Window, + Action, AnyElement, App, Context, Entity, FocusHandle, Focusable, Hsla, InteractiveElement, + Interactivity, IntoElement, SharedString, StyleRefinement, Styled, Window, }; #[track_caller] @@ -15,6 +15,7 @@ pub struct Input { pub(super) interactivity: Interactivity, pub(super) placeholder: Option, pub(super) colors: InputColors, + pub(super) cursor: Option, } impl Input { @@ -26,6 +27,7 @@ impl Input { interactivity: Interactivity::new(), placeholder: None, colors: InputColors::default(), + cursor: None, }; input.register_actions(); input @@ -163,6 +165,15 @@ impl Input { self.colors.marked = color; self } + + pub fn cursor(mut self, entity: Entity) -> Self + where + T: Cursor, + Entity: Into, + { + self.cursor = Some(entity.into()); + self + } } fn register_action( diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index f93f1cee5d..1e86382405 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -1,11 +1,12 @@ use crate::input::{Input, InputColors, InputLayoutData, InputLogicalLine, InputState}; use gpui::{ - Along, App, Axis, Bounds, ContentMask, CursorStyle, DispatchPhase, Element, ElementId, + Along, App, Axis, Bounds, ContentMask, CursorStyle, DispatchPhase, Display, Element, ElementId, ElementInputHandler, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, LayoutId, Length, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollWheelEvent, SharedString, Style, TextAlign, TextRun, TextStyle, Window, fill, point, px, relative, size, }; +use smallvec::SmallVec; use std::ops::Range; const CURSOR_WIDTH: f32 = 2.0; @@ -13,6 +14,8 @@ const MARKED_TEXT_UNDERLINE_THICKNESS: f32 = 2.0; pub struct InputLayoutState { text_style: TextStyle, + #[allow(dead_code)] + child_layout_ids: SmallVec<[LayoutId; 2]>, } pub struct InputPrepaintState { @@ -39,6 +42,7 @@ impl Element for Input { cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { let mut resolved_text_style = None; + let mut child_layout_ids = SmallVec::new(); let layout_id = self.interactivity.request_layout( global_id, @@ -46,12 +50,13 @@ impl Element for Input { window, cx, |element_style, window, cx| { - let layout = self.input.read(cx).layout_style(); window.with_text_style(element_style.text_style().cloned(), |window| { + let state = self.input.read(cx); + resolved_text_style = Some(window.text_style()); let mut layout_style = element_style.clone(); - if matches!(layout, super::InputLayoutStyle::MultiLine) { + if matches!(state.layout_style(), super::InputLayoutStyle::MultiLine) { if let Length::Auto = layout_style.size.width { layout_style.size.width = relative(1.).into(); } @@ -59,13 +64,21 @@ impl Element for Input { layout_style.size.height = relative(1.).into(); } } - window.request_layout(layout_style, None, cx) + + child_layout_ids = self + .cursor + .iter_mut() + .map(|cursor| cursor.request_layout(window, cx)) + .collect::>(); + + window.request_layout(layout_style, child_layout_ids.iter().copied(), cx) }) }, ); let layout_state = InputLayoutState { text_style: resolved_text_style.unwrap_or_else(|| window.text_style()), + child_layout_ids, }; (layout_id, layout_state) } @@ -106,8 +119,19 @@ impl Element for Input { bounds.size, window, cx, - |_style, _point, hitbox, window, _cx| { - hitbox.or_else(|| Some(window.insert_hitbox(bounds, HitboxBehavior::Normal))) + |style, scroll_offset, hitbox, window, cx| { + let hitbox = + hitbox.or_else(|| Some(window.insert_hitbox(bounds, HitboxBehavior::Normal))); + + if style.display != Display::None { + window.with_element_offset(scroll_offset, |window| { + if let Some(cursor) = &mut self.cursor { + cursor.prepaint(window, cx); + } + }); + } + + hitbox }, ); @@ -146,7 +170,11 @@ impl Element for Input { input.toggle_cursor_on_focus_change(is_focused, cx) }); - let perform_paint = |_style: &Style, window: &mut Window, cx: &mut App| { + let perform_paint = |style: &Style, window: &mut Window, cx: &mut App| { + if style.display == Display::None { + return; + } + let context = PaintContext { snapshot, is_focused, @@ -159,6 +187,10 @@ impl Element for Input { context.process_mouse_events(&self.input, window, cx); window.with_content_mask(Some(ContentMask { bounds }), |window| { context.paint(window, cx); + + if let Some(cursor) = &mut self.cursor { + cursor.paint(window, cx); + } }); }; self.interactivity.paint( diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index 8c53f6b386..cf0560fa58 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -2,8 +2,8 @@ use super::actions::*; use crate::input::{CursorBlinkType, InputLayoutStyle, InputStorage}; use gpui::{ App, AppContext, ClipboardItem, Context, Entity, EntityId, EntityInputHandler, EventEmitter, - FocusHandle, Focusable, NavigationDirection, Pixels, Point, SharedString, Size, Subscription, - TextRun, TextStyle, Window, WrappedLine, point, px, + FocusHandle, Focusable, NavigationDirection, Pixels, Point, Render, SharedString, Size, + Subscription, TextRun, TextStyle, Window, WrappedLine, point, px, }; use std::{ ops::Range, @@ -74,9 +74,6 @@ pub struct InputState { history_undo_stack: Vec, /// Stack of undone states for redo. history_redo_stack: Vec, - - /// Optional entity and subscription tracking the blinking of the text cursor. - cursor_blink: Option<(Entity, Subscription)>, } /// Data built during element prepaint that is stored in InputState for conveinence @@ -126,7 +123,7 @@ impl InputState { /// Creates a new `Input` with the specified multiline setting. /// Cursor blinking is enabled by default. pub fn new(cx: &mut Context) -> Self { - let mut this = Self { + Self { entity_id: cx.entity_id(), focus_handle: cx.focus_handle(), content: Box::new(super::Standard::default()), @@ -148,30 +145,7 @@ impl InputState { history_grouping_interval: super::DEFAULT_GROUP_INTERVAL, history_undo_stack: Vec::new(), history_redo_stack: Vec::new(), - - cursor_blink: None, - }; - // TODO: This is unoptimal for non-blinking cases, since the entity is generated and then discarded. - this = this.cursor_blink(CursorBlinkType::Enabled { - app: cx, - interval: None, - }); - this - } - - /// Configure how often the cursor should blink when the input element has focus. - pub fn cursor_blink<'app>(mut self, args: CursorBlinkType<'app>) -> Self { - self.cursor_blink = match args { - CursorBlinkType::Disabled => None, - CursorBlinkType::Enabled { app: cx, interval } => { - let interval = interval.unwrap_or(super::DEFAULT_BLINK_INTERVAL); - let cursor_blink = cx.new(|_cx| super::CursorBlink::new(interval)); - let entity_id = self.entity_id; - let subscription = cx.observe(&cursor_blink, move |_, cx| cx.notify(entity_id)); - Some((cursor_blink, subscription)) - } - }; - self + } } /// Returns the current text content. From 02531c48f7a05452ea0de0af764d96571346f900 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Mon, 15 Jun 2026 19:37:09 -0400 Subject: [PATCH 037/117] rework cursor to render itself --- crates/gpui_elements/src/input/colors.rs | 3 - crates/gpui_elements/src/input/cursor.rs | 129 +++++++++++++++--- crates/gpui_elements/src/input/element.rs | 17 +-- crates/gpui_elements/src/input/paint.rs | 93 ++++++++----- crates/gpui_elements/src/input/state.rs | 65 +++------ .../src/input/state_input_handler.rs | 4 +- 6 files changed, 190 insertions(+), 121 deletions(-) diff --git a/crates/gpui_elements/src/input/colors.rs b/crates/gpui_elements/src/input/colors.rs index f60f765280..8e9e638d0b 100644 --- a/crates/gpui_elements/src/input/colors.rs +++ b/crates/gpui_elements/src/input/colors.rs @@ -5,8 +5,6 @@ use gpui::Hsla; pub struct InputColors { /// This is the background color applied to the range of text that is currently selected by the user. pub selection: Hsla, - /// This is the color of the user's text cursor. - pub cursor: Hsla, /// This is the color of the placeholder string, when one is assigned and the text field is empty. pub placeholder: Hsla, pub marked: Hsla, @@ -16,7 +14,6 @@ impl Default for InputColors { fn default() -> Self { Self { selection: gpui::hsla(0.583, 0.519, 0.31, 1.0), - cursor: Hsla::white().opacity(0.8), marked: Hsla::white().opacity(0.6), placeholder: gpui::hsla(0., 0., 0.5, 1.0), } diff --git a/crates/gpui_elements/src/input/cursor.rs b/crates/gpui_elements/src/input/cursor.rs index 83620e5f5c..258776639b 100644 --- a/crates/gpui_elements/src/input/cursor.rs +++ b/crates/gpui_elements/src/input/cursor.rs @@ -1,47 +1,46 @@ -use gpui::Context; +use gpui::{Bounds, Context, Element, Hsla, IntoElement, Pixels, Point, Render}; use std::time::Duration; /// Default interval for cursor blinking. pub const DEFAULT_BLINK_INTERVAL: Duration = Duration::from_millis(500); -pub trait Cursor {} - -/// Configuration for cursor blinking, to be provided to InputState. -pub enum CursorBlinkType<'app> { - /// The cursor will not blink. - Disabled, - /// The cursor will blink at some interval. - Enabled { - /// Provide the app so that the internal state to track cursor blinking can be created. - app: &'app mut gpui::App, - /// The interval to blink at. If none, the default value of 500ms is used (defined by `DEFAULT_BLINK_INTERVAL`). - interval: Option, - }, -} - /// The state of an input's cursor blinking. While active, the cursor's visibility changes at some interval. /// This blinking can be temporarily paused (e.g. during typing). -pub(super) struct CursorBlink { +pub struct Cursor { interval: Duration, generation: usize, visible: bool, active: bool, paused: bool, + color: Hsla, + /// Tracks whether we were focused on the last update. + was_focused: bool, + point: Point, + height: Pixels, } -impl CursorBlink { +impl Cursor { /// Initializes the cursor blinking with the cursor already being visible. #[track_caller] - pub fn new(interval: Duration) -> Self { + pub fn new(interval: Option) -> Self { Self { - interval, + interval: interval.unwrap_or_default(), generation: 0, visible: true, active: false, paused: false, + color: Hsla::white(), + was_focused: false, + point: Point::default(), + height: Pixels::ZERO, } } + pub fn color(mut self, color: Hsla) -> Self { + self.color = color; + self + } + /// Returns whether the cursor should currently be rendered. pub fn visible(&self) -> bool { self.visible @@ -121,4 +120,94 @@ impl CursorBlink { }) .detach(); } + + pub fn update_input( + &mut self, + is_focused: bool, + pos: Point, + line_height: Pixels, + cx: &mut Context, + ) -> bool { + let was_focused = self.was_focused; + self.was_focused = is_focused; + + self.point = pos; + self.height = line_height; + + match (self.interval.is_zero(), is_focused, was_focused) { + (true, _, _) => true, + (false, true, false) => { + self.enable(cx); + true + } + (false, false, true) => { + self.disable(cx); + false + } + (false, _, _) => self.visible, + } + } +} + +impl IntoElement for Cursor { + type Element = Self; + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for Cursor { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + window: &mut gpui::Window, + cx: &mut gpui::App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let layout_id = window.request_layout(gpui::Style::default(), None, cx); + (layout_id, ()) + } + + fn prepaint( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + _bounds: gpui::Bounds, + _request_layout: &mut Self::RequestLayoutState, + _window: &mut gpui::Window, + _cx: &mut gpui::App, + ) -> Self::PrepaintState { + () + } + + fn paint( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + bounds: gpui::Bounds, + _request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + window: &mut gpui::Window, + _cx: &mut gpui::App, + ) { + const CURSOR_WIDTH: f32 = 2.0; + window.paint_quad(gpui::fill( + Bounds::new( + gpui::point(bounds.left(), bounds.top()) + self.point, + gpui::size(gpui::px(CURSOR_WIDTH), self.height), + ), + self.color, + )); + } } diff --git a/crates/gpui_elements/src/input/element.rs b/crates/gpui_elements/src/input/element.rs index 0c3e314567..2c64e59784 100644 --- a/crates/gpui_elements/src/input/element.rs +++ b/crates/gpui_elements/src/input/element.rs @@ -15,7 +15,7 @@ pub struct Input { pub(super) interactivity: Interactivity, pub(super) placeholder: Option, pub(super) colors: InputColors, - pub(super) cursor: Option, + pub(super) cursor: Option>, } impl Input { @@ -145,13 +145,6 @@ impl Input { self } - /// Sets the "cursor" color for the input element. - /// This is the color of the user's text cursor. - pub fn cursor_color(mut self, color: Hsla) -> Self { - self.colors.cursor = color; - self - } - /// Sets the "placeholder" color for the input element. /// This is the color of the placeholder string, when one is assigned and the text field is empty. pub fn placeholder_color(mut self, color: Hsla) -> Self { @@ -166,12 +159,8 @@ impl Input { self } - pub fn cursor(mut self, entity: Entity) -> Self - where - T: Cursor, - Entity: Into, - { - self.cursor = Some(entity.into()); + pub fn cursor(mut self, entity: Entity) -> Self { + self.cursor = Some(entity); self } } diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index 1e86382405..7c28a1b43a 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -1,4 +1,4 @@ -use crate::input::{Input, InputColors, InputLayoutData, InputLogicalLine, InputState}; +use crate::input::{Cursor, Input, InputColors, InputLayoutData, InputLogicalLine, InputState}; use gpui::{ Along, App, Axis, Bounds, ContentMask, CursorStyle, DispatchPhase, Display, Element, ElementId, ElementInputHandler, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla, @@ -9,17 +9,18 @@ use gpui::{ use smallvec::SmallVec; use std::ops::Range; -const CURSOR_WIDTH: f32 = 2.0; const MARKED_TEXT_UNDERLINE_THICKNESS: f32 = 2.0; pub struct InputLayoutState { text_style: TextStyle, #[allow(dead_code)] child_layout_ids: SmallVec<[LayoutId; 2]>, + cursor_layout: Option<::RequestLayoutState>, } pub struct InputPrepaintState { hitbox: Option, + cursor_prepaint: Option<::PrepaintState>, } impl Element for Input { @@ -43,6 +44,7 @@ impl Element for Input { ) -> (LayoutId, Self::RequestLayoutState) { let mut resolved_text_style = None; let mut child_layout_ids = SmallVec::new(); + let mut cursor_layout = None; let layout_id = self.interactivity.request_layout( global_id, @@ -65,11 +67,13 @@ impl Element for Input { } } - child_layout_ids = self - .cursor - .iter_mut() - .map(|cursor| cursor.request_layout(window, cx)) - .collect::>(); + if let Some(cursor) = &self.cursor { + let (layout_id, layout) = cursor.update(cx, |cursor, cx| { + cursor.request_layout(global_id, inspector_id, window, cx) + }); + child_layout_ids.push(layout_id); + cursor_layout = Some(layout); + } window.request_layout(layout_style, child_layout_ids.iter().copied(), cx) }) @@ -79,6 +83,7 @@ impl Element for Input { let layout_state = InputLayoutState { text_style: resolved_text_style.unwrap_or_else(|| window.text_style()), child_layout_ids, + cursor_layout, }; (layout_id, layout_state) } @@ -112,6 +117,7 @@ impl Element for Input { input.apply_layout_update(layout_data, window); }); + let mut cursor_prepaint = None; let hitbox = self.interactivity.prepaint( global_id, inspector_id, @@ -125,8 +131,21 @@ impl Element for Input { if style.display != Display::None { window.with_element_offset(scroll_offset, |window| { - if let Some(cursor) = &mut self.cursor { - cursor.prepaint(window, cx); + match (&mut self.cursor, &mut layout_state.cursor_layout) { + (Some(cursor), Some(layout)) => { + let prepaint = cursor.update(cx, |cursor, cx| { + cursor.prepaint( + global_id, + inspector_id, + bounds, + layout, + window, + cx, + ) + }); + cursor_prepaint = Some(prepaint); + } + _ => {} } }); } @@ -135,7 +154,10 @@ impl Element for Input { }, ); - InputPrepaintState { hitbox } + InputPrepaintState { + hitbox, + cursor_prepaint, + } } fn paint( @@ -166,10 +188,6 @@ impl Element for Input { let is_focused = focus_handle.is_focused(window); let colors = self.colors; - let is_cursor_visible = self.input.update(cx, |input, cx| { - input.toggle_cursor_on_focus_change(is_focused, cx) - }); - let perform_paint = |style: &Style, window: &mut Window, cx: &mut App| { if style.display == Display::None { return; @@ -182,14 +200,39 @@ impl Element for Input { text_style: &text_style, placeholder: placeholder.as_ref(), colors: &colors, - cursor_visible: is_cursor_visible, }; context.process_mouse_events(&self.input, window, cx); window.with_content_mask(Some(ContentMask { bounds }), |window| { context.paint(window, cx); - if let Some(cursor) = &mut self.cursor { - cursor.paint(window, cx); + match ( + &mut self.cursor, + &mut layout_state.cursor_layout, + &mut prepaint_state.cursor_prepaint, + ) { + (Some(cursor), Some(layout), Some(prepaint)) => { + cursor.update(cx, |cursor, cx| { + let cursor_pos = context.find_cursor_position_in_layouts(); + let visible = cursor.update_input( + is_focused, + cursor_pos, + context.snapshot.line_height, + cx, + ); + if is_focused && visible && context.snapshot.selected_range.is_empty() { + cursor.paint( + global_id, + inspector_id, + bounds, + layout, + prepaint, + window, + cx, + ); + } + }); + } + _ => {} } }); }; @@ -253,7 +296,6 @@ struct PaintContext<'app> { text_style: &'app TextStyle, placeholder: Option<&'app SharedString>, colors: &'app InputColors, - cursor_visible: bool, } impl<'app> PaintContext<'app> { @@ -388,10 +430,6 @@ impl<'app> PaintContext<'app> { } self.paint_marked_underline(window); - - if self.is_focused && self.snapshot.selected_range.is_empty() && self.cursor_visible { - self.paint_cursor(window); - } } fn paint_selection(&self, window: &mut Window) { @@ -557,17 +595,6 @@ impl<'app> PaintContext<'app> { Point::default() } - fn paint_cursor(&self, window: &mut Window) { - let cursor_pos = self.find_cursor_position_in_layouts(); - window.paint_quad(fill( - Bounds::new( - point(self.bounds.left(), self.bounds.top()) + cursor_pos, - size(px(CURSOR_WIDTH), self.snapshot.line_height), - ), - self.colors.cursor, - )); - } - fn is_line_visible(&self, line: &InputLogicalLine) -> bool { let line_y = line.y_offset - self.snapshot.scroll_distance; let line_bottom = line_y + self.snapshot.line_height * line.visual_line_count as f32; diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index cf0560fa58..f20262534c 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -1,5 +1,5 @@ use super::actions::*; -use crate::input::{CursorBlinkType, InputLayoutStyle, InputStorage}; +use crate::input::{InputLayoutStyle, InputStorage}; use gpui::{ App, AppContext, ClipboardItem, Context, Entity, EntityId, EntityInputHandler, EventEmitter, FocusHandle, Focusable, NavigationDirection, Pixels, Point, Render, SharedString, Size, @@ -26,9 +26,15 @@ pub enum InputStateEvent { /// Emitted when a redo operation is performed. Redo, } - impl EventEmitter for InputState {} +#[derive(Clone, Debug)] +pub enum CursorTrigger { + // TODO: cursor needs to receive this + PauseBlinkingForUserAction, +} +impl EventEmitter for InputState {} + /// `Input` is the state model for text input components. It handles: /// - Text content storage and manipulation /// - Selection and cursor management @@ -56,8 +62,6 @@ pub struct InputState { layout_data: InputLayoutData, /// A reinterpretation of `content` as wrapped lines with layout information. Regenerated when content changes or the layout changes during element painting. logical_lines: Vec, - /// Tracks whether we were focused on the last update. - was_focused: bool, /// True while the user is in the act of highlighting a section of the text (e.g. during mouse pressed & dragging). is_selecting: bool, @@ -135,7 +139,6 @@ impl InputState { layout_data: InputLayoutData::default(), logical_lines: Vec::new(), - was_focused: false, is_selecting: false, last_click_position: None, @@ -164,7 +167,7 @@ impl InputState { self.history_undo_stack.clear(); self.history_redo_stack.clear(); self.mark_layout_dirty(); - self.pause_cursor_blink(cx); + cx.emit(CursorTrigger::PauseBlinkingForUserAction); cx.emit(InputStateEvent::TextChanged); cx.notify(); } @@ -305,7 +308,7 @@ impl InputState { self.marked_range.take(); self.mark_layout_dirty(); - self.pause_cursor_blink(cx); + cx.emit(CursorTrigger::PauseBlinkingForUserAction); cx.emit(InputStateEvent::TextChanged); cx.notify(); } @@ -429,7 +432,7 @@ impl InputState { } pub(super) fn up(&mut self, _: &Up, _window: &mut Window, cx: &mut Context) { - self.pause_cursor_blink(cx); + cx.emit(CursorTrigger::PauseBlinkingForUserAction); match self.layout_style { InputLayoutStyle::SingleLine => { // In single-line mode, up moves to start @@ -450,7 +453,7 @@ impl InputState { } pub(super) fn down(&mut self, _: &Down, _window: &mut Window, cx: &mut Context) { - self.pause_cursor_blink(cx); + cx.emit(CursorTrigger::PauseBlinkingForUserAction); match self.layout_style { InputLayoutStyle::SingleLine => { // In single-line mode, down moves to end @@ -480,7 +483,7 @@ impl InputState { } pub(super) fn select_up(&mut self, _: &SelectUp, _window: &mut Window, cx: &mut Context) { - self.pause_cursor_blink(cx); + cx.emit(CursorTrigger::PauseBlinkingForUserAction); match self.layout_style { InputLayoutStyle::SingleLine => { // In single-line mode, select_up selects to start @@ -503,7 +506,7 @@ impl InputState { _window: &mut Window, cx: &mut Context, ) { - self.pause_cursor_blink(cx); + cx.emit(CursorTrigger::PauseBlinkingForUserAction); match self.layout_style { InputLayoutStyle::SingleLine => { // In single-line mode, select_down selects to end @@ -808,13 +811,6 @@ impl InputState { self.content.update_utf8(range, text_to_insert); } - /// Temporarily pauses blinking and leaves the cursor visible. Blinking will resume after the pre-established interval elapses from the time this is called. - pub(super) fn pause_cursor_blink(&self, cx: &mut Context) { - if let Some((cursor_blink, _)) = &self.cursor_blink { - cursor_blink.update(cx, |cb, cx| cb.pause_blinking(cx)); - } - } - /// Records a patch for undo. Called before making changes to content. /// Returns true if a new entry was created, false if grouped with previous. pub(super) fn push_undo_patch(&mut self, range: Range, new_text_len: usize) { @@ -1110,37 +1106,8 @@ impl InputState { logical_lines } - /// Processes a focus-flag update during window paint, returning whether the cursor should be visible in this frame. - /// Returns false if the cursor is blinking and not currently visible. - pub(super) fn toggle_cursor_on_focus_change( - &mut self, - is_focused: bool, - cx: &mut Context, - ) -> bool { - // Update cursor blink based on focus changes - let was_focused = self.was_focused; - self.was_focused = is_focused; - - match &self.cursor_blink { - None => true, - Some((cursor_blink, _)) => match (is_focused, was_focused) { - (true, false) => { - cursor_blink.update(cx, |cursor, cx| cursor.enable(cx)); - cx.emit(InputStateEvent::Focus); - true - } - (false, true) => { - cursor_blink.update(cx, |cursor, cx| cursor.disable(cx)); - cx.emit(InputStateEvent::Blur); - false - } - _ => cursor_blink.read(cx).visible(), - }, - } - } - fn move_to(&mut self, offset: usize, cx: &mut Context) { - self.pause_cursor_blink(cx); + cx.emit(CursorTrigger::PauseBlinkingForUserAction); let offset = offset.min(self.content.len()); self.selected_range = offset..offset; self.selection_direction = NavigationDirection::Forward; @@ -1149,7 +1116,7 @@ impl InputState { } fn select_to(&mut self, offset: usize, cx: &mut Context) { - self.pause_cursor_blink(cx); + cx.emit(CursorTrigger::PauseBlinkingForUserAction); let offset = offset.min(self.content.len()); self.apply_selection_offset(offset); self.scroll_to_cursor(); diff --git a/crates/gpui_elements/src/input/state_input_handler.rs b/crates/gpui_elements/src/input/state_input_handler.rs index cf866bd8d5..a2ca7e26ab 100644 --- a/crates/gpui_elements/src/input/state_input_handler.rs +++ b/crates/gpui_elements/src/input/state_input_handler.rs @@ -1,4 +1,4 @@ -use crate::input::InputStateEvent; +use crate::input::{CursorTrigger, InputStateEvent}; use gpui::{ Bounds, Context, EntityInputHandler, NavigationDirection, Pixels, Point, UTF16Selection, Window, point, px, @@ -73,7 +73,7 @@ impl EntityInputHandler for super::InputState { self.set_marked_range(None); self.mark_layout_dirty(); - self.pause_cursor_blink(cx); + cx.emit(CursorTrigger::PauseBlinkingForUserAction); cx.emit(InputStateEvent::TextChanged); cx.notify(); } From 6ebe87025b110c0eda037e574bb037f156683a58 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Mon, 15 Jun 2026 19:50:54 -0400 Subject: [PATCH 038/117] mocking out cursor event interop --- crates/gpui_elements/src/input.rs | 12 ++++++++++++ crates/gpui_elements/src/input/cursor.rs | 24 +++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/gpui_elements/src/input.rs b/crates/gpui_elements/src/input.rs index a0d130d737..2c83d493c7 100644 --- a/crates/gpui_elements/src/input.rs +++ b/crates/gpui_elements/src/input.rs @@ -17,3 +17,15 @@ pub(self) use history::*; pub use layout::*; pub use state::*; pub use storage::*; + +#[allow(dead_code)] +fn make_element(app: &mut gpui::App) -> impl gpui::IntoElement { + use gpui::AppContext; + let state = app.new(|cx| InputState::new(cx)); + let cursor = app.new(|cx| { + let mut cursor = Cursor::new(None); + cursor.subscribe_to(&state, cx); + cursor + }); + input(&state, app).cursor(cursor) +} diff --git a/crates/gpui_elements/src/input/cursor.rs b/crates/gpui_elements/src/input/cursor.rs index 258776639b..0f094c9223 100644 --- a/crates/gpui_elements/src/input/cursor.rs +++ b/crates/gpui_elements/src/input/cursor.rs @@ -1,6 +1,12 @@ -use gpui::{Bounds, Context, Element, Hsla, IntoElement, Pixels, Point, Render}; +use gpui::{ + Bounds, Context, Element, Entity, EventEmitter, Hsla, IntoElement, Pixels, Point, Render, + Subscription, +}; +use smallvec::SmallVec; use std::time::Duration; +use crate::input::CursorTrigger; + /// Default interval for cursor blinking. pub const DEFAULT_BLINK_INTERVAL: Duration = Duration::from_millis(500); @@ -17,6 +23,8 @@ pub struct Cursor { was_focused: bool, point: Point, height: Pixels, + #[allow(dead_code)] + subscriptions: SmallVec<[Subscription; 2]>, } impl Cursor { @@ -33,6 +41,7 @@ impl Cursor { was_focused: false, point: Point::default(), height: Pixels::ZERO, + subscriptions: SmallVec::new(), } } @@ -41,6 +50,19 @@ impl Cursor { self } + pub fn subscribe_to(&mut self, emitter: &Entity, cx: &mut Context) + where + E: EventEmitter, + { + let handle = cx.subscribe(emitter, |cursor, _emitter, event, cx| match event { + CursorTrigger::PauseBlinkingForUserAction => { + cursor.pause_blinking(cx); + cx.notify(); + } + }); + self.subscriptions.push(handle); + } + /// Returns whether the cursor should currently be rendered. pub fn visible(&self) -> bool { self.visible From b3ab49124044f4090467d12c451cebb1adf69900 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Tue, 16 Jun 2026 10:15:13 -0400 Subject: [PATCH 039/117] flip cursor implementation to have an element and a state-entity --- crates/gpui_elements/src/input.rs | 9 +- crates/gpui_elements/src/input/cursor.rs | 107 ++++++++++++++++------ crates/gpui_elements/src/input/element.rs | 6 +- crates/gpui_elements/src/input/paint.rs | 59 ++++++------ crates/gpui_elements/src/input/state.rs | 3 +- 5 files changed, 111 insertions(+), 73 deletions(-) diff --git a/crates/gpui_elements/src/input.rs b/crates/gpui_elements/src/input.rs index 2c83d493c7..1a2097e106 100644 --- a/crates/gpui_elements/src/input.rs +++ b/crates/gpui_elements/src/input.rs @@ -11,7 +11,7 @@ mod storage; pub(self) mod unicode; pub use colors::*; -pub(self) use cursor::*; +pub use cursor::*; pub use element::*; pub(self) use history::*; pub use layout::*; @@ -22,10 +22,5 @@ pub use storage::*; fn make_element(app: &mut gpui::App) -> impl gpui::IntoElement { use gpui::AppContext; let state = app.new(|cx| InputState::new(cx)); - let cursor = app.new(|cx| { - let mut cursor = Cursor::new(None); - cursor.subscribe_to(&state, cx); - cursor - }); - input(&state, app).cursor(cursor) + input(&state, app).text_cursor(default_cursor(&state, app)) } diff --git a/crates/gpui_elements/src/input/cursor.rs b/crates/gpui_elements/src/input/cursor.rs index 0f094c9223..a522fb33ea 100644 --- a/crates/gpui_elements/src/input/cursor.rs +++ b/crates/gpui_elements/src/input/cursor.rs @@ -1,5 +1,5 @@ use gpui::{ - Bounds, Context, Element, Entity, EventEmitter, Hsla, IntoElement, Pixels, Point, Render, + App, Bounds, Context, Element, Entity, EventEmitter, Hsla, IntoElement, Pixels, Point, Render, Subscription, }; use smallvec::SmallVec; @@ -13,35 +13,63 @@ pub const DEFAULT_BLINK_INTERVAL: Duration = Duration::from_millis(500); /// The state of an input's cursor blinking. While active, the cursor's visibility changes at some interval. /// This blinking can be temporarily paused (e.g. during typing). pub struct Cursor { - interval: Duration, - generation: usize, - visible: bool, - active: bool, - paused: bool, + state: Entity, color: Hsla, /// Tracks whether we were focused on the last update. was_focused: bool, point: Point, height: Pixels, +} + +pub struct CursorState { + interval: Duration, + generation: usize, + visible: bool, + active: bool, + paused: bool, #[allow(dead_code)] subscriptions: SmallVec<[Subscription; 2]>, } +impl Default for CursorState { + fn default() -> Self { + Self { + interval: Duration::ZERO, + generation: Default::default(), + visible: true, + active: Default::default(), + paused: Default::default(), + subscriptions: SmallVec::new(), + } + } +} + +#[track_caller] +pub fn cursor(state: Entity) -> Cursor { + Cursor::new(state) +} + +#[track_caller] +pub fn default_cursor(emitter: &Entity, cx: &mut App) -> Cursor +where + E: EventEmitter, +{ + use gpui::AppContext; + cursor(cx.new(|cx| { + let mut cursor = CursorState::default().blink_interval_default(); + cursor.subscribe_to(emitter, cx); + cursor + })) +} impl Cursor { - /// Initializes the cursor blinking with the cursor already being visible. #[track_caller] - pub fn new(interval: Option) -> Self { + fn new(state: Entity) -> Self { Self { - interval: interval.unwrap_or_default(), - generation: 0, - visible: true, - active: false, - paused: false, + state, color: Hsla::white(), was_focused: false, point: Point::default(), height: Pixels::ZERO, - subscriptions: SmallVec::new(), } } @@ -49,29 +77,38 @@ impl Cursor { self.color = color; self } +} + +impl CursorState { + pub fn blink_interval_default(mut self) -> Self { + self.interval = DEFAULT_BLINK_INTERVAL; + self + } + + pub fn blink_interval(mut self, interval: Duration) -> Self { + self.interval = interval; + self + } pub fn subscribe_to(&mut self, emitter: &Entity, cx: &mut Context) where E: EventEmitter, { - let handle = cx.subscribe(emitter, |cursor, _emitter, event, cx| match event { + let handle = cx.subscribe(emitter, |state, _emitter, event, cx| match event { CursorTrigger::PauseBlinkingForUserAction => { - cursor.pause_blinking(cx); - cx.notify(); + if !state.interval.is_zero() { + state.pause_blinking(cx); + cx.notify(); + } } }); self.subscriptions.push(handle); } - /// Returns whether the cursor should currently be rendered. - pub fn visible(&self) -> bool { - self.visible - } - /// Activates cursor blinking. /// /// While active, the cursor will alternate between visible and hidden states at the configured interval. Has no effect if already active. - pub fn enable(&mut self, cx: &mut Context) { + fn enable(&mut self, cx: &mut Context) { if self.active { return; } @@ -86,7 +123,7 @@ impl Cursor { /// /// Marks the cursor as invisible and pauses blinking indefinitely. `enable` must be called explicitly to resume visibility and blinking. /// Call `pause_blinking` instead if you want to temporarily stop blinking while keeping the cursor visible. - pub fn disable(&mut self, cx: &mut Context) { + fn disable(&mut self, cx: &mut Context) { self.active = false; self.visible = false; self.paused = false; @@ -94,7 +131,7 @@ impl Cursor { } /// Temporarily pauses blinking and leaves the cursor visible. Blinking will resume after the pre-established interval elapses from the time this is called. - pub fn pause_blinking(&mut self, cx: &mut Context) { + fn pause_blinking(&mut self, cx: &mut Context) { if !self.visible { self.visible = true; cx.notify(); @@ -142,13 +179,15 @@ impl Cursor { }) .detach(); } +} +impl Cursor { pub fn update_input( &mut self, is_focused: bool, pos: Point, line_height: Pixels, - cx: &mut Context, + cx: &mut App, ) -> bool { let was_focused = self.was_focused; self.was_focused = is_focused; @@ -156,17 +195,25 @@ impl Cursor { self.point = pos; self.height = line_height; - match (self.interval.is_zero(), is_focused, was_focused) { + match ( + self.state.read(cx).interval.is_zero(), + is_focused, + was_focused, + ) { (true, _, _) => true, (false, true, false) => { - self.enable(cx); + self.state.update(cx, |state, cx| { + state.enable(cx); + }); true } (false, false, true) => { - self.disable(cx); + self.state.update(cx, |state, cx| { + state.disable(cx); + }); false } - (false, _, _) => self.visible, + (false, _, _) => self.state.read(cx).visible, } } } diff --git a/crates/gpui_elements/src/input/element.rs b/crates/gpui_elements/src/input/element.rs index 2c64e59784..5409136f9d 100644 --- a/crates/gpui_elements/src/input/element.rs +++ b/crates/gpui_elements/src/input/element.rs @@ -15,7 +15,7 @@ pub struct Input { pub(super) interactivity: Interactivity, pub(super) placeholder: Option, pub(super) colors: InputColors, - pub(super) cursor: Option>, + pub(super) cursor: Option, } impl Input { @@ -159,8 +159,8 @@ impl Input { self } - pub fn cursor(mut self, entity: Entity) -> Self { - self.cursor = Some(entity); + pub fn text_cursor(mut self, cursor: Cursor) -> Self { + self.cursor = Some(cursor); self } } diff --git a/crates/gpui_elements/src/input/paint.rs b/crates/gpui_elements/src/input/paint.rs index 7c28a1b43a..3b25fdbcf1 100644 --- a/crates/gpui_elements/src/input/paint.rs +++ b/crates/gpui_elements/src/input/paint.rs @@ -67,10 +67,9 @@ impl Element for Input { } } - if let Some(cursor) = &self.cursor { - let (layout_id, layout) = cursor.update(cx, |cursor, cx| { - cursor.request_layout(global_id, inspector_id, window, cx) - }); + if let Some(cursor) = &mut self.cursor { + let (layout_id, layout) = + cursor.request_layout(global_id, inspector_id, window, cx); child_layout_ids.push(layout_id); cursor_layout = Some(layout); } @@ -133,16 +132,14 @@ impl Element for Input { window.with_element_offset(scroll_offset, |window| { match (&mut self.cursor, &mut layout_state.cursor_layout) { (Some(cursor), Some(layout)) => { - let prepaint = cursor.update(cx, |cursor, cx| { - cursor.prepaint( - global_id, - inspector_id, - bounds, - layout, - window, - cx, - ) - }); + let prepaint = cursor.prepaint( + global_id, + inspector_id, + bounds, + layout, + window, + cx, + ); cursor_prepaint = Some(prepaint); } _ => {} @@ -211,26 +208,24 @@ impl Element for Input { &mut prepaint_state.cursor_prepaint, ) { (Some(cursor), Some(layout), Some(prepaint)) => { - cursor.update(cx, |cursor, cx| { - let cursor_pos = context.find_cursor_position_in_layouts(); - let visible = cursor.update_input( - is_focused, - cursor_pos, - context.snapshot.line_height, + let cursor_pos = context.find_cursor_position_in_layouts(); + let visible = cursor.update_input( + is_focused, + cursor_pos, + context.snapshot.line_height, + cx, + ); + if is_focused && visible && context.snapshot.selected_range.is_empty() { + cursor.paint( + global_id, + inspector_id, + bounds, + layout, + prepaint, + window, cx, ); - if is_focused && visible && context.snapshot.selected_range.is_empty() { - cursor.paint( - global_id, - inspector_id, - bounds, - layout, - prepaint, - window, - cx, - ); - } - }); + } } _ => {} } diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index f20262534c..ed5fbf206c 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -16,8 +16,10 @@ use unicode_segmentation::UnicodeSegmentation; #[derive(Clone, Debug)] pub enum InputStateEvent { /// Emitted when the input gains focus. + /// TODO: an emit was removed from element painting Focus, /// Emitted when the input loses focus. + /// TODO: an emit was removed from element painting Blur, /// Emitted when the text content changes. TextChanged, @@ -30,7 +32,6 @@ impl EventEmitter for InputState {} #[derive(Clone, Debug)] pub enum CursorTrigger { - // TODO: cursor needs to receive this PauseBlinkingForUserAction, } impl EventEmitter for InputState {} From 866b7f4d6226f70438f79675b1e6ee273bc444ec Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 20 Jun 2026 10:04:28 -0400 Subject: [PATCH 040/117] add new editable text actions --- crates/gpui_elements/src/editable_text.rs | 5 + .../src/editable_text/actions.rs | 205 ++++++++++++++++++ .../src/editable_text/state_field.rs | 0 crates/gpui_elements/src/lib.rs | 1 + 4 files changed, 211 insertions(+) create mode 100644 crates/gpui_elements/src/editable_text.rs create mode 100644 crates/gpui_elements/src/editable_text/actions.rs create mode 100644 crates/gpui_elements/src/editable_text/state_field.rs diff --git a/crates/gpui_elements/src/editable_text.rs b/crates/gpui_elements/src/editable_text.rs new file mode 100644 index 0000000000..768a5fd18e --- /dev/null +++ b/crates/gpui_elements/src/editable_text.rs @@ -0,0 +1,5 @@ +mod actions; +mod state_field; + +pub use actions::*; +pub use state_field::*; diff --git a/crates/gpui_elements/src/editable_text/actions.rs b/crates/gpui_elements/src/editable_text/actions.rs new file mode 100644 index 0000000000..78e41b1d55 --- /dev/null +++ b/crates/gpui_elements/src/editable_text/actions.rs @@ -0,0 +1,205 @@ +use gpui::{App, Window}; + +/// The key context used for input element keybindings. +pub const DEFAULT_INPUT_CONTEXT: &str = "Input"; + +gpui::actions!( + actions, + [ + /// Blur focus from the input. + Escape, + /// Insert a newline at the cursor position. + Enter, + /// Insert a tab character at the cursor position. + Tab, + /// Delete the character before the cursor. + Backspace, + /// Delete the character after the cursor. + Delete, + /// Delete the word before the cursor. + DeleteWordLeft, + /// Delete the word after the cursor. + DeleteWordRight, + /// Delete from the cursor to the beginning of the line. + DeleteToBeginningOfLine, + /// Delete from the cursor to the end of the line. + DeleteToEndOfLine, + /// Move the cursor one character to the left. + Left, + /// Move the cursor one character to the right. + Right, + /// Move the cursor up one visual line. + Up, + /// Move the cursor down one visual line. + Down, + /// Move cursor to the start of the current line. + Home, + /// Move cursor to the end of the current line. + End, + /// Move cursor to the beginning of the content. + MoveToBeginning, + /// Move cursor to the end of the content. + MoveToEnd, + /// Move cursor one word to the left. + WordLeft, + /// Move cursor one word to the right. + WordRight, + /// Select all text content. + SelectAll, + /// Extend selection one character to the left. + SelectLeft, + /// Extend selection one character to the right. + SelectRight, + /// Extend selection up one visual line. + SelectUp, + /// Extend selection down one visual line. + SelectDown, + /// Extend selection to the beginning of the content. + SelectToBeginning, + /// Extend selection to the end of the content. + SelectToEnd, + /// Extend selection one word to the left. + SelectWordLeft, + /// Extend selection one word to the right. + SelectWordRight, + /// Cut selected text to clipboard. + Cut, + /// Copy selected text to clipboard. + Copy, + /// Paste from clipboard at the cursor position. + Paste, + /// Undo the last edit. + Undo, + /// Redo the last undone edit. + Redo, + /// Show the platform character palette. + ShowCharacterPalette, + ] +); + +pub fn default_bindings() -> gpui::ActionBindingCollection { + let mut bindings = gpui::ActionBindingCollection::default() + .with::("backspace") + .with::("delete") + .with::("tab") + .with::("enter") + .with::("left") + .with::("right") + .with::("up") + .with::("down") + .with::("secondary-a") + .with::("shift-left") + .with::("shift-right") + .with::("shift-up") + .with::("shift-down") + .with::("secondary-c") + .with::("secondary-x") + .with::("secondary-v") + .with::("secondary-z") + .with::("secondary-shift-z") + .with::("escape") + .with::("secondary-space"); + + #[cfg(target_os = "macos")] + { + bindings = bindings + .with::("alt-backspace") + .with::("alt-delete") + .with::("cmd-backspace") + .with::("ctrl-k") + // Mac keyboards don't have Home/End keys, so cmd-left/right are standard + .with::("cmd-left") + .with::("cmd-right") + .with::("cmd-up") + .with::("cmd-down") + .with::("cmd-shift-up") + .with::("cmd-shift-down") + .with::("alt-left") + .with::("alt-right") + .with::("alt-shift-left") + .with::("alt-shift-right"); + } + + #[cfg(not(target_os = "macos"))] + { + bindings = bindings + .with::("ctrl-backspace") + .with::("ctrl-delete") + .with::("ctrl-shift-backspace") + .with::("ctrl-shift-delete") + .with::("home") + .with::("end") + .with::("ctrl-home") + .with::("ctrl-end") + .with::("ctrl-shift-home") + .with::("ctrl-shift-end") + .with::("ctrl-left") + .with::("ctrl-right") + .with::("ctrl-shift-left") + .with::("ctrl-shift-right"); + } + + bindings +} + +pub trait EditableTextActionHandler { + fn escape(&mut self, _: &Escape, _w: &mut Window, _cx: &mut App) {} + + fn insert_enter(&mut self, _: &Enter, _w: &mut Window, _cx: &mut App) {} + fn insert_tab(&mut self, _: &Tab, _w: &mut Window, _cx: &mut App) {} + + fn backspace(&mut self, _: &Backspace, _w: &mut Window, _cx: &mut App) {} + fn delete(&mut self, _: &Delete, _w: &mut Window, _cx: &mut App) {} + + fn delete_word_left(&mut self, _: &DeleteWordLeft, _w: &mut Window, _cx: &mut App) {} + fn delete_word_right(&mut self, _: &DeleteWordRight, _w: &mut Window, _cx: &mut App) {} + fn delete_to_line_start( + &mut self, + _: &DeleteToBeginningOfLine, + _w: &mut Window, + _cx: &mut App, + ) { + } + fn delete_to_line_end(&mut self, _: &DeleteToEndOfLine, _w: &mut Window, _cx: &mut App) {} + + fn nav_left(&mut self, _: &Left, _w: &mut Window, _cx: &mut App) {} + fn nav_right(&mut self, _: &Right, _w: &mut Window, _cx: &mut App) {} + fn nav_up(&mut self, _: &Up, _w: &mut Window, _cx: &mut App) {} + fn nav_down(&mut self, _: &Down, _w: &mut Window, _cx: &mut App) {} + fn nav_line_start(&mut self, _: &Home, _w: &mut Window, _cx: &mut App) {} + fn nav_line_end(&mut self, _: &End, _w: &mut Window, _cx: &mut App) {} + fn nav_start(&mut self, _: &MoveToBeginning, _w: &mut Window, _cx: &mut App) {} + fn nav_end(&mut self, _: &MoveToEnd, _w: &mut Window, _cx: &mut App) {} + fn nav_left_word(&mut self, _: &WordLeft, _w: &mut Window, _cx: &mut App) {} + fn nav_right_word(&mut self, _: &WordRight, _w: &mut Window, _cx: &mut App) {} + + fn select_all(&mut self, _: &SelectAll, _w: &mut Window, _cx: &mut App) {} + fn select_left(&mut self, _: &SelectLeft, _w: &mut Window, _cx: &mut App) {} + fn select_right(&mut self, _: &SelectRight, _w: &mut Window, _cx: &mut App) {} + fn select_up(&mut self, _: &SelectUp, _w: &mut Window, _cx: &mut App) {} + fn select_down(&mut self, _: &SelectDown, _w: &mut Window, _cx: &mut App) {} + fn select_start(&mut self, _: &SelectToBeginning, _w: &mut Window, _cx: &mut App) {} + fn select_end(&mut self, _: &SelectToEnd, _w: &mut Window, _cx: &mut App) {} + fn select_left_word(&mut self, _: &SelectWordLeft, _w: &mut Window, _cx: &mut App) {} + fn select_right_word(&mut self, _: &SelectWordRight, _w: &mut Window, _cx: &mut App) {} + + fn cut(&mut self, _: &Cut, _w: &mut Window, _cx: &mut App) {} + fn copy(&mut self, _: &Copy, _w: &mut Window, _cx: &mut App) {} + fn paste(&mut self, _: &Paste, _w: &mut Window, _cx: &mut App) {} + + fn undo(&mut self, _: &Undo, _w: &mut Window, _cx: &mut App) {} + fn redo(&mut self, _: &Redo, _w: &mut Window, _cx: &mut App) {} + + fn show_character_palette(&mut self, _: &ShowCharacterPalette, _w: &mut Window, _cx: &mut App) { + } + + fn on_mouse_down( + &mut self, + _position: gpui::Point, + _w: &mut Window, + _cx: &mut App, + ) { + } + fn on_mouse_up(&mut self, _event: &gpui::MouseUpEvent, _w: &mut Window, _cx: &mut App) {} + fn on_mouse_move(&mut self, _event: &gpui::MouseMoveEvent, _w: &mut Window, _cx: &mut App) {} +} diff --git a/crates/gpui_elements/src/editable_text/state_field.rs b/crates/gpui_elements/src/editable_text/state_field.rs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/gpui_elements/src/lib.rs b/crates/gpui_elements/src/lib.rs index 7839bc5393..ecd8bbbeee 100644 --- a/crates/gpui_elements/src/lib.rs +++ b/crates/gpui_elements/src/lib.rs @@ -1 +1,2 @@ +pub mod editable_text; pub mod input; From bde62d95eaf5899518d7388d326234e81303caa8 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 20 Jun 2026 10:23:17 -0400 Subject: [PATCH 041/117] add UnicodeTextStorage trait for String and SharedString --- crates/gpui_elements/src/editable_text.rs | 2 + .../src/editable_text/actions.rs | 8 +- .../src/editable_text/storage.rs | 161 ++++++++++++++++++ 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 crates/gpui_elements/src/editable_text/storage.rs diff --git a/crates/gpui_elements/src/editable_text.rs b/crates/gpui_elements/src/editable_text.rs index 768a5fd18e..40e85948ef 100644 --- a/crates/gpui_elements/src/editable_text.rs +++ b/crates/gpui_elements/src/editable_text.rs @@ -1,5 +1,7 @@ mod actions; mod state_field; +mod storage; pub use actions::*; pub use state_field::*; +pub use storage::*; diff --git a/crates/gpui_elements/src/editable_text/actions.rs b/crates/gpui_elements/src/editable_text/actions.rs index 78e41b1d55..aac530dfba 100644 --- a/crates/gpui_elements/src/editable_text/actions.rs +++ b/crates/gpui_elements/src/editable_text/actions.rs @@ -190,7 +190,13 @@ pub trait EditableTextActionHandler { fn undo(&mut self, _: &Undo, _w: &mut Window, _cx: &mut App) {} fn redo(&mut self, _: &Redo, _w: &mut Window, _cx: &mut App) {} - fn show_character_palette(&mut self, _: &ShowCharacterPalette, _w: &mut Window, _cx: &mut App) { + fn show_character_palette( + &mut self, + _: &ShowCharacterPalette, + window: &mut Window, + _cx: &mut App, + ) { + window.show_character_palette(); } fn on_mouse_down( diff --git a/crates/gpui_elements/src/editable_text/storage.rs b/crates/gpui_elements/src/editable_text/storage.rs new file mode 100644 index 0000000000..a2ab788c9a --- /dev/null +++ b/crates/gpui_elements/src/editable_text/storage.rs @@ -0,0 +1,161 @@ +use gpui::SharedString; +use std::ops::Range; +use unicode_segmentation::UnicodeSegmentation; + +pub trait UnicodeTextStorage { + /// Returns a reference to the utf8 string. + fn content_utf8(&self) -> &str; + + /// Returns the UTF-16 length of the content. + fn len_utf16(&self) -> usize; + + fn utf_offset_8to16(&self, pos_uft8: usize) -> usize { + // Fast path: if offset is 0, return 0 + if pos_uft8 == 0 { + return 0; + } + + // Fast path: if offset is at or past end, return cached length + if pos_uft8 >= self.content_utf8().len() { + return self.len_utf16(); + } + + let mut count_utf16 = 0; + for (idx, character) in self.content_utf8().char_indices() { + if idx >= pos_uft8 { + break; + } + count_utf16 += character.len_utf16(); + } + count_utf16 + } + + fn utf_offset_16to8(&self, pos_utf16: usize) -> usize { + // Fast path: if offset is 0, return 0 + if pos_utf16 == 0 { + return 0; + } + + let mut count_utf16 = 0; + for (idx, character) in self.content_utf8().char_indices() { + if count_utf16 >= pos_utf16 { + return idx; + } + count_utf16 += character.len_utf16(); + } + self.content_utf8().len() + } + + fn utf_range_8to16(&self, range_utf8: &Range) -> Range { + self.utf_offset_8to16(range_utf8.start)..self.utf_offset_8to16(range_utf8.end) + } + + fn utf_range_16to8(&self, range_utf16: &Range) -> Range { + self.utf_offset_16to8(range_utf16.start)..self.utf_offset_16to8(range_utf16.end) + } + + fn previous_boundary(&self, offset: usize) -> usize { + if offset == 0 { + return 0; + } + + let text_before = &self.content_utf8()[..offset.min(self.content_utf8().len())]; + text_before + .grapheme_indices(true) + .map(|(i, _)| i) + .next_back() + .unwrap_or(0) + } + + fn next_boundary(&self, offset: usize) -> usize { + let len_utf8 = self.content_utf8().len(); + if offset >= len_utf8 { + return len_utf8; + } + + let text_after = &self.content_utf8()[offset..]; + text_after + .grapheme_indices(true) + .nth(1) + .map(|(i, _)| offset + i) + .unwrap_or(len_utf8) + } + + fn previous_word_boundary(&self, offset: usize) -> usize { + if offset == 0 { + return 0; + } + + let text_before = &self.content_utf8()[..offset.min(self.content_utf8().len())]; + + let mut last_word_start = 0; + for (idx, _) in text_before.unicode_word_indices() { + if idx < offset { + last_word_start = idx; + } + } + + if last_word_start == 0 && offset > 0 { + let trimmed = text_before.trim_end(); + if trimmed.is_empty() { + return 0; + } + for (idx, _) in trimmed.unicode_word_indices() { + last_word_start = idx; + } + } + + last_word_start + } + + fn next_word_boundary(&self, offset: usize) -> usize { + let len_utf8 = self.content_utf8().len(); + if offset >= len_utf8 { + return len_utf8; + } + + let text_after = &self.content_utf8()[offset..]; + + for (idx, word) in text_after.unicode_word_indices() { + let word_end = offset + idx + word.len(); + if word_end > offset { + return word_end; + } + } + + len_utf8 + } + + fn word_range_at(&self, offset: usize) -> (usize, usize) { + let offset = offset.min(self.content_utf8().len()); + + for (idx, word) in self.content_utf8().unicode_word_indices() { + let word_end = idx + word.len(); + if offset >= idx && offset <= word_end { + return (idx, word_end); + } + } + + (offset, offset) + } +} + +impl UnicodeTextStorage for String { + fn content_utf8(&self) -> &str { + self.as_str() + } + + fn len_utf16(&self) -> usize { + self.len() + } +} + +impl UnicodeTextStorage for SharedString { + fn content_utf8(&self) -> &str { + self.as_str() + } + + fn len_utf16(&self) -> usize { + self.len() + } +} From dd9d4585d6be61f6b1a4fbc81b52562478dde0e8 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 20 Jun 2026 11:57:43 -0400 Subject: [PATCH 042/117] outline reimplemented ime operations --- crates/gpui_elements/src/editable_text.rs | 13 +- .../src/editable_text/input_element.rs | 3 + .../src/editable_text/input_state.rs | 103 ++++++++++ .../gpui_elements/src/editable_text/notify.rs | 49 +++++ .../src/editable_text/shared_state.rs | 180 ++++++++++++++++++ .../src/editable_text/storage.rs | 13 +- .../src/editable_text/text_area_element.rs | 3 + .../{state_field.rs => text_area_state.rs} | 0 8 files changed, 353 insertions(+), 11 deletions(-) create mode 100644 crates/gpui_elements/src/editable_text/input_element.rs create mode 100644 crates/gpui_elements/src/editable_text/input_state.rs create mode 100644 crates/gpui_elements/src/editable_text/notify.rs create mode 100644 crates/gpui_elements/src/editable_text/shared_state.rs create mode 100644 crates/gpui_elements/src/editable_text/text_area_element.rs rename crates/gpui_elements/src/editable_text/{state_field.rs => text_area_state.rs} (100%) diff --git a/crates/gpui_elements/src/editable_text.rs b/crates/gpui_elements/src/editable_text.rs index 40e85948ef..dc434bbeca 100644 --- a/crates/gpui_elements/src/editable_text.rs +++ b/crates/gpui_elements/src/editable_text.rs @@ -1,7 +1,16 @@ mod actions; -mod state_field; +mod input_element; +mod input_state; +pub mod notify; +mod shared_state; mod storage; +mod text_area_element; +mod text_area_state; pub use actions::*; -pub use state_field::*; +pub use input_element::*; +pub use input_state::*; +pub use shared_state::*; pub use storage::*; +pub use text_area_element::*; +pub use text_area_state::*; diff --git a/crates/gpui_elements/src/editable_text/input_element.rs b/crates/gpui_elements/src/editable_text/input_element.rs new file mode 100644 index 0000000000..4800e2c35b --- /dev/null +++ b/crates/gpui_elements/src/editable_text/input_element.rs @@ -0,0 +1,3 @@ +use gpui::ElementId; + +pub fn input(id: impl Into) {} diff --git a/crates/gpui_elements/src/editable_text/input_state.rs b/crates/gpui_elements/src/editable_text/input_state.rs new file mode 100644 index 0000000000..854ee04569 --- /dev/null +++ b/crates/gpui_elements/src/editable_text/input_state.rs @@ -0,0 +1,103 @@ +use crate::editable_text::{TextInputStateBase, notify::TextChanged}; +use gpui::{ + Bounds, Context, EntityInputHandler, EventEmitter, Pixels, Point, UTF16Selection, Window, +}; +use std::ops::Range; + +pub struct TextInputState { + internal: TextInputStateBase, +} + +impl EventEmitter for TextInputState {} + +impl EntityInputHandler for TextInputState { + fn text_for_range( + &mut self, + range_utf16: Range, + adjusted_range: &mut Option>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + self.internal + .ime_text_for_range(range_utf16, adjusted_range) + } + + fn selected_text_range( + &mut self, + ignore_disabled_input: bool, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + self.internal.ime_selected_text_range(ignore_disabled_input) + } + + fn marked_text_range( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + self.internal.ime_marked_text_range() + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { + self.internal.ime_unmark_text(); + } + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + text_to_insert: &str, + _window: &mut Window, + cx: &mut Context, + ) { + let range_utf8 = self.internal.ime_resolve_range(range_utf16); + self.internal + .replace_text_in_range_bytes(range_utf8, text_to_insert); + //self.mark_layout_dirty(); + //cx.emit(CursorTrigger::PauseBlinkingForUserAction); + cx.emit(TextChanged); + cx.notify(); + } + + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + text_to_insert: &str, + new_selected_range_utf16: Option>, + _window: &mut Window, + cx: &mut Context, + ) { + let range = self.internal.ime_resolve_range(range_utf16); + self.internal + .replace_text_in_range_bytes(range.clone(), text_to_insert); + self.internal + .ime_mark_text_in_range(&range, text_to_insert.len()); + self.internal.ime_mark_selected_range( + &range, + &new_selected_range_utf16, + text_to_insert.len(), + ); + //self.mark_layout_dirty(); + cx.emit(TextChanged); + cx.notify(); + } + + fn bounds_for_range( + &mut self, + range_utf16: Range, + bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + unimplemented!() + } + + fn character_index_for_point( + &mut self, + point: Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + unimplemented!() + } +} diff --git a/crates/gpui_elements/src/editable_text/notify.rs b/crates/gpui_elements/src/editable_text/notify.rs new file mode 100644 index 0000000000..4e5e01ec65 --- /dev/null +++ b/crates/gpui_elements/src/editable_text/notify.rs @@ -0,0 +1,49 @@ +use std::{ops::Range, time::Instant}; + +use crate::editable_text::UnicodeTextStorage; + +pub struct TextChanged; + +pub struct TextHistoryPushed { + pub timestamp: Instant, + pub modified_range: Range, + pub text_payload: String, + pub new_length: usize, + pub selected_range: Range, +} +impl TextHistoryPushed { + pub fn new( + range: Range, + new_length: usize, + storage: impl UnicodeTextStorage, + selected_range: Range, + ) -> Self { + let timestamp = Instant::now(); + let modified_range = range.start..range.start + new_length; + // NOTE: not performant to allocate a new text payload if the event doesnt + // need to be logged (based on timestamp). Should consider a more robust way to access + // the storage only if it absolutely needs to be cloned from. + let text_payload = storage.content_utf8()[range].to_string(); + Self { + timestamp, + modified_range, + text_payload, + new_length, + selected_range, + } + } + + pub fn convert_to_redo(self, content: &str) -> Self { + let undo_start = self.modified_range.start; + let undo_end = (self.modified_range.start + self.new_length).min(content.len()); + let text_payload = content[undo_start..undo_end].to_string(); + let new_length = self.text_payload.len(); + Self { + timestamp: self.timestamp, + modified_range: undo_start..undo_start + self.text_payload.len(), + text_payload, + new_length, + selected_range: self.selected_range, + } + } +} diff --git a/crates/gpui_elements/src/editable_text/shared_state.rs b/crates/gpui_elements/src/editable_text/shared_state.rs new file mode 100644 index 0000000000..e4a975fb2a --- /dev/null +++ b/crates/gpui_elements/src/editable_text/shared_state.rs @@ -0,0 +1,180 @@ +use crate::editable_text::UnicodeTextStorage; +use gpui::{App, FocusHandle, Focusable, NavigationDirection, Pixels, Point, UTF16Selection}; +use std::ops::Range; + +pub struct TextInputStateBase { + storage: Box, + + /// The utf-8 character range that is currently selected by the user. + /// Valid both when start < end and start > end (which dictates the direction of the selection). Empty when start==end. + /// The start of this range is always the current position of the caret (input cursor). + /// + /// NOTE: because each input has its own selection state, its trivial for users to have multiple selections active across multiple inputs at the same time. + /// This could be considered undesirable behavior, and could prompt the question of whether there should be a mechanism to clear selection when focus is lost. + selected_range: Range, + + /// The utf-8 character range of `storage` which is being composed by IME + marked_range: Option>, + + /// True while the user is in the act of highlighting a section of the text (e.g. during mouse pressed & dragging). + is_selecting: bool, + /// The last ui location relative to the element that the user clicked. Used to filter when a user clicks multiple times in the same area. + last_click_position: Option>, + /// The number of times the user has clicked `last_click_position`. Used to determine which click behavior to trigger, depending on single, double, or triple clicks. + click_count: usize, + + focus_handle: FocusHandle, +} + +impl Focusable for TextInputStateBase { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl TextInputStateBase { + /// Creates a new `Input` with the specified multiline setting. + /// Cursor blinking is enabled by default. + pub fn new(storage: impl Into>, cx: &mut App) -> Self { + Self { + storage: storage.into(), + + selected_range: 0..0, + marked_range: None, + + is_selecting: false, + last_click_position: None, + click_count: 0, + + focus_handle: cx.focus_handle(), + } + } + + pub fn storage(&self) -> &Box { + &self.storage + } + + /// Returns the utf-8 character range that is currently selected within the current state of the text. + /// Internally converts the stored direction-aware range into a canonical range. + pub fn selected_range(&self) -> Range { + self.selected_range.start.min(self.selected_range.end) + ..self.selected_range.start.max(self.selected_range.end) + } + + pub fn selection_direction(&self) -> Option { + match self.selected_range.start.cmp(&self.selected_range.end) { + std::cmp::Ordering::Less => Some(NavigationDirection::Forward), + std::cmp::Ordering::Equal => None, + std::cmp::Ordering::Greater => Some(NavigationDirection::Back), + } + } + + pub fn caret_pos(&self) -> usize { + self.selected_range.start + } +} + +impl TextInputStateBase { + pub fn ime_text_for_range( + &self, + range_utf16: Range, + adjusted_range: &mut Option>, + ) -> Option { + let range = self.storage.utf_range_16to8(&range_utf16); + let storage_len_utf8 = self.storage.content_utf8().len(); + let clamped_range = range.start.min(storage_len_utf8)..range.end.min(storage_len_utf8); + adjusted_range.replace(self.storage.utf_range_8to16(&clamped_range)); + Some(self.storage.content_utf8()[clamped_range].to_string()) + } + + pub fn ime_selected_text_range(&self, _ignore_disabled_input: bool) -> Option { + let selection_range = self.selected_range(); + let direction = self.selection_direction(); + Some(UTF16Selection { + range: self.storage.utf_range_8to16(&selection_range), + reversed: direction == Some(NavigationDirection::Back), + }) + } + + pub fn ime_marked_text_range(&self) -> Option> { + self.marked_range + .as_ref() + .map(|range| self.storage.utf_range_8to16(range)) + } + + pub fn ime_unmark_text(&mut self) { + self.marked_range = None; + } + + pub fn ime_resolve_range(&self, range_utf16: Option>) -> Range { + // Use a series of fallbacks to pick the range to operate on. + // Fallback order: IME provided range, active IME marked range, selection + let range = range_utf16.map(|range_utf16| self.storage.utf_range_16to8(&range_utf16)); + let range = range.or_else(|| self.marked_range.clone()); + let range = range.unwrap_or_else(|| self.selected_range()); + + let storage_len_utf8 = self.storage().content_utf8().len(); + range.start.min(storage_len_utf8)..range.end.min(storage_len_utf8) + } + + pub fn replace_text(&mut self, start: usize, end: usize, new_text: &str) { + let storage_len_utf8 = self.storage.content_utf8().len(); + let start = start.min(storage_len_utf8); + let end = end.max(start).min(storage_len_utf8); + self.storage.replace_range(start..end, new_text); + + let new_caret = start + new_text.len(); + self.selected_range = new_caret..new_caret; + } + + pub fn replace_text_in_range_bytes(&mut self, range: Range, mut text_to_insert: &str) { + // TODO: Apply text sanitization + // single-line fields should prune \n and \r + // fields should be able to provide a max_length or other validations on text-input + + let max_length = None::; + + // Decide the effective new text up front (honouring `max_length`). + // This avoids the "apply, then truncate" path which would leave the caret past the end. + if let Some(cap) = max_length { + let existing_len = self.storage().content_utf8().len() - (range.end - range.start); + let room = cap.saturating_sub(existing_len); + text_to_insert = &text_to_insert[..text_to_insert.len().min(room)]; + } + + // TODO: Push history diff + // self.push_undo_patch(range.clone(), text_to_insert.len()); + + self.storage.replace_range(range, text_to_insert); + self.marked_range = None; + + // TODO: caller emits events + } + + pub fn ime_mark_text_in_range(&mut self, range: &Range, text_len: usize) { + self.marked_range = match text_len { + 0 => None, + _ => Some(range.start..range.start + text_len), + }; + } + + pub fn ime_mark_selected_range( + &mut self, + range_overwritten: &Range, + new_selected_range_utf16: &Option>, + text_len: usize, + ) { + // NOTE: Differs from yororen-ui + // https://github.com/MeowLynxSea/yororen-ui/blob/346502ac654b77fdaff3be2d7444fca8783acfc9/crates/yororen-ui-core/src/headless/text_input_core.rs#L359-L371 + self.selected_range = { + let new_range = new_selected_range_utf16.as_ref(); + let new_range = new_range.map(|range_utf16| self.storage.utf_range_16to8(range_utf16)); + let new_range = new_range.map(|new_range| { + new_range.start + range_overwritten.start..new_range.end + range_overwritten.start + }); + new_range.unwrap_or_else(|| { + range_overwritten.start + text_len..range_overwritten.start + text_len + }) + }; + } +} diff --git a/crates/gpui_elements/src/editable_text/storage.rs b/crates/gpui_elements/src/editable_text/storage.rs index a2ab788c9a..1907d70107 100644 --- a/crates/gpui_elements/src/editable_text/storage.rs +++ b/crates/gpui_elements/src/editable_text/storage.rs @@ -1,4 +1,3 @@ -use gpui::SharedString; use std::ops::Range; use unicode_segmentation::UnicodeSegmentation; @@ -9,6 +8,8 @@ pub trait UnicodeTextStorage { /// Returns the UTF-16 length of the content. fn len_utf16(&self) -> usize; + fn replace_range(&mut self, range: Range, text: &str); + fn utf_offset_8to16(&self, pos_uft8: usize) -> usize { // Fast path: if offset is 0, return 0 if pos_uft8 == 0 { @@ -148,14 +149,8 @@ impl UnicodeTextStorage for String { fn len_utf16(&self) -> usize { self.len() } -} -impl UnicodeTextStorage for SharedString { - fn content_utf8(&self) -> &str { - self.as_str() - } - - fn len_utf16(&self) -> usize { - self.len() + fn replace_range(&mut self, range: Range, text: &str) { + self.replace_range(range, &text); } } diff --git a/crates/gpui_elements/src/editable_text/text_area_element.rs b/crates/gpui_elements/src/editable_text/text_area_element.rs new file mode 100644 index 0000000000..cad2948606 --- /dev/null +++ b/crates/gpui_elements/src/editable_text/text_area_element.rs @@ -0,0 +1,3 @@ +use gpui::ElementId; + +pub fn text_area(id: impl Into) {} diff --git a/crates/gpui_elements/src/editable_text/state_field.rs b/crates/gpui_elements/src/editable_text/text_area_state.rs similarity index 100% rename from crates/gpui_elements/src/editable_text/state_field.rs rename to crates/gpui_elements/src/editable_text/text_area_state.rs From 93248e8ce97c149e5eb5648762ea737a3fb0d7ce Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 20 Jun 2026 14:07:55 -0400 Subject: [PATCH 043/117] implement keybinding actions --- .../src/editable_text/actions.rs | 104 +++-- .../src/editable_text/input_state.rs | 276 +++++++++++++- .../gpui_elements/src/editable_text/notify.rs | 2 +- .../src/editable_text/shared_state.rs | 257 ++++++++++++- .../src/editable_text/storage.rs | 63 ++++ .../src/editable_text/text_area_state.rs | 354 ++++++++++++++++++ crates/gpui_elements/src/input/state.rs | 6 +- 7 files changed, 999 insertions(+), 63 deletions(-) diff --git a/crates/gpui_elements/src/editable_text/actions.rs b/crates/gpui_elements/src/editable_text/actions.rs index aac530dfba..a97bb7f658 100644 --- a/crates/gpui_elements/src/editable_text/actions.rs +++ b/crates/gpui_elements/src/editable_text/actions.rs @@ -1,4 +1,4 @@ -use gpui::{App, Window}; +use gpui::{App, AppContext, Window}; /// The key context used for input element keybindings. pub const DEFAULT_INPUT_CONTEXT: &str = "Input"; @@ -142,70 +142,94 @@ pub fn default_bindings() -> gpui::ActionBindingCollection { bindings } -pub trait EditableTextActionHandler { - fn escape(&mut self, _: &Escape, _w: &mut Window, _cx: &mut App) {} +pub trait EditableTextActionHandler<'app>: Sized { + type Context: AppContext; - fn insert_enter(&mut self, _: &Enter, _w: &mut Window, _cx: &mut App) {} - fn insert_tab(&mut self, _: &Tab, _w: &mut Window, _cx: &mut App) {} + fn escape(&mut self, _: &Escape, _w: &mut Window, _cx: &mut Self::Context) {} - fn backspace(&mut self, _: &Backspace, _w: &mut Window, _cx: &mut App) {} - fn delete(&mut self, _: &Delete, _w: &mut Window, _cx: &mut App) {} + fn insert_enter(&mut self, _: &Enter, _w: &mut Window, _cx: &mut Self::Context) {} + fn insert_tab(&mut self, _: &Tab, _w: &mut Window, _cx: &mut Self::Context) {} - fn delete_word_left(&mut self, _: &DeleteWordLeft, _w: &mut Window, _cx: &mut App) {} - fn delete_word_right(&mut self, _: &DeleteWordRight, _w: &mut Window, _cx: &mut App) {} + fn backspace(&mut self, _: &Backspace, _w: &mut Window, _cx: &mut Self::Context) {} + fn delete(&mut self, _: &Delete, _w: &mut Window, _cx: &mut Self::Context) {} + + fn delete_word_left(&mut self, _: &DeleteWordLeft, _w: &mut Window, _cx: &mut Self::Context) {} + fn delete_word_right(&mut self, _: &DeleteWordRight, _w: &mut Window, _cx: &mut Self::Context) { + } fn delete_to_line_start( &mut self, _: &DeleteToBeginningOfLine, _w: &mut Window, - _cx: &mut App, + _cx: &mut Self::Context, + ) { + } + fn delete_to_line_end( + &mut self, + _: &DeleteToEndOfLine, + _w: &mut Window, + _cx: &mut Self::Context, ) { } - fn delete_to_line_end(&mut self, _: &DeleteToEndOfLine, _w: &mut Window, _cx: &mut App) {} - fn nav_left(&mut self, _: &Left, _w: &mut Window, _cx: &mut App) {} - fn nav_right(&mut self, _: &Right, _w: &mut Window, _cx: &mut App) {} - fn nav_up(&mut self, _: &Up, _w: &mut Window, _cx: &mut App) {} - fn nav_down(&mut self, _: &Down, _w: &mut Window, _cx: &mut App) {} - fn nav_line_start(&mut self, _: &Home, _w: &mut Window, _cx: &mut App) {} - fn nav_line_end(&mut self, _: &End, _w: &mut Window, _cx: &mut App) {} - fn nav_start(&mut self, _: &MoveToBeginning, _w: &mut Window, _cx: &mut App) {} - fn nav_end(&mut self, _: &MoveToEnd, _w: &mut Window, _cx: &mut App) {} - fn nav_left_word(&mut self, _: &WordLeft, _w: &mut Window, _cx: &mut App) {} - fn nav_right_word(&mut self, _: &WordRight, _w: &mut Window, _cx: &mut App) {} + fn nav_left(&mut self, _: &Left, _w: &mut Window, _cx: &mut Self::Context) {} + fn nav_right(&mut self, _: &Right, _w: &mut Window, _cx: &mut Self::Context) {} + fn nav_up(&mut self, _: &Up, _w: &mut Window, _cx: &mut Self::Context) {} + fn nav_down(&mut self, _: &Down, _w: &mut Window, _cx: &mut Self::Context) {} + fn nav_line_start(&mut self, _: &Home, _w: &mut Window, _cx: &mut Self::Context) {} + fn nav_line_end(&mut self, _: &End, _w: &mut Window, _cx: &mut Self::Context) {} + fn nav_start(&mut self, _: &MoveToBeginning, _w: &mut Window, _cx: &mut Self::Context) {} + fn nav_end(&mut self, _: &MoveToEnd, _w: &mut Window, _cx: &mut Self::Context) {} + fn nav_left_word(&mut self, _: &WordLeft, _w: &mut Window, _cx: &mut Self::Context) {} + fn nav_right_word(&mut self, _: &WordRight, _w: &mut Window, _cx: &mut Self::Context) {} - fn select_all(&mut self, _: &SelectAll, _w: &mut Window, _cx: &mut App) {} - fn select_left(&mut self, _: &SelectLeft, _w: &mut Window, _cx: &mut App) {} - fn select_right(&mut self, _: &SelectRight, _w: &mut Window, _cx: &mut App) {} - fn select_up(&mut self, _: &SelectUp, _w: &mut Window, _cx: &mut App) {} - fn select_down(&mut self, _: &SelectDown, _w: &mut Window, _cx: &mut App) {} - fn select_start(&mut self, _: &SelectToBeginning, _w: &mut Window, _cx: &mut App) {} - fn select_end(&mut self, _: &SelectToEnd, _w: &mut Window, _cx: &mut App) {} - fn select_left_word(&mut self, _: &SelectWordLeft, _w: &mut Window, _cx: &mut App) {} - fn select_right_word(&mut self, _: &SelectWordRight, _w: &mut Window, _cx: &mut App) {} + fn select_all(&mut self, _: &SelectAll, _w: &mut Window, _cx: &mut Self::Context) {} + fn select_left(&mut self, _: &SelectLeft, _w: &mut Window, _cx: &mut Self::Context) {} + fn select_right(&mut self, _: &SelectRight, _w: &mut Window, _cx: &mut Self::Context) {} + fn select_up(&mut self, _: &SelectUp, _w: &mut Window, _cx: &mut Self::Context) {} + fn select_down(&mut self, _: &SelectDown, _w: &mut Window, _cx: &mut Self::Context) {} + fn select_start(&mut self, _: &SelectToBeginning, _w: &mut Window, _cx: &mut Self::Context) {} + fn select_end(&mut self, _: &SelectToEnd, _w: &mut Window, _cx: &mut Self::Context) {} + fn select_left_word(&mut self, _: &SelectWordLeft, _w: &mut Window, _cx: &mut Self::Context) {} + fn select_right_word(&mut self, _: &SelectWordRight, _w: &mut Window, _cx: &mut Self::Context) { + } - fn cut(&mut self, _: &Cut, _w: &mut Window, _cx: &mut App) {} - fn copy(&mut self, _: &Copy, _w: &mut Window, _cx: &mut App) {} - fn paste(&mut self, _: &Paste, _w: &mut Window, _cx: &mut App) {} + fn cut(&mut self, _: &Cut, _w: &mut Window, _cx: &mut Self::Context) {} + fn copy(&mut self, _: &Copy, _w: &mut Window, _cx: &mut Self::Context) {} + fn paste(&mut self, _: &Paste, _w: &mut Window, _cx: &mut Self::Context) {} - fn undo(&mut self, _: &Undo, _w: &mut Window, _cx: &mut App) {} - fn redo(&mut self, _: &Redo, _w: &mut Window, _cx: &mut App) {} + fn undo(&mut self, _: &Undo, _w: &mut Window, _cx: &mut Self::Context) {} + fn redo(&mut self, _: &Redo, _w: &mut Window, _cx: &mut Self::Context) {} fn show_character_palette( &mut self, _: &ShowCharacterPalette, window: &mut Window, - _cx: &mut App, + _cx: &mut Self::Context, ) { window.show_character_palette(); } fn on_mouse_down( &mut self, - _position: gpui::Point, + _event: &gpui::MouseDownEvent, + _text_position: gpui::Point, _w: &mut Window, - _cx: &mut App, + _cx: &mut Self::Context, + ) { + } + fn on_mouse_up( + &mut self, + _event: &gpui::MouseUpEvent, + _w: &mut Window, + _cx: &mut Self::Context, + ) { + } + fn on_mouse_move( + &mut self, + _event: &gpui::MouseMoveEvent, + _text_position: gpui::Point, + _w: &mut Window, + _cx: &mut Self::Context, ) { } - fn on_mouse_up(&mut self, _event: &gpui::MouseUpEvent, _w: &mut Window, _cx: &mut App) {} - fn on_mouse_move(&mut self, _event: &gpui::MouseMoveEvent, _w: &mut Window, _cx: &mut App) {} } diff --git a/crates/gpui_elements/src/editable_text/input_state.rs b/crates/gpui_elements/src/editable_text/input_state.rs index 854ee04569..2baceab4a0 100644 --- a/crates/gpui_elements/src/editable_text/input_state.rs +++ b/crates/gpui_elements/src/editable_text/input_state.rs @@ -1,6 +1,11 @@ -use crate::editable_text::{TextInputStateBase, notify::TextChanged}; +use super::notify::TextHistoryPushed; +use crate::editable_text::{ + EditableTextActionHandler, TextBoundary, TextInputStateBase, TextStateNotifier, + notify::TextChanged, +}; use gpui::{ - Bounds, Context, EntityInputHandler, EventEmitter, Pixels, Point, UTF16Selection, Window, + Bounds, Context, EntityInputHandler, EventEmitter, NavigationDirection, Pixels, Point, + UTF16Selection, Window, }; use std::ops::Range; @@ -9,6 +14,21 @@ pub struct TextInputState { } impl EventEmitter for TextInputState {} +impl EventEmitter for TextInputState {} + +impl TextStateNotifier for Context<'_, TextInputState> { + fn notify_changed(&mut self) { + self.notify(); + } + + fn emit_text_changed(&mut self, event: TextChanged) { + self.emit(event); + } + + fn emit_history(&mut self, event: TextHistoryPushed) { + self.emit(event); + } +} impl EntityInputHandler for TextInputState { fn text_for_range( @@ -52,11 +72,11 @@ impl EntityInputHandler for TextInputState { ) { let range_utf8 = self.internal.ime_resolve_range(range_utf16); self.internal - .replace_text_in_range_bytes(range_utf8, text_to_insert); + .replace_text_in_range_bytes(range_utf8, text_to_insert, cx); //self.mark_layout_dirty(); //cx.emit(CursorTrigger::PauseBlinkingForUserAction); - cx.emit(TextChanged); - cx.notify(); + cx.emit_text_changed(TextChanged); + cx.notify_changed(); } fn replace_and_mark_text_in_range( @@ -69,7 +89,7 @@ impl EntityInputHandler for TextInputState { ) { let range = self.internal.ime_resolve_range(range_utf16); self.internal - .replace_text_in_range_bytes(range.clone(), text_to_insert); + .replace_text_in_range_bytes(range.clone(), text_to_insert, cx); self.internal .ime_mark_text_in_range(&range, text_to_insert.len()); self.internal.ime_mark_selected_range( @@ -78,8 +98,8 @@ impl EntityInputHandler for TextInputState { text_to_insert.len(), ); //self.mark_layout_dirty(); - cx.emit(TextChanged); - cx.notify(); + cx.emit_text_changed(TextChanged); + cx.notify_changed(); } fn bounds_for_range( @@ -101,3 +121,243 @@ impl EntityInputHandler for TextInputState { unimplemented!() } } + +impl<'app> EditableTextActionHandler<'app> for TextInputState { + type Context = gpui::Context<'app, Self>; + + fn escape(&mut self, _: &super::Escape, window: &mut Window, cx: &mut Self::Context) { + self.internal.set_selected_range(0..0); + cx.notify(); + + window.blur(); + } + + fn insert_enter(&mut self, _: &super::Enter, _w: &mut Window, _cx: &mut Self::Context) {} + + fn insert_tab(&mut self, _: &super::Tab, window: &mut Window, cx: &mut Self::Context) { + self.replace_text_in_range(None, "\t", window, cx); + } + + fn backspace(&mut self, _: &super::Backspace, _: &mut Window, cx: &mut Self::Context) { + self.internal + .delete(NavigationDirection::Back, TextBoundary::Graphmeme, cx); + } + + fn delete(&mut self, _: &super::Delete, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .delete(NavigationDirection::Forward, TextBoundary::Graphmeme, cx); + } + + fn delete_word_left( + &mut self, + _: &super::DeleteWordLeft, + _w: &mut Window, + cx: &mut Self::Context, + ) { + self.internal + .delete(NavigationDirection::Back, TextBoundary::Word, cx); + } + + fn delete_word_right( + &mut self, + _: &super::DeleteWordRight, + _w: &mut Window, + cx: &mut Self::Context, + ) { + self.internal + .delete(NavigationDirection::Forward, TextBoundary::Word, cx); + } + + fn delete_to_line_start( + &mut self, + _: &super::DeleteToBeginningOfLine, + _w: &mut Window, + cx: &mut Self::Context, + ) { + self.internal + .delete(NavigationDirection::Back, TextBoundary::Line, cx); + } + + fn delete_to_line_end( + &mut self, + _: &super::DeleteToEndOfLine, + _w: &mut Window, + cx: &mut Self::Context, + ) { + self.internal + .delete(NavigationDirection::Forward, TextBoundary::Line, cx); + } + + fn nav_left(&mut self, _: &super::Left, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .nav_linear(NavigationDirection::Back, TextBoundary::Graphmeme, cx); + } + + fn nav_right(&mut self, _: &super::Right, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .nav_linear(NavigationDirection::Forward, TextBoundary::Graphmeme, cx); + } + + fn nav_up(&mut self, _: &super::Up, _w: &mut Window, cx: &mut Self::Context) { + // semantically equivalent to line + self.internal + .nav_linear(NavigationDirection::Back, TextBoundary::Line, cx); + } + + fn nav_down(&mut self, _: &super::Down, _w: &mut Window, cx: &mut Self::Context) { + // semantically equivalent to line + self.internal + .nav_linear(NavigationDirection::Forward, TextBoundary::Line, cx); + } + + fn nav_line_start(&mut self, _: &super::Home, _w: &mut Window, cx: &mut Self::Context) { + // semantically equivalent to document + self.internal + .nav_linear(NavigationDirection::Back, TextBoundary::Line, cx); + } + + fn nav_line_end(&mut self, _: &super::End, _w: &mut Window, cx: &mut Self::Context) { + // semantically equivalent to document + self.internal + .nav_linear(NavigationDirection::Forward, TextBoundary::Line, cx); + } + + fn nav_start(&mut self, _: &super::MoveToBeginning, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .nav_linear(NavigationDirection::Back, TextBoundary::Document, cx); + } + + fn nav_end(&mut self, _: &super::MoveToEnd, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .nav_linear(NavigationDirection::Forward, TextBoundary::Document, cx); + } + + fn nav_left_word(&mut self, _: &super::WordLeft, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .nav_linear(NavigationDirection::Back, TextBoundary::Word, cx); + } + + fn nav_right_word(&mut self, _: &super::WordRight, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .nav_linear(NavigationDirection::Forward, TextBoundary::Word, cx); + } + + fn select_all(&mut self, _: &super::SelectAll, _w: &mut Window, cx: &mut Self::Context) { + self.internal.select_all(cx); + } + + fn select_left(&mut self, _: &super::SelectLeft, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .select_linear(NavigationDirection::Back, TextBoundary::Graphmeme, cx); + } + + fn select_right(&mut self, _: &super::SelectRight, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .select_linear(NavigationDirection::Forward, TextBoundary::Graphmeme, cx); + } + + fn select_up(&mut self, _: &super::SelectUp, _w: &mut Window, cx: &mut Self::Context) { + // semantically equivalent to select document + self.internal + .select_linear(NavigationDirection::Back, TextBoundary::Document, cx); + } + + fn select_down(&mut self, _: &super::SelectDown, _w: &mut Window, cx: &mut Self::Context) { + // semantically equivalent to select document + self.internal + .select_linear(NavigationDirection::Forward, TextBoundary::Document, cx); + } + + fn select_start( + &mut self, + _: &super::SelectToBeginning, + _w: &mut Window, + cx: &mut Self::Context, + ) { + self.internal + .select_linear(NavigationDirection::Back, TextBoundary::Document, cx); + } + + fn select_end(&mut self, _: &super::SelectToEnd, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .select_linear(NavigationDirection::Forward, TextBoundary::Document, cx); + } + + fn select_left_word( + &mut self, + _: &super::SelectWordLeft, + _w: &mut Window, + cx: &mut Self::Context, + ) { + self.internal + .select_linear(NavigationDirection::Back, TextBoundary::Word, cx); + } + + fn select_right_word( + &mut self, + _: &super::SelectWordRight, + _w: &mut Window, + cx: &mut Self::Context, + ) { + self.internal + .select_linear(NavigationDirection::Forward, TextBoundary::Word, cx); + } + + fn cut(&mut self, _: &super::Cut, _w: &mut Window, cx: &mut Self::Context) { + self.internal.cut(cx); + } + + fn copy(&mut self, _: &super::Copy, _w: &mut Window, cx: &mut Self::Context) { + self.internal.copy(cx); + } + + fn paste(&mut self, _: &super::Paste, _w: &mut Window, cx: &mut Self::Context) { + self.internal.paste(cx); + } + + fn undo(&mut self, _: &super::Undo, _w: &mut Window, _cx: &mut Self::Context) { + // TODO: STUB + } + + fn redo(&mut self, _: &super::Redo, _w: &mut Window, _cx: &mut Self::Context) { + // TODO: STUB + } + + fn on_mouse_down( + &mut self, + event: &gpui::MouseDownEvent, + text_position: gpui::Point, + window: &mut Window, + cx: &mut Self::Context, + ) { + let character_pos = self.internal.caret_pos(); // TODO: Should be index_for_pixel_point + self.internal.on_mouse_down( + text_position, + character_pos, + event.click_count, + event.modifiers.shift, + window, + cx, + ); + } + + fn on_mouse_up( + &mut self, + _event: &gpui::MouseUpEvent, + _w: &mut Window, + _cx: &mut Self::Context, + ) { + self.internal.on_mouse_up(); + } + + fn on_mouse_move( + &mut self, + _event: &gpui::MouseMoveEvent, + text_position: Point, + _w: &mut Window, + cx: &mut Self::Context, + ) { + let character_pos = self.internal.caret_pos(); // TODO: Should be index_for_pixel_point + self.internal.on_mouse_move(character_pos, cx); + } +} diff --git a/crates/gpui_elements/src/editable_text/notify.rs b/crates/gpui_elements/src/editable_text/notify.rs index 4e5e01ec65..4fcb0467ff 100644 --- a/crates/gpui_elements/src/editable_text/notify.rs +++ b/crates/gpui_elements/src/editable_text/notify.rs @@ -15,7 +15,7 @@ impl TextHistoryPushed { pub fn new( range: Range, new_length: usize, - storage: impl UnicodeTextStorage, + storage: &dyn UnicodeTextStorage, selected_range: Range, ) -> Self { let timestamp = Instant::now(); diff --git a/crates/gpui_elements/src/editable_text/shared_state.rs b/crates/gpui_elements/src/editable_text/shared_state.rs index e4a975fb2a..b40428d140 100644 --- a/crates/gpui_elements/src/editable_text/shared_state.rs +++ b/crates/gpui_elements/src/editable_text/shared_state.rs @@ -1,7 +1,19 @@ -use crate::editable_text::UnicodeTextStorage; -use gpui::{App, FocusHandle, Focusable, NavigationDirection, Pixels, Point, UTF16Selection}; +use crate::editable_text::{ + TextBoundary, UnicodeTextStorage, + notify::{TextChanged, TextHistoryPushed}, +}; +use gpui::{ + App, AppContext, ClipboardItem, FocusHandle, Focusable, NavigationDirection, Pixels, Point, + UTF16Selection, Window, +}; use std::ops::Range; +pub trait TextStateNotifier { + fn notify_changed(&mut self); + fn emit_text_changed(&mut self, event: TextChanged); + fn emit_history(&mut self, event: TextHistoryPushed); +} + pub struct TextInputStateBase { storage: Box, @@ -72,6 +84,10 @@ impl TextInputStateBase { pub fn caret_pos(&self) -> usize { self.selected_range.start } + + pub fn set_selected_range(&mut self, range: Range) { + self.selected_range = range; + } } impl TextInputStateBase { @@ -117,17 +133,36 @@ impl TextInputStateBase { range.start.min(storage_len_utf8)..range.end.min(storage_len_utf8) } - pub fn replace_text(&mut self, start: usize, end: usize, new_text: &str) { + pub fn replace_text(&mut self, range: &Range, new_text: &str) { let storage_len_utf8 = self.storage.content_utf8().len(); - let start = start.min(storage_len_utf8); - let end = end.max(start).min(storage_len_utf8); + let start = range.start.min(storage_len_utf8); + let end = range.end.max(start).min(storage_len_utf8); self.storage.replace_range(start..end, new_text); let new_caret = start + new_text.len(); self.selected_range = new_caret..new_caret; } - pub fn replace_text_in_range_bytes(&mut self, range: Range, mut text_to_insert: &str) { + fn emit_change_for_undo( + &self, + cx: &mut impl TextStateNotifier, + range: Range, + length: usize, + ) { + cx.emit_history(TextHistoryPushed::new( + range.clone(), + length, + &*self.storage, + self.selected_range.clone(), + )); + } + + pub fn replace_text_in_range_bytes( + &mut self, + range: Range, + mut text_to_insert: &str, + cx: &mut impl TextStateNotifier, + ) { // TODO: Apply text sanitization // single-line fields should prune \n and \r // fields should be able to provide a max_length or other validations on text-input @@ -142,13 +177,9 @@ impl TextInputStateBase { text_to_insert = &text_to_insert[..text_to_insert.len().min(room)]; } - // TODO: Push history diff - // self.push_undo_patch(range.clone(), text_to_insert.len()); - + self.emit_change_for_undo(cx, range.clone(), text_to_insert.len()); self.storage.replace_range(range, text_to_insert); self.marked_range = None; - - // TODO: caller emits events } pub fn ime_mark_text_in_range(&mut self, range: &Range, text_len: usize) { @@ -178,3 +209,207 @@ impl TextInputStateBase { }; } } + +impl TextInputStateBase { + fn move_to(&mut self, caret_pos: usize) { + //cx.emit(CursorTrigger::PauseBlinkingForUserAction); + let caret_pos = caret_pos.min(self.storage.content_utf8().len()); + self.selected_range = caret_pos..caret_pos; + //self.scroll_to_cursor(); + //cx.notify_changed(); + } + + fn select_to(&mut self, caret_pos: usize) { + //cx.emit(CursorTrigger::PauseBlinkingForUserAction); + let caret_pos = caret_pos.min(self.storage().content_utf8().len()); + self.selected_range = caret_pos..self.selected_range.start; + //self.scroll_to_cursor(); + //cx.notify_changed(); + } + + pub fn delete( + &mut self, + direction: NavigationDirection, + boundary: TextBoundary, + cx: &mut impl TextStateNotifier, + ) { + let range = self.selected_range(); + let range = match range.is_empty() { + false => range, + true => self + .storage + .range_from_caret(self.caret_pos(), direction, boundary), + }; + + self.emit_change_for_undo(cx, range.clone(), 0); + + self.replace_text(&range, ""); + self.marked_range = None; + + cx.emit_text_changed(TextChanged); + cx.notify_changed(); + } + + pub fn nav_linear( + &mut self, + direction: NavigationDirection, + boundary: TextBoundary, + _cx: &mut impl TextStateNotifier, + ) { + let caret_pos = match self.selected_range.is_empty() { + false => match direction { + NavigationDirection::Back => self.selected_range.start, + NavigationDirection::Forward => self.selected_range.end, + }, + true => self + .storage + .offset_from_caret(self.caret_pos(), direction, boundary), + }; + self.move_to(caret_pos); + } + + pub fn select_all(&mut self, _cx: &mut impl TextStateNotifier) { + self.selected_range = 0..self.storage.content_utf8().len(); + } + + pub fn select_linear( + &mut self, + direction: NavigationDirection, + boundary: TextBoundary, + _cx: &mut impl TextStateNotifier, + ) { + let caret_pos = self + .storage + .offset_from_caret(self.caret_pos(), direction, boundary); + self.select_to(caret_pos); + } + + pub fn cut(&mut self, cx: &mut T) + where + T: TextStateNotifier + std::ops::Deref, + { + if !self.selected_range.is_empty() { + // Cut selected text + let slice = &self.storage.content_utf8()[self.selected_range.clone()]; + cx.write_to_clipboard(ClipboardItem::new_string(slice.to_string())); + self.replace_text_in_range_bytes(self.selected_range.clone(), "", cx); + } else { + // No selection: cut the entire current line (including newline) + let caret = self.caret_pos(); + let line_start = self.storage.find_line_start(caret); + let line_end = self.storage.find_line_end(caret); + let storage_len_utf8 = self.storage.content_utf8().len(); + + // Include the newline character if there is one after the line + let cut_end = if line_end < storage_len_utf8 { + line_end + 1 // Include the newline + } else if line_start > 0 { + // Last line with no trailing newline - include preceding newline instead + line_end + } else { + line_end + }; + + // For last line, also remove the preceding newline if it exists + let cut_start = if line_end >= storage_len_utf8 && line_start > 0 { + line_start - 1 // Include preceding newline for last line + } else { + line_start + }; + + self.selected_range = cut_start..cut_end; + + let slice = &self.storage.content_utf8()[self.selected_range.clone()]; + cx.write_to_clipboard(ClipboardItem::new_string(slice.to_string())); + + self.replace_text_in_range_bytes(self.selected_range.clone(), "", cx); + } + cx.emit_text_changed(TextChanged); + cx.notify_changed(); + } + + pub fn copy(&mut self, app: &mut App) { + if !self.selected_range.is_empty() { + let slice = &self.storage.content_utf8()[self.selected_range.clone()]; + app.write_to_clipboard(ClipboardItem::new_string(slice.to_string())); + } + } + + pub fn paste(&mut self, cx: &mut T) + where + T: TextStateNotifier + std::ops::Deref, + { + let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { + return; + }; + self.replace_text_in_range_bytes(self.ime_resolve_range(None), &text, cx); + cx.emit_text_changed(TextChanged); + cx.notify_changed(); + } + + pub fn on_mouse_down( + &mut self, + position: Point, + character_pos: usize, + click_count: usize, + shift: bool, + window: &mut Window, + cx: &mut Context, + ) where + Context: TextStateNotifier + std::ops::DerefMut, + { + window.focus(&self.focus_handle, cx); + self.is_selecting = true; + + let is_same_position = self + .last_click_position + .map(|last| { + let threshold = gpui::px(4.); + (position.x - last.x).abs() < threshold && (position.y - last.y).abs() < threshold + }) + .unwrap_or(false); + + if is_same_position && click_count > 1 { + self.click_count = click_count; + } else { + self.click_count = 1; + } + self.last_click_position = Some(position); + + match self.click_count { + 2 => { + let (word_start, word_end) = self.storage.word_range_at(character_pos); + self.selected_range = word_start..word_end; + //cx.notify(); + } + 3 => { + let line_start = self.storage.find_line_start(character_pos); + let line_end = self.storage.find_line_end(character_pos); + let line_end_with_newline = if line_end < self.storage.content_utf8().len() { + line_end + 1 + } else { + line_end + }; + self.selected_range = line_start..line_end_with_newline; + //cx.notify(); + } + _ => { + if shift { + self.select_to(character_pos); + } else { + self.move_to(character_pos); + } + } + } + } + + pub fn on_mouse_up(&mut self) { + self.is_selecting = false; + } + + pub fn on_mouse_move(&mut self, character_pos: usize, _cx: &mut impl TextStateNotifier) { + if self.is_selecting && self.click_count == 1 { + self.select_to(character_pos); + } + } +} diff --git a/crates/gpui_elements/src/editable_text/storage.rs b/crates/gpui_elements/src/editable_text/storage.rs index 1907d70107..1cd990cff8 100644 --- a/crates/gpui_elements/src/editable_text/storage.rs +++ b/crates/gpui_elements/src/editable_text/storage.rs @@ -1,6 +1,18 @@ +use gpui::NavigationDirection; use std::ops::Range; use unicode_segmentation::UnicodeSegmentation; +pub enum TextBoundary { + /// The next utf-8 character in a direction from the caret + Graphmeme, + /// The next word in a direction from the caret + Word, + /// The start/end of the current line + Line, + /// The rest of the text to the start/end of a document + Document, +} + pub trait UnicodeTextStorage { /// Returns a reference to the utf8 string. fn content_utf8(&self) -> &str; @@ -127,6 +139,57 @@ pub trait UnicodeTextStorage { len_utf8 } + /// Returns the utf-8 character position of first character after the first new-line preceeding the character at the provided utf-8 character position. + fn find_line_start(&self, position: usize) -> usize { + let content = self.content_utf8(); + content[..position.min(content.len())] + .rfind('\n') + .map(|pos| pos + 1) + .unwrap_or(0) + } + + /// Returns the utf-8 character position of the character immediately before the first new-line character after the character at the provided utf-8 character position. + fn find_line_end(&self, position: usize) -> usize { + let content = self.content_utf8(); + content[position.min(content.len())..] + .find('\n') + .map(|pos| position + pos) + .unwrap_or(content.len()) + } + + fn range_from_caret( + &self, + caret: usize, + direction: NavigationDirection, + magnitude: TextBoundary, + ) -> Range { + let offset = self.offset_from_caret(caret, direction, magnitude); + match direction { + NavigationDirection::Back => offset..caret, + NavigationDirection::Forward => caret..offset, + } + } + + fn offset_from_caret( + &self, + caret: usize, + direction: NavigationDirection, + magnitude: TextBoundary, + ) -> usize { + use NavigationDirection::*; + use TextBoundary::*; + match (direction, magnitude) { + (Back, Graphmeme) => self.previous_boundary(caret), + (Forward, Graphmeme) => self.next_boundary(caret), + (Back, Word) => self.previous_word_boundary(caret), + (Forward, Word) => self.next_word_boundary(caret), + (Back, Line) => self.find_line_start(caret), + (Forward, Line) => self.find_line_end(caret), + (Back, Document) => 0, + (Forward, Document) => self.content_utf8().len(), + } + } + fn word_range_at(&self, offset: usize) -> (usize, usize) { let offset = offset.min(self.content_utf8().len()); diff --git a/crates/gpui_elements/src/editable_text/text_area_state.rs b/crates/gpui_elements/src/editable_text/text_area_state.rs index e69de29bb2..628e135e73 100644 --- a/crates/gpui_elements/src/editable_text/text_area_state.rs +++ b/crates/gpui_elements/src/editable_text/text_area_state.rs @@ -0,0 +1,354 @@ +use crate::editable_text::{ + EditableTextActionHandler, TextBoundary, TextInputStateBase, TextStateNotifier, + notify::{TextChanged, TextHistoryPushed}, +}; +use gpui::{ + Bounds, Context, EntityInputHandler, EventEmitter, NavigationDirection, Pixels, Point, + UTF16Selection, Window, +}; +use std::ops::Range; + +pub struct TextAreaState { + internal: TextInputStateBase, +} + +impl EventEmitter for TextAreaState {} +impl EventEmitter for TextAreaState {} + +impl TextStateNotifier for Context<'_, TextAreaState> { + fn notify_changed(&mut self) { + self.notify(); + } + + fn emit_text_changed(&mut self, event: TextChanged) { + self.emit(event); + } + + fn emit_history(&mut self, event: TextHistoryPushed) { + self.emit(event); + } +} + +impl EntityInputHandler for TextAreaState { + fn text_for_range( + &mut self, + range_utf16: Range, + adjusted_range: &mut Option>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + self.internal + .ime_text_for_range(range_utf16, adjusted_range) + } + + fn selected_text_range( + &mut self, + ignore_disabled_input: bool, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + self.internal.ime_selected_text_range(ignore_disabled_input) + } + + fn marked_text_range( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + self.internal.ime_marked_text_range() + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { + self.internal.ime_unmark_text(); + } + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + text_to_insert: &str, + _window: &mut Window, + cx: &mut Context, + ) { + let range_utf8 = self.internal.ime_resolve_range(range_utf16); + self.internal + .replace_text_in_range_bytes(range_utf8, text_to_insert, cx); + //self.mark_layout_dirty(); + //cx.emit(CursorTrigger::PauseBlinkingForUserAction); + cx.emit_text_changed(TextChanged); + cx.notify_changed(); + } + + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + text_to_insert: &str, + new_selected_range_utf16: Option>, + _window: &mut Window, + cx: &mut Context, + ) { + let range = self.internal.ime_resolve_range(range_utf16); + self.internal + .replace_text_in_range_bytes(range.clone(), text_to_insert, cx); + self.internal + .ime_mark_text_in_range(&range, text_to_insert.len()); + self.internal.ime_mark_selected_range( + &range, + &new_selected_range_utf16, + text_to_insert.len(), + ); + //self.mark_layout_dirty(); + cx.emit_text_changed(TextChanged); + cx.notify_changed(); + } + + fn bounds_for_range( + &mut self, + range_utf16: Range, + bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + unimplemented!() + } + + fn character_index_for_point( + &mut self, + point: Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + unimplemented!() + } +} + +impl<'app> EditableTextActionHandler<'app> for TextAreaState { + type Context = gpui::Context<'app, Self>; + + fn escape(&mut self, _: &super::Escape, window: &mut Window, cx: &mut Self::Context) { + self.internal.set_selected_range(0..0); + cx.notify(); + + window.blur(); + } + + fn insert_enter(&mut self, _: &super::Enter, window: &mut Window, cx: &mut Self::Context) { + self.replace_text_in_range(None, "\n", window, cx); + } + + fn insert_tab(&mut self, _: &super::Tab, window: &mut Window, cx: &mut Self::Context) { + self.replace_text_in_range(None, "\t", window, cx); + } + + fn backspace(&mut self, _: &super::Backspace, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .delete(NavigationDirection::Back, TextBoundary::Graphmeme, cx); + } + + fn delete(&mut self, _: &super::Delete, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .delete(NavigationDirection::Forward, TextBoundary::Graphmeme, cx); + } + + fn delete_word_left( + &mut self, + _: &super::DeleteWordLeft, + _w: &mut Window, + cx: &mut Self::Context, + ) { + self.internal + .delete(NavigationDirection::Back, TextBoundary::Word, cx); + } + + fn delete_word_right( + &mut self, + _: &super::DeleteWordRight, + _w: &mut Window, + cx: &mut Self::Context, + ) { + self.internal + .delete(NavigationDirection::Forward, TextBoundary::Word, cx); + } + + fn delete_to_line_start( + &mut self, + _: &super::DeleteToBeginningOfLine, + _w: &mut Window, + cx: &mut Self::Context, + ) { + self.internal + .delete(NavigationDirection::Back, TextBoundary::Line, cx); + } + + fn delete_to_line_end( + &mut self, + _: &super::DeleteToEndOfLine, + _w: &mut Window, + cx: &mut Self::Context, + ) { + self.internal + .delete(NavigationDirection::Forward, TextBoundary::Line, cx); + } + + fn nav_left(&mut self, _: &super::Left, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .nav_linear(NavigationDirection::Back, TextBoundary::Graphmeme, cx); + } + + fn nav_right(&mut self, _: &super::Right, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .nav_linear(NavigationDirection::Forward, TextBoundary::Graphmeme, cx); + } + + fn nav_up(&mut self, _: &super::Up, _w: &mut Window, cx: &mut Self::Context) { + // TODO: implement + } + + fn nav_down(&mut self, _: &super::Down, _w: &mut Window, cx: &mut Self::Context) { + // TODO: implement + } + + fn nav_line_start(&mut self, _: &super::Home, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .nav_linear(NavigationDirection::Back, TextBoundary::Line, cx); + } + + fn nav_line_end(&mut self, _: &super::End, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .nav_linear(NavigationDirection::Forward, TextBoundary::Line, cx); + } + + fn nav_start(&mut self, _: &super::MoveToBeginning, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .nav_linear(NavigationDirection::Back, TextBoundary::Document, cx); + } + + fn nav_end(&mut self, _: &super::MoveToEnd, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .nav_linear(NavigationDirection::Forward, TextBoundary::Document, cx); + } + + fn nav_left_word(&mut self, _: &super::WordLeft, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .nav_linear(NavigationDirection::Back, TextBoundary::Word, cx); + } + + fn nav_right_word(&mut self, _: &super::WordRight, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .nav_linear(NavigationDirection::Forward, TextBoundary::Word, cx); + } + + fn select_all(&mut self, _: &super::SelectAll, _w: &mut Window, cx: &mut Self::Context) { + self.internal.select_all(cx); + } + + fn select_left(&mut self, _: &super::SelectLeft, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .select_linear(NavigationDirection::Back, TextBoundary::Graphmeme, cx); + } + + fn select_right(&mut self, _: &super::SelectRight, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .select_linear(NavigationDirection::Forward, TextBoundary::Graphmeme, cx); + } + + fn select_up(&mut self, _: &super::SelectUp, _w: &mut Window, cx: &mut Self::Context) { + // TODO: implement + } + + fn select_down(&mut self, _: &super::SelectDown, _w: &mut Window, cx: &mut Self::Context) { + // TODO: implement + } + + fn select_start( + &mut self, + _: &super::SelectToBeginning, + _w: &mut Window, + cx: &mut Self::Context, + ) { + self.internal + .select_linear(NavigationDirection::Back, TextBoundary::Document, cx); + } + + fn select_end(&mut self, _: &super::SelectToEnd, _w: &mut Window, cx: &mut Self::Context) { + self.internal + .select_linear(NavigationDirection::Forward, TextBoundary::Document, cx); + } + + fn select_left_word( + &mut self, + _: &super::SelectWordLeft, + _w: &mut Window, + cx: &mut Self::Context, + ) { + self.internal + .select_linear(NavigationDirection::Back, TextBoundary::Word, cx); + } + + fn select_right_word( + &mut self, + _: &super::SelectWordRight, + _w: &mut Window, + cx: &mut Self::Context, + ) { + self.internal + .select_linear(NavigationDirection::Forward, TextBoundary::Word, cx); + } + + fn cut(&mut self, _: &super::Cut, _w: &mut Window, cx: &mut Self::Context) { + self.internal.cut(cx); + } + + fn copy(&mut self, _: &super::Copy, _w: &mut Window, cx: &mut Self::Context) { + self.internal.copy(cx); + } + + fn paste(&mut self, _: &super::Paste, _w: &mut Window, cx: &mut Self::Context) { + self.internal.paste(cx); + } + + fn undo(&mut self, _: &super::Undo, _w: &mut Window, _cx: &mut Self::Context) { + // TODO: STUB + } + + fn redo(&mut self, _: &super::Redo, _w: &mut Window, _cx: &mut Self::Context) { + // TODO: STUB + } + + fn on_mouse_down( + &mut self, + event: &gpui::MouseDownEvent, + text_position: gpui::Point, + window: &mut Window, + cx: &mut Self::Context, + ) { + let character_pos = self.internal.caret_pos(); // TODO: Should be index_for_pixel_point + self.internal.on_mouse_down( + text_position, + character_pos, + event.click_count, + event.modifiers.shift, + window, + cx, + ); + } + + fn on_mouse_up( + &mut self, + _event: &gpui::MouseUpEvent, + _w: &mut Window, + _cx: &mut Self::Context, + ) { + self.internal.on_mouse_up(); + } + + fn on_mouse_move( + &mut self, + _event: &gpui::MouseMoveEvent, + text_position: Point, + _w: &mut Window, + cx: &mut Self::Context, + ) { + let character_pos = self.internal.caret_pos(); // TODO: Should be index_for_pixel_point + self.internal.on_mouse_move(character_pos, cx); + } +} diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs index ed5fbf206c..fa1ba51c34 100644 --- a/crates/gpui_elements/src/input/state.rs +++ b/crates/gpui_elements/src/input/state.rs @@ -1,9 +1,9 @@ use super::actions::*; use crate::input::{InputLayoutStyle, InputStorage}; use gpui::{ - App, AppContext, ClipboardItem, Context, Entity, EntityId, EntityInputHandler, EventEmitter, - FocusHandle, Focusable, NavigationDirection, Pixels, Point, Render, SharedString, Size, - Subscription, TextRun, TextStyle, Window, WrappedLine, point, px, + App, ClipboardItem, Context, EntityId, EntityInputHandler, EventEmitter, FocusHandle, + Focusable, NavigationDirection, Pixels, Point, SharedString, Size, TextRun, TextStyle, Window, + WrappedLine, point, px, }; use std::{ ops::Range, From 7c1658a24a14ff981740fd28b71b8f4bdb504ebe Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 20 Jun 2026 14:39:04 -0400 Subject: [PATCH 044/117] add empty render for element --- .../src/editable_text/input_element.rs | 137 +++++++++++++++++- .../src/editable_text/input_state.rs | 9 +- .../src/editable_text/shared_state.rs | 6 +- 3 files changed, 145 insertions(+), 7 deletions(-) diff --git a/crates/gpui_elements/src/editable_text/input_element.rs b/crates/gpui_elements/src/editable_text/input_element.rs index 4800e2c35b..483ee4b871 100644 --- a/crates/gpui_elements/src/editable_text/input_element.rs +++ b/crates/gpui_elements/src/editable_text/input_element.rs @@ -1,3 +1,136 @@ -use gpui::ElementId; +use gpui::{ + App, Element, ElementId, Entity, Hitbox, InteractiveElement, Interactivity, IntoElement, + Length, SharedString, StyleRefinement, Styled, TextStyle, +}; -pub fn input(id: impl Into) {} +use crate::editable_text::{TextInputState, UnicodeTextStorage}; + +#[track_caller] +pub fn input(id: impl Into) -> TextInputElement { + let mut this = TextInputElement { + id: id.into(), + placeholder: None, + interactivity: Interactivity::new(), + init_storage: None, + }; + this = this.key_context(super::DEFAULT_INPUT_CONTEXT); + this +} + +// TODO: Disabled flag/state? +pub struct TextInputElement { + id: ElementId, + placeholder: Option, + interactivity: Interactivity, + init_storage: Option Box>>, +} + +impl InteractiveElement for TextInputElement { + fn interactivity(&mut self) -> &mut Interactivity { + &mut self.interactivity + } +} + +impl Styled for TextInputElement { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.interactivity.base_style + } +} + +impl IntoElement for TextInputElement { + type Element = Self; + fn into_element(self) -> Self::Element { + self + } +} + +#[doc(hidden)] +pub struct LayoutState { + state: Entity, + text_style: TextStyle, +} + +#[doc(hidden)] +pub struct PrepaintState { + hitbox: Option, +} + +impl Element for TextInputElement { + type RequestLayoutState = LayoutState; + type PrepaintState = PrepaintState; + + fn id(&self) -> Option { + self.interactivity.element_id.clone() + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + self.interactivity.source_location() + } + + fn request_layout( + &mut self, + global_id: Option<&gpui::GlobalElementId>, + inspector_id: Option<&gpui::InspectorElementId>, + window: &mut gpui::Window, + cx: &mut gpui::App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let mut resolved_text_style = None; + + // Get the state from the app using the element's id as the key. + // If it doesnt exist, initialize a new state with the user's desired storage medium. + let state = window.use_keyed_state(self.id.clone(), cx, |_window, cx| { + let storage = match &self.init_storage { + None => Box::new(String::new()), + Some(init_storage) => (*init_storage)(cx), + }; + TextInputState::new(storage, cx) + }); + + let layout_id = self.interactivity.request_layout( + global_id, + inspector_id, + window, + cx, + |element_style, window, cx| { + window.with_text_style(element_style.text_style().cloned(), |window| { + resolved_text_style = Some(window.text_style()); + + let style = element_style.clone(); + // TODO: Does this need to propagate the line_height as the element's height? + window.request_layout(style, None, cx) + }) + }, + ); + + let layout_state = LayoutState { + state, + text_style: resolved_text_style.unwrap_or_else(|| window.text_style()), + }; + (layout_id, layout_state) + } + + fn prepaint( + &mut self, + id: Option<&gpui::GlobalElementId>, + inspector_id: Option<&gpui::InspectorElementId>, + bounds: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut gpui::Window, + cx: &mut gpui::App, + ) -> Self::PrepaintState { + todo!() + } + + fn paint( + &mut self, + id: Option<&gpui::GlobalElementId>, + inspector_id: Option<&gpui::InspectorElementId>, + bounds: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut gpui::Window, + cx: &mut gpui::App, + ) { + todo!() + } +} diff --git a/crates/gpui_elements/src/editable_text/input_state.rs b/crates/gpui_elements/src/editable_text/input_state.rs index 2baceab4a0..d5c784172e 100644 --- a/crates/gpui_elements/src/editable_text/input_state.rs +++ b/crates/gpui_elements/src/editable_text/input_state.rs @@ -1,7 +1,7 @@ use super::notify::TextHistoryPushed; use crate::editable_text::{ EditableTextActionHandler, TextBoundary, TextInputStateBase, TextStateNotifier, - notify::TextChanged, + UnicodeTextStorage, notify::TextChanged, }; use gpui::{ Bounds, Context, EntityInputHandler, EventEmitter, NavigationDirection, Pixels, Point, @@ -16,6 +16,13 @@ pub struct TextInputState { impl EventEmitter for TextInputState {} impl EventEmitter for TextInputState {} +impl TextInputState { + pub fn new(storage: impl Into>, cx: &mut Context) -> Self { + let internal = TextInputStateBase::new(storage, cx); + Self { internal } + } +} + impl TextStateNotifier for Context<'_, TextInputState> { fn notify_changed(&mut self) { self.notify(); diff --git a/crates/gpui_elements/src/editable_text/shared_state.rs b/crates/gpui_elements/src/editable_text/shared_state.rs index b40428d140..bc1c87b549 100644 --- a/crates/gpui_elements/src/editable_text/shared_state.rs +++ b/crates/gpui_elements/src/editable_text/shared_state.rs @@ -3,8 +3,8 @@ use crate::editable_text::{ notify::{TextChanged, TextHistoryPushed}, }; use gpui::{ - App, AppContext, ClipboardItem, FocusHandle, Focusable, NavigationDirection, Pixels, Point, - UTF16Selection, Window, + App, ClipboardItem, FocusHandle, Focusable, NavigationDirection, Pixels, Point, UTF16Selection, + Window, }; use std::ops::Range; @@ -45,8 +45,6 @@ impl Focusable for TextInputStateBase { } impl TextInputStateBase { - /// Creates a new `Input` with the specified multiline setting. - /// Cursor blinking is enabled by default. pub fn new(storage: impl Into>, cx: &mut App) -> Self { Self { storage: storage.into(), From bad745a76106f186c455935c5cdb311a54bb8817 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 20 Jun 2026 15:23:59 -0400 Subject: [PATCH 045/117] register text-based actions with element via traits --- .../src/editable_text/actions.rs | 133 +++++++++++++++++- .../src/editable_text/input_element.rs | 56 ++++++-- .../src/editable_text/shared_state.rs | 21 ++- 3 files changed, 193 insertions(+), 17 deletions(-) diff --git a/crates/gpui_elements/src/editable_text/actions.rs b/crates/gpui_elements/src/editable_text/actions.rs index a97bb7f658..9b11909925 100644 --- a/crates/gpui_elements/src/editable_text/actions.rs +++ b/crates/gpui_elements/src/editable_text/actions.rs @@ -1,4 +1,4 @@ -use gpui::{App, AppContext, Window}; +use gpui::{Action, App, AppContext, Context, InteractiveElement, Window}; /// The key context used for input element keybindings. pub const DEFAULT_INPUT_CONTEXT: &str = "Input"; @@ -233,3 +233,134 @@ pub trait EditableTextActionHandler<'app>: Sized { ) { } } + +pub trait EditableInputActionElement: super::StateBackedElement { + fn register_action( + &mut self, + init_props: Self::InitProps, + listener: fn(&mut Self::State, &A, &mut Window, &mut Context), + ) where + Self: InteractiveElement, + { + self.interactivity() + .on_action::(move |action, window, cx| { + let state = Self::get_or_init_state(&init_props, window, cx); + state.update(cx, |state, cx| { + listener(state, action, window, cx); + }); + }); + } + + fn register_actions(&mut self) + where + Self: InteractiveElement, + Self::InitProps: Clone, + Self::State: + for<'app> EditableTextActionHandler<'app, Context = gpui::Context<'app, Self::State>>, + { + use super::actions::*; + let init_props = self.init_props(); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.escape(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.insert_enter(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.insert_tab(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.backspace(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.delete(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.delete_word_left(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.delete_word_right(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.delete_to_line_start(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.delete_to_line_end(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.nav_left(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.nav_right(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.nav_up(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.nav_down(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.nav_line_start(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.nav_line_end(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.nav_start(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.nav_end(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.nav_left_word(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.nav_right_word(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.select_all(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.select_left(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.select_right(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.select_up(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.select_down(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.select_start(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.select_end(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.select_left_word(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.select_right_word(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.cut(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.copy(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.paste(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.undo(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.redo(action, window, cx) + }); + self.register_action(init_props.clone(), |state, action, window, cx| { + state.show_character_palette(action, window, cx) + }); + } +} diff --git a/crates/gpui_elements/src/editable_text/input_element.rs b/crates/gpui_elements/src/editable_text/input_element.rs index 483ee4b871..52257aabec 100644 --- a/crates/gpui_elements/src/editable_text/input_element.rs +++ b/crates/gpui_elements/src/editable_text/input_element.rs @@ -1,9 +1,11 @@ +use crate::editable_text::{ + EditableInputActionElement, StateBackedElement, TextInputState, UnicodeTextStorage, +}; use gpui::{ App, Element, ElementId, Entity, Hitbox, InteractiveElement, Interactivity, IntoElement, - Length, SharedString, StyleRefinement, Styled, TextStyle, + SharedString, StyleRefinement, Styled, TextStyle, Window, }; - -use crate::editable_text::{TextInputState, UnicodeTextStorage}; +use std::rc::Rc; #[track_caller] pub fn input(id: impl Into) -> TextInputElement { @@ -11,9 +13,10 @@ pub fn input(id: impl Into) -> TextInputElement { id: id.into(), placeholder: None, interactivity: Interactivity::new(), - init_storage: None, + init_storage: InitStorage::default(), }; this = this.key_context(super::DEFAULT_INPUT_CONTEXT); + this.register_actions(); this } @@ -22,7 +25,7 @@ pub struct TextInputElement { id: ElementId, placeholder: Option, interactivity: Interactivity, - init_storage: Option Box>>, + init_storage: InitStorage, } impl InteractiveElement for TextInputElement { @@ -44,6 +47,39 @@ impl IntoElement for TextInputElement { } } +#[derive(Clone, Default)] +pub(super) struct InitStorage(Option Box>>); +impl InitStorage { + fn exec(&self, cx: &mut App) -> Box { + match &self.0 { + None => Box::new(String::new()), + Some(init) => (*init)(cx), + } + } +} + +impl EditableInputActionElement for TextInputElement {} +impl super::StateBackedElement for TextInputElement { + type State = TextInputState; + type InitProps = (ElementId, InitStorage); + + fn init_props(&self) -> Self::InitProps { + (self.id.clone(), self.init_storage.clone()) + } + + fn get_or_init_state( + init_props: &Self::InitProps, + window: &mut Window, + cx: &mut App, + ) -> Entity { + // Get the state from the app using the element's id as the key. + // If it doesnt exist, initialize a new state with the user's desired storage medium. + window.use_keyed_state(init_props.0.clone(), cx, |_window, cx| { + TextInputState::new(init_props.1.exec(cx), cx) + }) + } +} + #[doc(hidden)] pub struct LayoutState { state: Entity, @@ -76,15 +112,7 @@ impl Element for TextInputElement { ) -> (gpui::LayoutId, Self::RequestLayoutState) { let mut resolved_text_style = None; - // Get the state from the app using the element's id as the key. - // If it doesnt exist, initialize a new state with the user's desired storage medium. - let state = window.use_keyed_state(self.id.clone(), cx, |_window, cx| { - let storage = match &self.init_storage { - None => Box::new(String::new()), - Some(init_storage) => (*init_storage)(cx), - }; - TextInputState::new(storage, cx) - }); + let state = self.get_state(window, cx); let layout_id = self.interactivity.request_layout( global_id, diff --git a/crates/gpui_elements/src/editable_text/shared_state.rs b/crates/gpui_elements/src/editable_text/shared_state.rs index bc1c87b549..e67d3af202 100644 --- a/crates/gpui_elements/src/editable_text/shared_state.rs +++ b/crates/gpui_elements/src/editable_text/shared_state.rs @@ -3,8 +3,8 @@ use crate::editable_text::{ notify::{TextChanged, TextHistoryPushed}, }; use gpui::{ - App, ClipboardItem, FocusHandle, Focusable, NavigationDirection, Pixels, Point, UTF16Selection, - Window, + App, ClipboardItem, Entity, FocusHandle, Focusable, NavigationDirection, Pixels, Point, + UTF16Selection, Window, }; use std::ops::Range; @@ -14,6 +14,23 @@ pub trait TextStateNotifier { fn emit_history(&mut self, event: TextHistoryPushed); } +pub(super) trait StateBackedElement { + type State: 'static; + type InitProps: 'static; + + fn init_props(&self) -> Self::InitProps; + + fn get_or_init_state( + init_props: &Self::InitProps, + window: &mut Window, + cx: &mut App, + ) -> Entity; + + fn get_state(&self, window: &mut Window, cx: &mut App) -> Entity { + Self::get_or_init_state(&self.init_props(), window, cx) + } +} + pub struct TextInputStateBase { storage: Box, From 3f6faa20a50122976eb4df41ccd43520221c0e52 Mon Sep 17 00:00:00 2001 From: temportalflux Date: Sat, 20 Jun 2026 15:28:43 -0400 Subject: [PATCH 046/117] stub out