diff --git a/crates/gpui_elements/src/input.rs b/crates/gpui_elements/src/input.rs deleted file mode 100644 index 0b1575e708..0000000000 --- a/crates/gpui_elements/src/input.rs +++ /dev/null @@ -1,25 +0,0 @@ -mod colors; -mod cursor; -mod element; -mod history; -mod layout; -mod paint; -mod state; -mod state_input_handler; -mod storage; -pub(self) mod unicode; - -pub use colors::*; -pub use cursor::*; -pub use element::*; -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)); - input(&state, app).text_cursor(default_cursor(&state, app)) -} diff --git a/crates/gpui_elements/src/input/colors.rs b/crates/gpui_elements/src/input/colors.rs deleted file mode 100644 index 8e9e638d0b..0000000000 --- a/crates/gpui_elements/src/input/colors.rs +++ /dev/null @@ -1,21 +0,0 @@ -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 placeholder string, when one is assigned and the text field is empty. - pub placeholder: Hsla, - pub marked: Hsla, -} - -impl Default for InputColors { - fn default() -> Self { - Self { - selection: gpui::hsla(0.583, 0.519, 0.31, 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/cursor.rs b/crates/gpui_elements/src/input/cursor.rs deleted file mode 100644 index a522fb33ea..0000000000 --- a/crates/gpui_elements/src/input/cursor.rs +++ /dev/null @@ -1,282 +0,0 @@ -use gpui::{ - App, 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); - -/// 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 { - 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 { - #[track_caller] - fn new(state: Entity) -> Self { - Self { - state, - color: Hsla::white(), - was_focused: false, - point: Point::default(), - height: Pixels::ZERO, - } - } - - pub fn color(mut self, color: Hsla) -> Self { - 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, |state, _emitter, event, cx| match event { - CursorTrigger::PauseBlinkingForUserAction => { - if !state.interval.is_zero() { - state.pause_blinking(cx); - cx.notify(); - } - } - }); - self.subscriptions.push(handle); - } - - /// Activates cursor blinking. - /// - /// While active, the cursor will alternate between visible and hidden states at the configured interval. Has no effect if already active. - fn enable(&mut self, cx: &mut Context) { - if self.active { - return; - } - - self.active = true; - self.visible = false; - self.paused = false; - self.spawn_ticker(cx); - } - - /// Deactivates cursor blinking. - /// - /// 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. - fn disable(&mut self, cx: &mut Context) { - self.active = false; - self.visible = false; - self.paused = false; - cx.notify(); - } - - /// Temporarily pauses blinking and leaves the cursor visible. Blinking will resume after the pre-established interval elapses from the time this is called. - 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.spawn_ticker(cx); - } - }) - }) - .detach(); - } - - fn spawn_ticker(&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.spawn_ticker(cx); - } - }); - } - }) - .detach(); - } -} - -impl Cursor { - pub fn update_input( - &mut self, - is_focused: bool, - pos: Point, - line_height: Pixels, - cx: &mut App, - ) -> bool { - let was_focused = self.was_focused; - self.was_focused = is_focused; - - self.point = pos; - self.height = line_height; - - match ( - self.state.read(cx).interval.is_zero(), - is_focused, - was_focused, - ) { - (true, _, _) => true, - (false, true, false) => { - self.state.update(cx, |state, cx| { - state.enable(cx); - }); - true - } - (false, false, true) => { - self.state.update(cx, |state, cx| { - state.disable(cx); - }); - false - } - (false, _, _) => self.state.read(cx).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 deleted file mode 100644 index 11adb146d7..0000000000 --- a/crates/gpui_elements/src/input/element.rs +++ /dev/null @@ -1,205 +0,0 @@ -use crate::input::{Cursor, InputColors, InputState}; -use gpui::{ - Action, AnyElement, App, Context, Entity, FocusHandle, Focusable, Hsla, InteractiveElement, - Interactivity, IntoElement, SharedString, StyleRefinement, Styled, Window, -}; - -#[track_caller] -pub fn input(input_state: &Entity, cx: &App) -> Input { - Input::new(input_state, cx) -} - -/// A text editing element that supports both single-line and multi-line modes. -pub struct Input { - pub(super) input: Entity, - pub(super) interactivity: Interactivity, - pub(super) placeholder: Option, - pub(super) colors: InputColors, - pub(super) cursor: Option, -} - -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: InputColors::default(), - cursor: None, - }; - input.register_actions(); - input - .key_context(crate::editable_text::actions::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 - } - - /// 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 "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 - } - - pub fn text_cursor(mut self, cursor: Cursor) -> Self { - self.cursor = Some(cursor); - 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 - } -} - -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 - } -} diff --git a/crates/gpui_elements/src/input/history.rs b/crates/gpui_elements/src/input/history.rs deleted file mode 100644 index 7f8b44ad9c..0000000000 --- a/crates/gpui_elements/src/input/history.rs +++ /dev/null @@ -1,61 +0,0 @@ -use gpui::NavigationDirection; -use std::{ - ops::Range, - time::{Duration, Instant}, -}; - -use crate::input::InputStorage; - -/// 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, - /// The direction of the selection before the edit. - pub selection_direction: NavigationDirection, - /// 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 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.as_str()[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_direction: self.selection_direction, - timestamp: self.timestamp, - } - } - - /// Apply this patch to redo an edit, returning the reverse patch for undo. - 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/layout.rs b/crates/gpui_elements/src/input/layout.rs deleted file mode 100644 index eb0b80378c..0000000000 --- a/crates/gpui_elements/src/input/layout.rs +++ /dev/null @@ -1,24 +0,0 @@ -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum InputLayoutStyle { - SingleLine, - MultiLine, -} - -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 - 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 deleted file mode 100644 index 3b25fdbcf1..0000000000 --- a/crates/gpui_elements/src/input/paint.rs +++ /dev/null @@ -1,685 +0,0 @@ -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, - 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 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 { - 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 mut child_layout_ids = SmallVec::new(); - let mut cursor_layout = None; - - 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| { - let state = self.input.read(cx); - - resolved_text_style = Some(window.text_style()); - - let mut layout_style = element_style.clone(); - if matches!(state.layout_style(), super::InputLayoutStyle::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(); - } - } - - 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); - } - - 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, - cursor_layout, - }; - (layout_id, layout_state) - } - - 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 = match self.input.read(cx).layout_style() { - super::InputLayoutStyle::SingleLine => None, - super::InputLayoutStyle::MultiLine => Some(bounds.size.width), - }; - - self.input.update(cx, |input, _cx| { - let layout_data = InputLayoutData { - text_style: layout_state.text_style.clone(), - line_height, - wrap_width, - available_size: bounds.size, - dirty: false, - }; - input.apply_layout_update(layout_data, window); - }); - - let mut cursor_prepaint = None; - let hitbox = self.interactivity.prepaint( - global_id, - inspector_id, - bounds, - bounds.size, - window, - cx, - |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| { - match (&mut self.cursor, &mut layout_state.cursor_layout) { - (Some(cursor), Some(layout)) => { - let prepaint = cursor.prepaint( - global_id, - inspector_id, - bounds, - layout, - window, - cx, - ); - cursor_prepaint = Some(prepaint); - } - _ => {} - } - }); - } - - hitbox - }, - ); - - InputPrepaintState { - hitbox, - cursor_prepaint, - } - } - - 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 snapshot = InputStateSnapshot::new(&self.input, cx); - let placeholder = self.placeholder.clone(); - let text_style = layout_state.text_style.clone(); - let is_focused = focus_handle.is_focused(window); - let colors = self.colors; - - let perform_paint = |style: &Style, window: &mut Window, cx: &mut App| { - if style.display == Display::None { - return; - } - - let context = PaintContext { - snapshot, - is_focused, - bounds, - text_style: &text_style, - placeholder: placeholder.as_ref(), - colors: &colors, - }; - context.process_mouse_events(&self.input, window, cx); - window.with_content_mask(Some(ContentMask { bounds }), |window| { - context.paint(window, cx); - - match ( - &mut self.cursor, - &mut layout_state.cursor_layout, - &mut prepaint_state.cursor_prepaint, - ) { - (Some(cursor), Some(layout), Some(prepaint)) => { - 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, - ); - } - } - _ => {} - } - }); - }; - self.interactivity.paint( - global_id, - inspector_id, - bounds, - prepaint_state.hitbox.as_ref(), - window, - cx, - perform_paint, - ); - } -} - -/// 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_axis: Axis, - should_center_placeholder: bool, - show_placeholder: bool, - selected_range: Range, - marked_range: Option>, - cursor_position: usize, - logical_lines: Vec, - scroll_distance: 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_position = input_state.cursor_position(); - 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(); - let should_center_placeholder = matches!( - input_state.layout_style(), - super::InputLayoutStyle::SingleLine - ); - Self { - layout_axis, - should_center_placeholder, - show_placeholder: input_state.content().as_str().is_empty(), - selected_range, - marked_range, - cursor_position, - logical_lines, - scroll_distance, - line_height, - } - } -} - -struct PaintContext<'app> { - snapshot: InputStateSnapshot, - is_focused: bool, - bounds: Bounds, - text_style: &'app TextStyle, - placeholder: Option<&'app SharedString>, - colors: &'app InputColors, -} - -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; - let scroll_distance = self.snapshot.scroll_distance; - 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 + scroll_distance); - 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; - } - - 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; - } - - 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 + scroll_distance); - 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.lines().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; - } - - 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.apply_scroll_delta(delta, max_scroll); - cx.notify(); - }); - } - }); - } - - 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); - } - - if self.snapshot.show_placeholder { - self.paint_placeholder(window, cx); - } else { - self.paint_text(window, cx); - } - - self.paint_marked_underline(window); - } - - 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_distance; - - 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.), - ); - } - } - } - - fn paint_placeholder(&self, window: &mut Window, cx: &mut App) { - 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 self.snapshot.should_center_placeholder { - 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) { - for line_layout in &self.snapshot.logical_lines { - let line_y = line_layout.y_offset - self.snapshot.scroll_distance; - - if !self.is_line_visible(line_layout) { - continue; - } - - let Some(wrapped) = &line_layout.wrapped_line else { - continue; - }; - - 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, - ); - } - } - - fn paint_marked_underline(&self, window: &mut Window) { - let Some(marked_range) = &self.snapshot.marked_range else { - return; - }; - if marked_range.is_empty() { - return; - } - - let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS); - let underline_offset = self.snapshot.line_height - underline_thickness; - 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.marked, - underline_offset, - ); - } - } - - fn find_cursor_position_in_layouts(&self) -> Point { - for line in &self.snapshot.logical_lines { - let line_y = line.y_offset - self.snapshot.scroll_distance; - - if !self.is_line_visible(line) { - 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_position == line.text_range.start - } else { - line.text_range.contains(&self.snapshot.cursor_position) - || self.snapshot.cursor_position == 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_position - .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 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; - 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 paint_line_range( - &self, - window: &mut Window, - line: &InputLogicalLine, - 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_distance; - - 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( - 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 - } -} diff --git a/crates/gpui_elements/src/input/state.rs b/crates/gpui_elements/src/input/state.rs deleted file mode 100644 index 1c4ebc7fba..0000000000 --- a/crates/gpui_elements/src/input/state.rs +++ /dev/null @@ -1,2996 +0,0 @@ -use crate::editable_text::actions::*; -use crate::input::{InputLayoutStyle, InputStorage}; -use gpui::{ - App, ClipboardItem, Context, EntityId, EntityInputHandler, EventEmitter, FocusHandle, - Focusable, NavigationDirection, Pixels, Point, SharedString, Size, 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. - /// 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, - /// Emitted when an undo operation is performed. - Undo, - /// Emitted when a redo operation is performed. - Redo, -} -impl EventEmitter for InputState {} - -#[derive(Clone, Debug)] -pub enum CursorTrigger { - 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 -/// - 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: Box, - - /// The style of layout (single or multiline). - 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. - selected_range: Range, - /// The direction of the selection_range. Forward means providing in iteration order along `content`. Back means reverse iteration order. - selection_direction: NavigationDirection, - /// The utf-8 character range of `content` that is currently marked/highlighted. - marked_range: Option>, - - // refreshed each update by the element, for conveinent access in mutations and painting - 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, - - /// 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). - 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, - /// Stack of previous states for undo. - history_undo_stack: Vec, - /// Stack of undone states for redo. - history_redo_stack: Vec, -} - -/// 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. -/// When text wrapping is enabled, a logical line may span multiple visual lines. -#[derive(Clone, Debug)] -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. - pub wrapped_line: Option>, - /// The vertical offset from the top of the text area in 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, -} - -impl Focusable for InputState { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -// External API -impl InputState { - /// Creates a new `Input` with the specified multiline setting. - /// Cursor blinking is enabled by default. - pub fn new(cx: &mut Context) -> Self { - Self { - entity_id: cx.entity_id(), - focus_handle: cx.focus_handle(), - content: Box::new(super::Standard::default()), - - layout_style: InputLayoutStyle::SingleLine, - selected_range: 0..0, - selection_direction: NavigationDirection::Forward, - marked_range: None, - - layout_data: InputLayoutData::default(), - logical_lines: Vec::new(), - - is_selecting: false, - last_click_position: None, - click_count: 0, - scroll_distance: px(0.), - - history_grouping_interval: super::DEFAULT_GROUP_INTERVAL, - history_undo_stack: Vec::new(), - history_redo_stack: Vec::new(), - } - } - - /// Returns the current text 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.emplace(content.as_ref()); - self.selected_range = 0..0; - self.selection_direction = NavigationDirection::Forward; - self.marked_range = None; - self.history_undo_stack.clear(); - self.history_redo_stack.clear(); - self.mark_layout_dirty(); - cx.emit(CursorTrigger::PauseBlinkingForUserAction); - cx.emit(InputStateEvent::TextChanged); - cx.notify(); - } - - /// 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 - } - - /// Returns the input's layout style. - pub fn layout_style(&self) -> InputLayoutStyle { - self.layout_style - } - - /// 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 - } - - 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()); - self.selected_range = range; - 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_direction { - NavigationDirection::Back => self.selected_range.start, - NavigationDirection::Forward => 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_distance <= 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_distance + 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_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_distance.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_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. - /// 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.history_undo_stack.is_empty() - } - - /// Returns whether redo is currently available based on the recorded states. - pub fn is_redo_available(&self) -> bool { - !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). - /// 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 - self.content - .update_utf8(range.clone(), text_to_insert.as_ref()); - - 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(); - self.marked_range.take(); - self.mark_layout_dirty(); - - cx.emit(CursorTrigger::PauseBlinkingForUserAction); - 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.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.history_redo_stack.push(redo_entry); - - self.content.clear_utf16_cache(); - self.selected_range = selected_range; - self.selection_direction = selection_direction; - self.mark_layout_dirty(); - - 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.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.history_undo_stack.push(undo_entry); - - self.content.clear_utf16_cache(); - self.mark_layout_dirty(); - - 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.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.history_redo_stack.push(redo_entry); - - // Restore selection state - self.selected_range = selected_range; - self.selection_direction = selection_direction; - self.content.clear_utf16_cache(); - self.mark_layout_dirty(); - - 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.history_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_direction = NavigationDirection::Forward; - - self.history_undo_stack.push(undo_entry); - self.content.clear_utf16_cache(); - self.mark_layout_dirty(); - - 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_direction = NavigationDirection::Forward; - 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) { - cx.emit(CursorTrigger::PauseBlinkingForUserAction); - match self.layout_style { - InputLayoutStyle::SingleLine => { - // In single-line mode, up moves to start - self.selected_range = 0..0; - 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_direction = NavigationDirection::Forward; - self.scroll_to_cursor(); - cx.notify(); - } - } - } - } - - pub(super) fn down(&mut self, _: &Down, _window: &mut Window, cx: &mut Context) { - cx.emit(CursorTrigger::PauseBlinkingForUserAction); - 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_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_direction = NavigationDirection::Forward; - 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) { - cx.emit(CursorTrigger::PauseBlinkingForUserAction); - match self.layout_style { - InputLayoutStyle::SingleLine => { - // In single-line mode, select_up selects to start - self.select_to(0, cx); - } - InputLayoutStyle::MultiLine => { - 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(); - } - } - } - - pub(super) fn select_down( - &mut self, - _: &SelectDown, - _window: &mut Window, - cx: &mut Context, - ) { - cx.emit(CursorTrigger::PauseBlinkingForUserAction); - match self.layout_style { - InputLayoutStyle::SingleLine => { - // In single-line mode, select_down selects to end - self.select_to(self.content.len(), cx); - } - InputLayoutStyle::MultiLine => { - 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(); - } - } - } - - 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() { - 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 - 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) - 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 - }; - - 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); - } - } - - 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_direction = NavigationDirection::Forward; - 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_direction = NavigationDirection::Forward; - 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 - } - - pub(super) fn lines(&self) -> &Vec { - &self.logical_lines - } - - 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_text_at_range(&mut self, range: Range, text: &str) { - 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) { - self.content.update_utf8(range, text_to_insert); - } - - /// 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.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 - return; - } - } - - // Capture the text that will be replaced - 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, - old_text, - new_text_len, - selected_range: self.selected_range.clone(), - selection_direction: self.selection_direction, - timestamp: now, - }); - - // Limit history size - if self.history_undo_stack.len() > super::MAX_HISTORY_LEN { - self.history_undo_stack.remove(0); - } - - // New edit invalidates redo stack - 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. - pub(super) fn find_line_start(&self, position: usize) -> usize { - self.content.as_str()[..position.min(self.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. - pub(super) fn find_line_end(&self, position: usize) -> usize { - self.content.as_str()[position.min(self.content.len())..] - .find('\n') - .map(|pos| position + pos) - .unwrap_or(self.content.len()) - } - - /// 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.as_str().is_empty() { - return 0; - } - - for line in self.logical_lines.iter() { - let line_height_total = self.line_height() * line.visual_line_count as f32; - - 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; - }; - - 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 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(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; - } - - let cursor_offset = self.cursor_position(); - match self.layout_style { - InputLayoutStyle::SingleLine => { - 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_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_distance = (cursor_x - padding).max(px(0.)); - } else if cursor_x > visible_right - padding { - self.scroll_distance = - cursor_x - self.layout_data.available_size.width + padding; - } - - self.scroll_distance = self.scroll_distance.max(px(0.)); - } - 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_distance; - let visible_bottom = - self.scroll_distance + self.layout_data.available_size.height; - - if cursor_visual_y < visible_top { - self.scroll_distance = cursor_visual_y; - } else if cursor_visual_y + line_height > visible_bottom { - self.scroll_distance = (cursor_visual_y + line_height) - - self.layout_data.available_size.height; - } - - self.scroll_distance = self.scroll_distance.max(px(0.)); - break; - } - } - } - } - } - - 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 - || 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.as_str(), 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: &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 content.is_empty() { - logical_lines.push(InputLogicalLine { - text_range: 0..0, - wrapped_line: None, - y_offset: px(0.), - visual_line_count: 1, - }); - return logical_lines; - } - - let mut y_offset = px(0.); - let mut current_pos = 0; - - while current_pos < content.len() { - let line_end = content[current_pos..] - .find('\n') - .map(|pos| current_pos + pos) - .unwrap_or(content.len()); - - let line_slice = &content[current_pos..line_end]; - - if line_slice.is_empty() { - logical_lines.push(InputLogicalLine { - text_range: current_pos..current_pos, - wrapped_line: None, - y_offset, - visual_line_count: 1, - }); - y_offset += layout_data.line_height; - } else { - let run = TextRun { - len: line_slice.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_slice.to_string()), - font_size, - &[run], - 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 = layout_data.line_height * visual_line_count as f32; - - logical_lines.push(InputLogicalLine { - 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 < content.len() { - line_end + 1 - } else { - 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, - }); - } - - logical_lines - } - - fn move_to(&mut self, offset: usize, cx: &mut Context) { - cx.emit(CursorTrigger::PauseBlinkingForUserAction); - let offset = offset.min(self.content.len()); - self.selected_range = offset..offset; - self.selection_direction = NavigationDirection::Forward; - self.scroll_to_cursor(); - cx.notify(); - } - - fn select_to(&mut self, offset: usize, cx: &mut Context) { - cx.emit(CursorTrigger::PauseBlinkingForUserAction); - let offset = offset.min(self.content.len()); - 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); - } - - 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.)) - } - - fn previous_boundary(&self, offset: usize) -> usize { - if offset == 0 { - return 0; - } - - let text_before = &self.content.as_str()[..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.as_str()[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.as_str()[..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.as_str()[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.as_str().unicode_word_indices() { - let word_end = idx + word.len(); - if offset >= idx && offset <= word_end { - return (idx, word_end); - } - } - - (offset, offset) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::{AppContext, Entity, IntoElement, Render, TestAppContext, 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).with_layout_style(InputLayoutStyle::MultiLine); - input.content.emplace(content); - input.selected_range = range; - input - }); - TestView { input } - }) - } - - #[allow(dead_code)] - 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).with_layout_style(InputLayoutStyle::MultiLine); - 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.as_str(), - window, - &input.layout_data, - ); - 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_eq!(input.selection_direction, NavigationDirection::Back); - }); - }) - .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_eq!(input.selection_direction, NavigationDirection::Forward); - }); - }) - .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_eq!(input.selection_direction, NavigationDirection::Back); - }); - }) - .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_eq!(input.selection_direction, NavigationDirection::Forward); - }); - }) - .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().as_str(), "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().as_str(), "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().as_str(), "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().as_str(), "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().as_str(), " 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().as_str(), "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().as_str(), "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().as_str(), "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().as_str(), "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().as_str(), " 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().as_str(), "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().as_str(), ""); - - input.delete(&Delete, window, cx); - assert_eq!(input.content().as_str(), ""); - - 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_direction = NavigationDirection::Back; - input.marked_range = Some(5..7); - input.set_content("new content", cx); - 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); - }); - }) - .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().as_str(), "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().as_str(), "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().as_str(), "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).with_layout_style(InputLayoutStyle::SingleLine); - input.content.emplace(content); - 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().as_str(), "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().as_str(), "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_eq!(input.selection_direction, NavigationDirection::Back); - }); - }) - .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_eq!(input.selection_direction, NavigationDirection::Forward); - }); - }) - .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.layout_style(), InputLayoutStyle::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.layout_style(), InputLayoutStyle::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().as_str(), "hello world"); - - // Undo should restore original content - input.undo(&Undo, window, cx); - assert_eq!(input.content().as_str(), "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().as_str(), "hello world"); - - input.undo(&Undo, window, cx); - assert_eq!(input.content().as_str(), "hello"); - - input.redo(&Redo, window, cx); - assert_eq!(input.content().as_str(), "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.is_undo_available()); - input.undo(&Undo, window, cx); - assert_eq!(input.content().as_str(), "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.is_redo_available()); - input.redo(&Redo, window, cx); - assert_eq!(input.content().as_str(), "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().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().as_str(), "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().as_str(), "abc"); - - input.undo(&Undo, window, cx); - assert_eq!(input.content().as_str(), "ab"); - - input.undo(&Undo, window, cx); - assert_eq!(input.content().as_str(), "a"); - - input.undo(&Undo, window, cx); - assert_eq!(input.content().as_str(), ""); - - input.redo(&Redo, window, cx); - assert_eq!(input.content().as_str(), "a"); - - input.redo(&Redo, window, cx); - assert_eq!(input.content().as_str(), "ab"); - - input.redo(&Redo, window, cx); - assert_eq!(input.content().as_str(), "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().as_str(), "hello world"); - - input.undo(&Undo, window, cx); - 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().as_str(), "hello!"); - assert!(!input.is_redo_available()); - }); - }) - .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.is_undo_available()); - - input.set_content("new content", cx); - assert!(!input.is_undo_available()); - assert!(!input.is_redo_available()); - }); - }) - .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.is_undo_available()); - assert!(!input.is_redo_available()); - - input.replace_text_in_range(None, "!", window, cx); - assert!(input.is_undo_available()); - assert!(!input.is_redo_available()); - - input.undo(&Undo, window, cx); - assert!(!input.is_undo_available()); - assert!(input.is_redo_available()); - - input.redo(&Redo, window, cx); - assert!(input.is_undo_available()); - assert!(!input.is_redo_available()); - }); - }) - .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().as_str(), "hell"); - - input.undo(&Undo, window, cx); - assert_eq!(input.content().as_str(), "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().as_str(), "ello"); - - input.undo(&Undo, window, cx); - assert_eq!(input.content().as_str(), "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().as_str(), " world"); - - input.undo(&Undo, window, cx); - assert_eq!(input.content().as_str(), "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().as_str(), "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().as_str(), "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().as_str(), "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().as_str(), "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().as_str(), ""); - }); - }) - .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().as_str(), "line1\nline3"); - - input.undo(&Undo, window, cx); - assert_eq!(input.content().as_str(), "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().as_str(), "hello world"); - - input.undo(&Undo, window, cx); - assert_eq!(input.content().as_str(), "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().as_str(), "hello\n world"); - - input.undo(&Undo, window, cx); - assert_eq!(input.content().as_str(), "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().as_str(), " 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().as_str(), " 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().as_str(), "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().as_str(), " 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().as_str(), " 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().as_str(), "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().as_str(), " 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().as_str(), "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().as_str(), "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().as_str(), "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().as_str(), "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().as_str(), "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().as_str(), " world"); - - input.undo(&Undo, window, cx); - assert_eq!(input.content().as_str(), "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().as_str(), "hello "); - - input.undo(&Undo, window, cx); - assert_eq!(input.content().as_str(), "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().as_str(), " world"); - - input.undo(&Undo, window, cx); - assert_eq!(input.content().as_str(), "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().as_str(), "hello"); - - input.undo(&Undo, window, cx); - 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 deleted file mode 100644 index a2ca7e26ab..0000000000 --- a/crates/gpui_elements/src/input/state_input_handler.rs +++ /dev/null @@ -1,197 +0,0 @@ -use crate::input::{CursorTrigger, InputStateEvent}; -use gpui::{ - Bounds, Context, EntityInputHandler, NavigationDirection, 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.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.content().utf_range_8to16(&clamped_range)); - Some(self.content().as_str()[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.content().utf_range_8to16(self.selected_range()), - reversed: self.selection_direction() == NavigationDirection::Back, - }) - } - - fn marked_text_range( - &self, - _window: &mut Window, - _cx: &mut Context, - ) -> Option> { - self.marked_range() - .as_ref() - .map(|range| self.content().utf_range_8to16(range)) - } - - fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { - self.set_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.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()); - - 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.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.mark_layout_dirty(); - - cx.emit(CursorTrigger::PauseBlinkingForUserAction); - 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.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()); - - let text_to_insert = self.layout_style().sanitize_content(new_text); - - self.update_utf16_len(range.clone(), &text_to_insert); - 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.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(|| { - range.start + text_to_insert.len()..range.start + text_to_insert.len() - }) - }); - self.mark_layout_dirty(); - - 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.content().utf_range_16to8(&range_utf16); - - for line in self.lines() { - 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_pixel_point(point); - 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 deleted file mode 100644 index fa3f33f251..0000000000 --- a/crates/gpui_elements/src/input/storage.rs +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index 586154d397..0000000000 --- a/crates/gpui_elements/src/input/unicode.rs +++ /dev/null @@ -1,71 +0,0 @@ -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 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 { - 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) - } -} diff --git a/crates/gpui_elements/src/lib.rs b/crates/gpui_elements/src/lib.rs index ecd8bbbeee..5214586101 100644 --- a/crates/gpui_elements/src/lib.rs +++ b/crates/gpui_elements/src/lib.rs @@ -1,2 +1 @@ pub mod editable_text; -pub mod input;