From 21dbaffcf46fe5588ade741fb80180ee6a2793cf Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 27 Jun 2025 20:48:26 +0800 Subject: [PATCH] input: Add `Cursor` and `Selection` type and export line number, cursor. (#1012) --- crates/story/examples/code-editor.rs | 91 +++++----- crates/story/src/textarea_story.rs | 33 ++-- crates/ui/Cargo.toml | 1 - crates/ui/src/input/change.rs | 16 +- crates/ui/src/input/cursor.rs | 194 +++++++++++++++++++++ crates/ui/src/input/element.rs | 111 ++++++------ crates/ui/src/input/marker.rs | 23 +-- crates/ui/src/input/mod.rs | 2 + crates/ui/src/input/state.rs | 244 +++++++++++++++------------ crates/ui/src/input/text_wrapper.rs | 16 ++ 10 files changed, 493 insertions(+), 238 deletions(-) create mode 100644 crates/ui/src/input/cursor.rs diff --git a/crates/story/examples/code-editor.rs b/crates/story/examples/code-editor.rs index d370901e..50911f2d 100644 --- a/crates/story/examples/code-editor.rs +++ b/crates/story/examples/code-editor.rs @@ -1,11 +1,11 @@ use gpui::*; use gpui_component::{ - checkbox::Checkbox, + button::{Button, ButtonVariants as _}, dropdown::{Dropdown, DropdownEvent, DropdownState}, h_flex, highlighter::{Language, LanguageConfig, LanguageRegistry}, input::{InputEvent, InputState, Marker, TabSize, TextInput}, - v_flex, + v_flex, ActiveTheme, Selectable, Sizable, }; use story::Assets; @@ -185,44 +185,55 @@ impl Render for Example { self.update_highlighter(window, cx); self.set_markers(window, cx); - v_flex() - .size_full() - .child( - h_flex() - .p_4() - .pb_0() - .gap_4() - .flex_shrink_0() - .items_center() - .justify_between() - .child(Dropdown::new(&self.language_state).title_prefix("Language: ")) - .child( - Checkbox::new("line-numbger") - .checked(self.line_number) - .on_click(cx.listener(|this, checked: &bool, window, cx| { - this.line_number = *checked; - this.input_state.update(cx, |state, cx| { - state.set_line_number(this.line_number, window, cx); - }); - cx.notify(); - })) - .label("Line Number"), - ), - ) - .child( - div() - .id("source") - .w_full() - .flex_1() - .p_4() - .font_family("Monaco") - .text_size(px(12.)) - .child( - TextInput::new(&self.input_state) - .h_full() - .focus_bordered(false), - ), - ) + v_flex().size_full().child( + v_flex() + .id("source") + .w_full() + .flex_1() + .p_4() + .gap_2() + .child( + TextInput::new(&self.input_state) + .h_full() + .font_family("Monaco") + .text_size(px(12.)) + .focus_bordered(false), + ) + .child( + h_flex() + .justify_between() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child( + h_flex() + .gap_3() + .child( + Dropdown::new(&self.language_state) + .menu_width(px(160.)) + .small(), + ) + .child( + Button::new("line-number") + .ghost() + .label("Line Number") + .small() + .selected(self.line_number) + .on_click(cx.listener(|this, _, window, cx| { + this.line_number = !this.line_number; + this.input_state.update(cx, |state, cx| { + state.set_line_number(this.line_number, window, cx); + }); + cx.notify(); + })), + ), + ) + .child({ + let loc = self.input_state.read(cx).line_column(); + let cursor = self.input_state.read(cx).cursor(); + format!("{} ({} c)", loc, cursor.offset()) + }), + ), + ) } } diff --git a/crates/story/src/textarea_story.rs b/crates/story/src/textarea_story.rs index 8ffea246..d1c99355 100644 --- a/crates/story/src/textarea_story.rs +++ b/crates/story/src/textarea_story.rs @@ -136,6 +136,8 @@ impl Focusable for TextareaStory { impl Render for TextareaStory { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let loc = self.textarea.read(cx).line_column(); + v_flex() .key_context(CONTEXT) .id("textarea-story") @@ -152,19 +154,28 @@ impl Render for TextareaStory { .child(TextInput::new(&self.textarea).h(px(320.))) .child( h_flex() - .gap_2() + .justify_between() .child( - Button::new("btn-insert-text") - .xsmall() - .label("Insert Text") - .on_click(cx.listener(Self::on_insert_text_to_textarea)), + h_flex() + .gap_2() + .child( + Button::new("btn-insert-text") + .xsmall() + .label("Insert Text") + .on_click( + cx.listener(Self::on_insert_text_to_textarea), + ), + ) + .child( + Button::new("btn-replace-text") + .xsmall() + .label("Replace Text") + .on_click( + cx.listener(Self::on_replace_text_to_textarea), + ), + ), ) - .child( - Button::new("btn-replace-text") - .xsmall() - .label("Replace Text") - .on_click(cx.listener(Self::on_replace_text_to_textarea)), - ), + .child(format!("{}:{}", loc.line, loc.column)), ), ), ) diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 65250003..198f591a 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -19,7 +19,6 @@ webview = ["dep:wry"] [dependencies] gpui.workspace = true gpui-component-macros.workspace = true - anyhow = "1" enum-iterator = "2.1.0" futures-util = "0.3.31" diff --git a/crates/ui/src/input/change.rs b/crates/ui/src/input/change.rs index 657bbae9..bd771a9a 100644 --- a/crates/ui/src/input/change.rs +++ b/crates/ui/src/input/change.rs @@ -1,27 +1,27 @@ -use std::{fmt::Debug, ops::Range}; +use std::fmt::Debug; -use crate::history::HistoryItem; +use crate::{history::HistoryItem, input::Selection}; #[derive(Debug, PartialEq, Clone)] pub struct Change { - pub(crate) old_range: Range, + pub(crate) old_range: Selection, pub(crate) old_text: String, - pub(crate) new_range: Range, + pub(crate) new_range: Selection, pub(crate) new_text: String, version: usize, } impl Change { pub fn new( - old_range: Range, + old_range: impl Into, old_text: &str, - new_range: Range, + new_range: impl Into, new_text: &str, ) -> Self { Self { - old_range, + old_range: old_range.into(), old_text: old_text.to_string(), - new_range, + new_range: new_range.into(), new_text: new_text.to_string(), version: 0, } diff --git a/crates/ui/src/input/cursor.rs b/crates/ui/src/input/cursor.rs new file mode 100644 index 00000000..a27f790e --- /dev/null +++ b/crates/ui/src/input/cursor.rs @@ -0,0 +1,194 @@ +use std::{ + cmp::Ordering, + fmt, + ops::{Add, Deref, Range, Sub}, +}; + +/// Cursor of the text. +#[derive(Debug, Copy, Clone, Default)] +pub struct Cursor { + /// The byte offset in the text (zero-based). + pub(super) offset: usize, +} + +impl Cursor { + pub fn new(offset: usize) -> Self { + Self { offset } + } + + /// Returns the byte offset in the text (zero-based). + pub fn offset(&self) -> usize { + self.offset + } +} + +impl Eq for Cursor {} +impl PartialEq for Cursor { + fn eq(&self, other: &Self) -> bool { + self.offset == other.offset + } +} +impl PartialEq for Cursor { + fn eq(&self, other: &usize) -> bool { + self.offset == *other + } +} +impl PartialEq for usize { + fn eq(&self, other: &Cursor) -> bool { + *self == other.offset + } +} + +impl PartialOrd for Cursor { + fn partial_cmp(&self, other: &Self) -> Option { + self.offset.partial_cmp(&other.offset) + } +} +impl PartialOrd for Cursor { + fn partial_cmp(&self, other: &usize) -> Option { + self.offset.partial_cmp(other) + } +} +impl PartialOrd for usize { + fn partial_cmp(&self, other: &Cursor) -> Option { + self.partial_cmp(&other.offset) + } +} + +impl Add for Cursor { + type Output = Self; + + fn add(mut self, other: Self) -> Self { + self.offset += other.offset; + self + } +} +impl Add for Cursor { + type Output = Self; + + fn add(mut self, other: usize) -> Self { + self.offset += other; + self + } +} +impl Add for usize { + type Output = Cursor; + + fn add(self, other: Cursor) -> Cursor { + Cursor::new(self + other.offset) + } +} + +impl Sub for Cursor { + type Output = Self; + + fn sub(mut self, other: Self) -> Self { + self.offset -= other.offset; + self + } +} +impl Sub for Cursor { + type Output = Self; + + fn sub(mut self, other: usize) -> Self { + self.offset -= other; + self + } +} +impl Sub for usize { + type Output = Cursor; + + fn sub(self, other: Cursor) -> Cursor { + Cursor::new(self - other.offset) + } +} + +impl Deref for Cursor { + type Target = usize; + + fn deref(&self) -> &Self::Target { + &self.offset + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)] +pub struct Selection { + pub start: Cursor, + pub end: Cursor, +} + +impl Selection { + pub fn new(start: Cursor, end: Cursor) -> Self { + Self { start, end } + } + + pub fn len(&self) -> usize { + self.end.offset.saturating_sub(self.start.offset) + } + + pub fn is_empty(&self) -> bool { + self.start == self.end + } +} + +impl From> for Selection { + fn from(value: Range) -> Self { + Self::new(value.start, value.end) + } +} +impl From for Range { + fn from(value: Selection) -> Self { + value.start..value.end + } +} +impl From> for Selection { + fn from(value: Range) -> Self { + Self::new(Cursor::new(value.start), Cursor::new(value.end)) + } +} +impl From for Range { + fn from(value: Selection) -> Self { + value.start.offset..value.end.offset + } +} +impl From<&Selection> for Range { + fn from(value: &Selection) -> Self { + value.start.offset..value.end.offset + } +} + +/// Line and column position (1-based) in the source code. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct LineColumn { + /// Line number (1-based) + pub line: usize, + /// Column number (1-based) + pub column: usize, +} + +impl From<(usize, usize)> for LineColumn { + fn from(value: (usize, usize)) -> Self { + Self { + line: value.0.max(1), + column: value.1.max(1), + } + } +} + +impl fmt::Display for LineColumn { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.line, self.column) + } +} + +#[cfg(test)] +mod tests { + use crate::input::LineColumn; + + #[test] + fn test_line_column_display() { + assert_eq!(LineColumn::from((1, 2)).to_string(), "1:2"); + assert_eq!(LineColumn::from((10, 10)).to_string(), "10:10"); + assert_eq!(LineColumn::from((0, 0)).to_string(), "1:1"); + } +} diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 5c4dc52c..0615a141 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -21,14 +21,14 @@ const BOTTOM_MARGIN_ROWS: usize = 1; const LINE_NUMBER_MARGIN_RIGHT: Pixels = px(10.); pub(super) struct TextElement { - input: Entity, + state: Entity, placeholder: SharedString, } impl TextElement { - pub(super) fn new(input: Entity) -> Self { + pub(super) fn new(state: Entity) -> Self { Self { - input, + state, placeholder: SharedString::default(), } } @@ -41,12 +41,12 @@ impl TextElement { fn paint_mouse_listeners(&mut self, window: &mut Window, _: &mut App) { window.on_mouse_event({ - let input = self.input.clone(); + let state = self.state.clone(); move |event: &MouseMoveEvent, _, window, cx| { if event.pressed_button == Some(MouseButton::Left) { - input.update(cx, |input, cx| { - input.on_drag_move(event, window, cx); + state.update(cx, |state, cx| { + state.on_drag_move(event, window, cx); }); } } @@ -67,19 +67,19 @@ impl TextElement { window: &mut Window, cx: &mut App, ) -> (Option>, Point, Option) { - let input = self.input.read(cx); - let mut selected_range = input.selected_range.clone(); - if let Some(marked_range) = &input.marked_range { - selected_range = marked_range.end..marked_range.end; + let state = self.state.read(cx); + let mut selected_range = state.selected_range; + if let Some(marked_range) = &state.marked_range { + selected_range = (marked_range.end..marked_range.end).into(); } - let cursor_offset = input.cursor_offset(); + let cursor = state.cursor(); let mut current_line_index = None; - let mut scroll_offset = input.scroll_handle.offset(); + let mut scroll_offset = state.scroll_handle.offset(); let mut cursor_bounds = None; // If the input has a fixed height (Otherwise is auto-grow), we need to add a bottom margin to the input. - let bottom_margin = if input.is_auto_grow() { + let bottom_margin = if state.is_auto_grow() { px(0.) + line_height } else { BOTTOM_MARGIN_ROWS * line_height + line_height @@ -99,7 +99,8 @@ impl TextElement { let line_origin = point(px(0.), offset_y); if cursor_pos.is_none() { - let offset = cursor_offset.saturating_sub(prev_lines_offset); + let offset = cursor.offset.saturating_sub(prev_lines_offset); + if let Some(pos) = line.position_for_index(offset, line_height) { current_line_index = Some(line_ix); cursor_pos = Some(line_origin + pos); @@ -126,8 +127,8 @@ impl TextElement { if let (Some(cursor_pos), Some(cursor_start), Some(cursor_end)) = (cursor_pos, cursor_start, cursor_end) { - let cursor_moved = input.last_cursor_offset != Some(cursor_offset); - let selection_changed = input.last_selected_range != Some(selected_range.clone()); + let cursor_moved = state.last_cursor != Some(cursor); + let selection_changed = state.last_selected_range != Some(selected_range); if cursor_moved || selection_changed { scroll_offset.x = @@ -152,7 +153,7 @@ impl TextElement { scroll_offset.y }; - if input.selection_reversed { + if state.selection_reversed { if scroll_offset.x + cursor_start.x < px(0.) { // selection start is out of left scroll_offset.x = -cursor_start.x; @@ -173,7 +174,7 @@ impl TextElement { } } - if input.show_cursor(window, cx) { + if state.show_cursor(window, cx) { // cursor blink let cursor_height = line_height; cursor_bounds = Some(Bounds::new( @@ -200,11 +201,11 @@ impl TextElement { _: &mut Window, cx: &mut App, ) -> Option> { - let input = self.input.read(cx); - let mut selected_range = input.selected_range.clone(); - if let Some(marked_range) = &input.marked_range { + let state = self.state.read(cx); + let mut selected_range = state.selected_range; + if let Some(marked_range) = &state.marked_range { if !marked_range.is_empty() { - selected_range = marked_range.end..marked_range.end; + selected_range = (marked_range.end..marked_range.end).into(); } } if selected_range.is_empty() { @@ -368,7 +369,7 @@ impl TextElement { let theme = LanguageRegistry::global(cx) .theme(cx.theme().is_dark()) .clone(); - self.input.update(cx, |state, cx| match &state.mode { + self.state.update(cx, |state, cx| match &state.mode { InputMode::CodeEditor { language, highlighter, @@ -504,19 +505,19 @@ impl Element for TextElement { window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { - let input = self.input.read(cx); + let state = self.state.read(cx); let line_height = window.line_height(); let mut style = Style::default(); style.size.width = relative(1.).into(); - if self.input.read(cx).is_multi_line() { + if state.is_multi_line() { style.flex_grow = 1.0; - if let Some(h) = input.mode.height() { + if let Some(h) = state.mode.height() { style.size.height = h.into(); style.min_size.height = line_height.into(); } else { style.size.height = relative(1.).into(); - style.min_size.height = (input.mode.rows() * line_height).into(); + style.min_size.height = (state.mode.rows() * line_height).into(); } } else { // For single-line inputs, the minimum height should be the line height @@ -535,15 +536,15 @@ impl Element for TextElement { window: &mut Window, cx: &mut App, ) -> Self::PrepaintState { - let state = self.input.read(cx); + let state = self.state.read(cx); let line_height = window.line_height(); let visible_range = self.calculate_visible_range(&state, line_height, bounds.size.height); let highlight_styles = self.highlight_lines(&visible_range, cx); - let multi_line = self.input.read(cx).is_multi_line(); - let input = self.input.read(cx); - let text = input.text.clone(); + let state = self.state.read(cx); + let multi_line = state.is_multi_line(); + let text = state.text.clone(); let is_empty = text.is_empty(); let placeholder = self.placeholder.clone(); let style = window.text_style(); @@ -552,7 +553,7 @@ impl Element for TextElement { let (display_text, text_color) = if is_empty { (placeholder, cx.theme().muted_foreground) - } else if input.masked { + } else if state.masked { ( "*".repeat(text.chars().count()).into(), cx.theme().foreground, @@ -581,7 +582,7 @@ impl Element for TextElement { None, ) .unwrap(); - let line_number_width = if input.mode.line_number() { + let line_number_width = if state.mode.line_number() { empty_line_number.last().unwrap().width() + LINE_NUMBER_MARGIN_RIGHT } else { px(0.) @@ -620,7 +621,7 @@ impl Element for TextElement { runs.extend(highlight_styles.iter().map(|(range, style)| { let mut run = text_style.clone().highlight(*style).to_run(range.len()); - if let Some(marked_range) = &input.marked_range { + if let Some(marked_range) = &state.marked_range { if range.start >= marked_range.start && range.end <= marked_range.end { run.color = marked_run.color; run.strikethrough = marked_run.strikethrough; @@ -635,20 +636,20 @@ impl Element for TextElement { } else { vec![run] } - } else if let Some(marked_range) = &input.marked_range { + } else if let Some(marked_range) = &state.marked_range { // IME marked text vec![ TextRun { - len: marked_range.start, + len: marked_range.start.offset, ..run.clone() }, TextRun { - len: marked_range.end - marked_range.start, + len: marked_range.end.offset - marked_range.start.offset, underline: marked_run.underline, ..run.clone() }, TextRun { - len: display_text.len() - marked_range.end, + len: display_text.len() - marked_range.end.offset, ..run.clone() }, ] @@ -740,8 +741,8 @@ impl Element for TextElement { cx, ); - let input = self.input.read(cx); - let line_numbers = if input.mode.line_number() { + let state = self.state.read(cx); + let line_numbers = if state.mode.line_number() { let mut line_numbers = vec![]; let run_len = 4; let other_line_runs = vec![TextRun { @@ -820,21 +821,21 @@ impl Element for TextElement { window: &mut Window, cx: &mut App, ) { - let focus_handle = self.input.read(cx).focus_handle.clone(); + let focus_handle = self.state.read(cx).focus_handle.clone(); let focused = focus_handle.is_focused(window); let bounds = prepaint.bounds; - let selected_range = self.input.read(cx).selected_range.clone(); + let selected_range = self.state.read(cx).selected_range; let visible_range = &prepaint.last_layout.visible_range; window.handle_input( &focus_handle, - ElementInputHandler::new(bounds, self.input.clone()), + ElementInputHandler::new(bounds, self.state.clone()), cx, ); // Set Root focused_input when self is focused if focused { - let state = self.input.clone(); + let state = self.state.clone(); if Root::read(window, cx).focused_input.as_ref() != Some(&state) { Root::update(window, cx, |root, _, cx| { root.focused_input = Some(state); @@ -845,7 +846,7 @@ impl Element for TextElement { // And reset focused_input when next_frame start window.on_next_frame({ - let state = self.input.clone(); + let state = self.state.clone(); move |window, cx| { if !focused && Root::read(window, cx).focused_input.as_ref() == Some(&state) { Root::update(window, cx, |root, _, cx| { @@ -866,7 +867,7 @@ impl Element for TextElement { } let mut mask_offset_y = px(0.); - if self.input.read(cx).masked { + if self.state.read(cx).masked { // Move down offset for vertical centering the ***** if cfg!(target_os = "macos") { mask_offset_y = px(3.); @@ -931,15 +932,15 @@ impl Element for TextElement { } } - self.input.update(cx, |input, cx| { - input.last_layout = Some(prepaint.last_layout.clone()); - input.last_bounds = Some(bounds); - input.last_cursor_offset = Some(input.cursor_offset()); - input.set_input_bounds(input_bounds, cx); - input.last_selected_range = Some(selected_range); - input.scroll_size = prepaint.scroll_size; - input.line_number_width = prepaint.line_number_width; - input + self.state.update(cx, |state, cx| { + state.last_layout = Some(prepaint.last_layout.clone()); + state.last_bounds = Some(bounds); + state.last_cursor = Some(state.cursor()); + state.set_input_bounds(input_bounds, cx); + state.last_selected_range = Some(selected_range); + state.scroll_size = prepaint.scroll_size; + state.line_number_width = prepaint.line_number_width; + state .scroll_handle .set_offset(prepaint.cursor_scroll_offset); cx.notify(); diff --git a/crates/ui/src/input/marker.rs b/crates/ui/src/input/marker.rs index 1c207616..770d788f 100644 --- a/crates/ui/src/input/marker.rs +++ b/crates/ui/src/input/marker.rs @@ -1,4 +1,7 @@ -use crate::{highlighter::HighlightTheme, input::InputState}; +use crate::{ + highlighter::HighlightTheme, + input::{InputState, LineColumn}, +}; use gpui::{px, HighlightStyle, Hsla, SharedString, UnderlineStyle}; use itertools::Itertools; use std::ops::Range; @@ -75,24 +78,6 @@ impl Marker { } } -/// Line and column position (1-based) in the source code. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct LineColumn { - /// Line number (1-based) - pub line: usize, - /// Column number (1-based) - pub column: usize, -} - -impl From<(usize, usize)> for LineColumn { - fn from(value: (usize, usize)) -> Self { - Self { - line: value.0.max(1), - column: value.1.max(1), - } - } -} - /// Severity of the marker. #[allow(unused)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index c34d213e..2defa36c 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -1,6 +1,7 @@ mod blink_cursor; mod change; mod clear_button; +mod cursor; mod element; mod hover_popover; mod marker; @@ -13,6 +14,7 @@ mod text_input; mod text_wrapper; pub(crate) use clear_button::*; +pub(super) use cursor::*; pub use marker::*; pub use mask_pattern::MaskPattern; pub use mode::TabSize; diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index 7097ca20..e9b7bd35 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -32,6 +32,7 @@ use super::{ }; use crate::input::hover_popover::DiagnosticPopover; use crate::input::marker::Marker; +use crate::input::{Cursor, LineColumn, Selection}; use crate::{history::History, scroll::ScrollbarState, Root}; #[derive(Action, Clone, PartialEq, Eq, Deserialize)] @@ -244,19 +245,19 @@ pub struct InputState { /// /// - "Hello δΈ–η•ŒπŸ’" = 16 /// - "πŸ’" = 4 - pub(super) selected_range: Range, + pub(super) selected_range: Selection, /// Range for save the selected word, use to keep word range when drag move. - pub(super) selected_word_range: Option>, + pub(super) selected_word_range: Option, pub(super) selection_reversed: bool, /// The marked range is the temporary insert text on IME typing. - pub(super) marked_range: Option>, + pub(super) marked_range: Option, pub(super) last_layout: Option, - pub(super) last_cursor_offset: Option, + pub(super) last_cursor: Option, /// The input container bounds pub(super) input_bounds: Bounds, /// The text bounds pub(super) last_bounds: Option>, - pub(super) last_selected_range: Option>, + pub(super) last_selected_range: Option, pub(super) selecting: bool, pub(super) disabled: bool, pub(super) masked: bool, @@ -322,7 +323,7 @@ impl InputState { ), blink_cursor, history, - selected_range: 0..0, + selected_range: (Cursor::new(0)..Cursor::new(0)).into(), selected_word_range: None, selection_reversed: false, marked_range: None, @@ -338,7 +339,7 @@ impl InputState { last_layout: None, last_bounds: None, last_selected_range: None, - last_cursor_offset: None, + last_cursor: None, scroll_handle: ScrollHandle::new(), scroll_state: ScrollbarState::default(), scroll_size: gpui::size(px(0.), px(0.)), @@ -519,7 +520,7 @@ impl InputState { }; // Find which line and sub-line the cursor is on and its position - let (_, _, cursor_pos) = self.line_and_position_for_offset(self.cursor_offset()); + let (_, _, cursor_pos) = self.line_and_position_for_offset(self.cursor().offset); if let Some(pos) = cursor_pos { self.preferred_x_offset = Some(pos.x + bounds.origin.x); @@ -570,7 +571,7 @@ impl InputState { return; }; - let offset = self.cursor_offset(); + let offset = self.cursor().offset; let preferred_x_offset = self.preferred_x_offset; let line_height = last_layout.line_height; let (current_line, current_sub_line, current_pos) = @@ -591,7 +592,7 @@ impl InputState { // Handle moving above the first line if move_lines < 0 && current_line == 0 && new_sub_line < 0 { // Move cursor to the beginning of the text - self.move_to(0, window, cx); + self.move_to(Cursor::new(0), window, cx); self.preferred_x_offset = preferred_x_offset; return; } @@ -645,7 +646,8 @@ impl InputState { } let new_offset = (prev_lines_offset + new_local_index).min(self.text.len()); - self.selected_range = new_offset..new_offset; + let new_cursor = Cursor::new(new_offset); + self.selected_range = (new_cursor..new_cursor).into(); self.pause_blink_cursor(cx); // Set back the preferred_x_offset self.preferred_x_offset = preferred_x_offset; @@ -684,9 +686,10 @@ impl InputState { self.history.ignore = false; // Ensure cursor to start when set text if self.is_single_line() { - self.selected_range = self.text.len()..self.text.len(); + self.selected_range = + (Cursor::new(self.text.len())..Cursor::new(self.text.len())).into(); } else { - self.selected_range = 0..0; + self.selected_range = (Cursor::new(0)..Cursor::new(0)).into(); } // Move scroll to top self.scroll_handle.set_offset(point(px(0.), px(0.))); @@ -704,9 +707,9 @@ impl InputState { cx: &mut Context, ) { let text: SharedString = text.into(); - let range_utf16 = self.range_to_utf16(&(self.cursor_offset()..self.cursor_offset())); + let range_utf16 = self.range_to_utf16(&(self.cursor().offset..self.cursor().offset)); self.replace_text_in_range(Some(range_utf16), &text, window, cx); - self.selected_range = self.selected_range.end..self.selected_range.end; + self.selected_range = (self.selected_range.end..self.selected_range.end).into(); } /// Replace text at the current cursor position. @@ -720,7 +723,7 @@ impl InputState { ) { let text: SharedString = text.into(); self.replace_text_in_range(None, &text, window, cx); - self.selected_range = self.selected_range.end..self.selected_range.end; + self.selected_range = (self.selected_range.end..self.selected_range.end).into(); } fn replace_text( @@ -819,6 +822,11 @@ impl InputState { self.mask_pattern.unmask(&self.text).into() } + /// Return the line and column (1-based) of the cursor. + pub fn line_column(&self) -> LineColumn { + self.text_wrapper.line_column(self.cursor().offset) + } + /// Focus the input field. pub fn focus(&self, window: &mut Window, _: &mut Context) { self.focus_handle.focus(window); @@ -827,7 +835,11 @@ impl InputState { pub(super) fn left(&mut self, _: &MoveLeft, window: &mut Window, cx: &mut Context) { self.pause_blink_cursor(cx); if self.selected_range.is_empty() { - self.move_to(self.previous_boundary(self.cursor_offset()), window, cx); + self.move_to( + Cursor::new(self.previous_boundary(self.cursor().offset)), + window, + cx, + ); } else { self.move_to(self.selected_range.start, window, cx) } @@ -836,7 +848,11 @@ impl InputState { pub(super) fn right(&mut self, _: &MoveRight, window: &mut Window, cx: &mut Context) { self.pause_blink_cursor(cx); if self.selected_range.is_empty() { - self.move_to(self.next_boundary(self.selected_range.end), window, cx); + self.move_to( + Cursor::new(self.next_boundary(self.selected_range.end.offset)), + window, + cx, + ); } else { self.move_to(self.selected_range.end, window, cx) } @@ -849,7 +865,7 @@ impl InputState { if !self.selected_range.is_empty() { self.move_to( - self.previous_boundary(self.selected_range.start.saturating_sub(1)), + Cursor::new(self.previous_boundary(self.selected_range.start.saturating_sub(1))), window, cx, ); @@ -865,7 +881,7 @@ impl InputState { if !self.selected_range.is_empty() { self.move_to( - self.next_boundary(self.selected_range.end.saturating_sub(1)), + Cursor::new(self.next_boundary(self.selected_range.end.offset.saturating_sub(1))), window, cx, ); @@ -912,7 +928,11 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - self.select_to(self.previous_boundary(self.cursor_offset()), window, cx); + self.select_to( + Cursor::new(self.previous_boundary(self.cursor().offset)), + window, + cx, + ); } pub(super) fn select_right( @@ -921,7 +941,11 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - self.select_to(self.next_boundary(self.cursor_offset()), window, cx); + self.select_to( + Cursor::new(self.next_boundary(self.cursor().offset)), + window, + cx, + ); } pub(super) fn select_up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { @@ -929,7 +953,7 @@ impl InputState { return; } let offset = self.start_of_line(window, cx).saturating_sub(1); - self.select_to(self.previous_boundary(offset), window, cx); + self.select_to(Cursor::new(self.previous_boundary(offset)), window, cx); } pub(super) fn select_down( @@ -942,7 +966,7 @@ impl InputState { return; } let offset = (self.end_of_line(window, cx) + 1).min(self.text.len()); - self.select_to(self.next_boundary(offset), window, cx); + self.select_to(Cursor::new(self.next_boundary(offset)), window, cx); } pub(super) fn select_all( @@ -951,20 +975,20 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - self.move_to(0, window, cx); - self.select_to(self.text.len(), window, cx) + self.move_to(Cursor::new(0), window, cx); + self.select_to(Cursor::new(self.text.len()), window, cx) } pub(super) fn home(&mut self, _: &MoveHome, window: &mut Window, cx: &mut Context) { self.pause_blink_cursor(cx); let offset = self.start_of_line(window, cx); - self.move_to(offset, window, cx); + self.move_to(Cursor::new(offset), window, cx); } pub(super) fn end(&mut self, _: &MoveEnd, window: &mut Window, cx: &mut Context) { self.pause_blink_cursor(cx); let offset = self.end_of_line(window, cx); - self.move_to(offset, window, cx); + self.move_to(Cursor::new(offset), window, cx); } pub(super) fn move_to_start( @@ -973,7 +997,7 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - self.move_to(0, window, cx); + self.move_to(Cursor::new(0), window, cx); } pub(super) fn move_to_end( @@ -983,7 +1007,7 @@ impl InputState { cx: &mut Context, ) { let end = self.text.len(); - self.move_to(end, window, cx); + self.move_to(Cursor::new(end), window, cx); } pub(super) fn move_to_previous_word( @@ -993,7 +1017,7 @@ impl InputState { cx: &mut Context, ) { let offset = self.previous_start_of_word(); - self.move_to(offset, window, cx); + self.move_to(Cursor::new(offset), window, cx); } pub(super) fn move_to_next_word( @@ -1003,7 +1027,7 @@ impl InputState { cx: &mut Context, ) { let offset = self.next_end_of_word(); - self.move_to(offset, window, cx); + self.move_to(Cursor::new(offset), window, cx); } pub(super) fn select_to_start( @@ -1012,7 +1036,7 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - self.select_to(0, window, cx); + self.select_to(Cursor::new(0), window, cx); } pub(super) fn select_to_end( @@ -1022,7 +1046,7 @@ impl InputState { cx: &mut Context, ) { let end = self.text.len(); - self.select_to(end, window, cx); + self.select_to(Cursor::new(end), window, cx); } pub(super) fn select_to_start_of_line( @@ -1032,7 +1056,7 @@ impl InputState { cx: &mut Context, ) { let offset = self.start_of_line(window, cx); - self.select_to(self.previous_boundary(offset), window, cx); + self.select_to(Cursor::new(self.previous_boundary(offset)), window, cx); } pub(super) fn select_to_end_of_line( @@ -1042,7 +1066,7 @@ impl InputState { cx: &mut Context, ) { let offset = self.end_of_line(window, cx); - self.select_to(self.next_boundary(offset), window, cx); + self.select_to(Cursor::new(self.next_boundary(offset)), window, cx); } pub(super) fn select_to_previous_word( @@ -1052,7 +1076,7 @@ impl InputState { cx: &mut Context, ) { let offset = self.previous_start_of_word(); - self.select_to(offset, window, cx); + self.select_to(Cursor::new(offset), window, cx); } pub(super) fn select_to_next_word( @@ -1062,12 +1086,12 @@ impl InputState { cx: &mut Context, ) { let offset = self.next_end_of_word(); - self.select_to(offset, window, cx); + self.select_to(Cursor::new(offset), window, cx); } /// Return the start offset of the previous word. fn previous_start_of_word(&mut self) -> usize { - let offset = self.selected_range.start; + let offset = self.selected_range.start.offset; let prev_str = self.text_for_range_utf8(0..offset); UnicodeSegmentation::split_word_bound_indices(prev_str) .filter(|(_, s)| !s.trim_start().is_empty()) @@ -1078,7 +1102,7 @@ impl InputState { /// Return the next end offset of the next word. fn next_end_of_word(&mut self) -> usize { - let offset = self.cursor_offset(); + let offset = self.cursor().offset; let next_str = self.text_for_range_utf8(offset..self.text.len()); UnicodeSegmentation::split_word_bound_indices(next_str) .find(|(_, s)| !s.trim_start().is_empty()) @@ -1092,7 +1116,7 @@ impl InputState { return 0; } - let offset = self.previous_boundary(self.cursor_offset()); + let offset = self.previous_boundary(self.cursor().offset); let line = self .text_for_range(self.range_to_utf16(&(0..offset + 1)), &mut None, window, cx) .unwrap_or_default() @@ -1110,8 +1134,11 @@ impl InputState { return 0; } - let mut offset = - self.previous_boundary(self.selected_range.start.min(self.selected_range.end)); + let mut offset = self.previous_boundary( + self.selected_range + .start + .min(self.selected_range.end.offset), + ); if self.text.chars().nth(offset) == Some('\r') { offset += 1; } @@ -1131,7 +1158,7 @@ impl InputState { return self.text.len(); } - let offset = self.next_boundary(self.cursor_offset()); + let offset = self.next_boundary(self.cursor().offset); // ignore if offset is "\n" if self .text_for_range( @@ -1205,7 +1232,11 @@ impl InputState { 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_offset()), window, cx) + self.select_to( + Cursor::new(self.previous_boundary(self.cursor().offset)), + window, + cx, + ) } self.replace_text_in_range(None, "", window, cx); self.pause_blink_cursor(cx); @@ -1213,7 +1244,11 @@ impl InputState { 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_offset()), window, cx) + self.select_to( + Cursor::new(self.next_boundary(self.cursor().offset)), + window, + cx, + ) } self.replace_text_in_range(None, "", window, cx); self.pause_blink_cursor(cx); @@ -1226,11 +1261,11 @@ impl InputState { cx: &mut Context, ) { let mut offset = self.start_of_line(window, cx); - if offset == self.cursor_offset() { + if offset == self.cursor().offset { offset = offset.saturating_sub(1); } self.replace_text_in_range( - Some(self.range_to_utf16(&(offset..self.cursor_offset()))), + Some(self.range_to_utf16(&(offset..self.cursor().offset))), "", window, cx, @@ -1246,11 +1281,11 @@ impl InputState { cx: &mut Context, ) { let mut offset = self.end_of_line(window, cx); - if offset == self.cursor_offset() { + if offset == self.cursor().offset { offset = (offset + 1).clamp(0, self.text.len()); } self.replace_text_in_range( - Some(self.range_to_utf16(&(self.cursor_offset()..offset))), + Some(self.range_to_utf16(&(self.cursor().offset..offset))), "", window, cx, @@ -1266,7 +1301,7 @@ impl InputState { ) { let offset = self.previous_start_of_word(); self.replace_text_in_range( - Some(self.range_to_utf16(&(offset..self.cursor_offset()))), + Some(self.range_to_utf16(&(offset..self.cursor().offset))), "", window, cx, @@ -1282,7 +1317,7 @@ impl InputState { ) { let offset = self.next_end_of_word(); self.replace_text_in_range( - Some(self.range_to_utf16(&(self.cursor_offset()..offset))), + Some(self.range_to_utf16(&(self.cursor().offset..offset))), "", window, cx, @@ -1346,7 +1381,7 @@ impl InputState { }; let tab_indent = tab_size.to_string(); - let selected_range = self.selected_range.clone(); + let selected_range = self.selected_range; let mut added_len = 0; let is_selected = !self.selected_range.is_empty(); @@ -1356,7 +1391,7 @@ impl InputState { let selected_text = self .text_for_range( - self.range_to_utf16(&(offset..selected_range.end)), + self.range_to_utf16(&(offset..selected_range.end.offset)), &mut None, window, cx, @@ -1376,14 +1411,15 @@ impl InputState { } if is_selected { - self.selected_range = start_offset..selected_range.end + added_len; + self.selected_range = + (Cursor::new(start_offset)..selected_range.end + added_len).into(); } else { self.selected_range = - selected_range.start + added_len..selected_range.end + added_len; + (selected_range.start + added_len..selected_range.end + added_len).into(); } } else { // Selected none - let offset = self.selected_range.start; + let offset = self.selected_range.start.offset; self.replace_text_in_range( Some(self.range_to_utf16(&(offset..offset))), &tab_indent, @@ -1392,7 +1428,8 @@ impl InputState { ); added_len = tab_indent.len(); - self.selected_range = selected_range.start + added_len..selected_range.end + added_len; + self.selected_range = + (selected_range.start + added_len..selected_range.end + added_len).into(); } } @@ -1402,7 +1439,7 @@ impl InputState { }; let tab_indent = tab_size.to_string(); - let selected_range = self.selected_range.clone(); + let selected_range = self.selected_range; let mut removed_len = 0; let is_selected = !self.selected_range.is_empty(); @@ -1412,7 +1449,7 @@ impl InputState { let selected_text = self .text_for_range( - self.range_to_utf16(&(offset..selected_range.end)), + self.range_to_utf16(&(offset..selected_range.end.offset)), &mut None, window, cx, @@ -1437,10 +1474,14 @@ impl InputState { } if is_selected { - self.selected_range = start_offset..selected_range.end.saturating_sub(removed_len); + self.selected_range = (Cursor::new(start_offset) + ..Cursor::new(selected_range.end.saturating_sub(removed_len))) + .into(); } else { - self.selected_range = selected_range.start.saturating_sub(removed_len) - ..selected_range.end.saturating_sub(removed_len); + self.selected_range = + (Cursor::new(selected_range.start.saturating_sub(removed_len)) + ..Cursor::new(selected_range.end.saturating_sub(removed_len))) + .into(); } } else { // Selected none @@ -1458,7 +1499,7 @@ impl InputState { ); removed_len = tab_indent.len(); let new_offset = start_offset.saturating_sub(removed_len); - self.selected_range = new_offset..new_offset; + self.selected_range = (Cursor::new(new_offset)..Cursor::new(new_offset)).into(); } } } @@ -1505,9 +1546,9 @@ impl InputState { } if event.modifiers.shift { - self.select_to(offset, window, cx); + self.select_to(Cursor::new(offset), window, cx); } else { - self.move_to(offset, window, cx) + self.move_to(Cursor::new(offset), window, cx) } } @@ -1594,9 +1635,7 @@ impl InputState { return; } - let selected_text = self - .text_for_range_utf8(self.selected_range.clone()) - .to_string(); + let selected_text = self.text_for_range_utf8(self.selected_range).to_string(); cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); } @@ -1605,9 +1644,7 @@ impl InputState { return; } - let selected_text = self - .text_for_range_utf8(self.selected_range.clone()) - .to_string(); + let selected_text = self.text_for_range_utf8(self.selected_range).to_string(); cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); self.replace_text_in_range(None, "", window, cx); } @@ -1640,19 +1677,15 @@ impl InputState { let new_range = range.start..range.start + new_text.len(); - self.history.push(Change::new( - range.clone(), - &old_text, - new_range.clone(), - new_text, - )); + self.history + .push(Change::new(range.clone(), &old_text, new_range, new_text)); } pub(super) fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context) { self.history.ignore = true; if let Some(changes) = self.history.undo() { for change in changes { - let range_utf16 = self.range_to_utf16(&change.new_range); + let range_utf16 = self.range_to_utf16(&change.new_range.into()); self.replace_text_in_range(Some(range_utf16), &change.old_text, window, cx); } } @@ -1663,7 +1696,7 @@ impl InputState { self.history.ignore = true; if let Some(changes) = self.history.redo() { for change in changes { - let range_utf16 = self.range_to_utf16(&change.old_range); + let range_utf16 = self.range_to_utf16(&change.old_range.into()); self.replace_text_in_range(Some(range_utf16), &change.new_text, window, cx); } } @@ -1675,15 +1708,18 @@ impl InputState { /// The offset is the UTF-8 offset. /// /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. - fn move_to(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { - let offset = offset.clamp(0, self.text.len()); - self.selected_range = offset..offset; + fn move_to(&mut self, cursor: Cursor, _: &mut Window, cx: &mut Context) { + let cursor = Cursor::new(cursor.offset.clamp(0, self.text.len())); + self.selected_range = (cursor..cursor).into(); self.pause_blink_cursor(cx); self.update_preferred_x_offset(cx); cx.notify() } - pub(super) fn cursor_offset(&self) -> usize { + /// Get the cursor position. + /// + /// The offset is the UTF-8 offset. + pub fn cursor(&self) -> Cursor { if let Some(marked_range) = &self.marked_range { return marked_range.end; } @@ -1799,17 +1835,17 @@ impl InputState { /// The offset is the UTF-8 offset. /// /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. - fn select_to(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { + fn select_to(&mut self, offset: Cursor, _: &mut Window, cx: &mut Context) { let offset = offset.clamp(0, self.text.len()); if self.selection_reversed { - self.selected_range.start = offset + self.selected_range.start = Cursor::new(offset) } else { - self.selected_range.end = offset + self.selected_range.end = Cursor::new(offset) }; if self.selected_range.end < self.selected_range.start { self.selection_reversed = !self.selection_reversed; - self.selected_range = self.selected_range.end..self.selected_range.start; + self.selected_range = (self.selected_range.end..self.selected_range.start).into(); } // Ensure keep word selected range @@ -1878,14 +1914,14 @@ impl InputState { return; } - self.selected_range = start..end; - self.selected_word_range = Some(self.selected_range.clone()); + self.selected_range = (start..end).into(); + self.selected_word_range = Some(self.selected_range); cx.notify() } fn unselect(&mut self, _: &mut Window, cx: &mut Context) { - let offset = self.next_boundary(self.cursor_offset()); - self.selected_range = offset..offset; + let offset = self.next_boundary(self.cursor().offset); + self.selected_range = (offset..offset).into(); cx.notify() } @@ -1998,7 +2034,7 @@ impl InputState { } let offset = self.index_for_mouse_position(event.position, window, cx); - self.select_to(offset, window, cx); + self.select_to(Cursor::new(offset), window, cx); } fn is_valid_input(&self, new_text: &str) -> bool { @@ -2090,7 +2126,7 @@ impl EntityInputHandler for InputState { _cx: &mut Context, ) -> Option { Some(UTF16Selection { - range: self.range_to_utf16(&self.selected_range), + range: self.range_to_utf16(&self.selected_range.into()), reversed: false, }) } @@ -2101,8 +2137,7 @@ impl EntityInputHandler for InputState { _cx: &mut Context, ) -> Option> { self.marked_range - .as_ref() - .map(|range| self.range_to_utf16(range)) + .map(|range| self.range_to_utf16(&range.into())) } fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { @@ -2127,8 +2162,8 @@ impl EntityInputHandler for InputState { let range = range_utf16 .as_ref() .map(|range_utf16| self.range_from_utf16(range_utf16)) - .or(self.marked_range.clone()) - .unwrap_or(self.selected_range.clone()); + .or(self.marked_range.map(|range| range.into())) + .unwrap_or(self.selected_range.into()); let pending_text: SharedString = (self.text_for_range_utf8(0..range.start).to_owned() + new_text @@ -2149,7 +2184,7 @@ impl EntityInputHandler for InputState { .update_highlighter(&range, self.text.clone(), &new_text, cx); self.mode.clear_markers(); self.text_wrapper.update(self.text.clone(), false, cx); - self.selected_range = new_offset..new_offset; + self.selected_range = (new_offset..new_offset).into(); self.marked_range.take(); self.update_preferred_x_offset(cx); self.update_scroll_offset(None, cx); @@ -2174,8 +2209,8 @@ impl EntityInputHandler for InputState { let range = range_utf16 .as_ref() .map(|range_utf16| self.range_from_utf16(range_utf16)) - .or(self.marked_range.clone()) - .unwrap_or(self.selected_range.clone()); + .or(self.marked_range.map(|range| range.into())) + .unwrap_or(self.selected_range.into()); let pending_text: SharedString = (self.text_for_range_utf8(0..range.start).to_owned() + new_text + self.text_for_range_utf8(range.end..self.text.len())) @@ -2192,15 +2227,16 @@ impl EntityInputHandler for InputState { self.text_wrapper.update(self.text.clone(), false, cx); if new_text.is_empty() { // Cancel selection, when cancel IME input. - self.selected_range = range.start..range.start; + self.selected_range = (range.start..range.start).into(); self.marked_range = None; } else { - self.marked_range = Some(range.start..range.start + new_text.len()); + self.marked_range = Some((range.start..range.start + new_text.len()).into()); self.selected_range = new_selected_range_utf16 .as_ref() .map(|range_utf16| self.range_from_utf16(range_utf16)) .map(|new_range| new_range.start + range.start..new_range.end + range.end) - .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()); + .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()) + .into(); } self.mode.update_auto_grow(&self.text_wrapper); cx.emit(InputEvent::Change(self.unmask_value())); diff --git a/crates/ui/src/input/text_wrapper.rs b/crates/ui/src/input/text_wrapper.rs index 05dac176..5c3a028a 100644 --- a/crates/ui/src/input/text_wrapper.rs +++ b/crates/ui/src/input/text_wrapper.rs @@ -1,5 +1,6 @@ use std::ops::Range; +use crate::input::LineColumn; use gpui::{App, Font, LineFragment, Pixels, SharedString}; #[allow(unused)] @@ -99,4 +100,19 @@ impl TextWrapper { self.wrapped_lines = wrapped_lines; self.lines = lines; } + + /// Returns the line and column (1-based) of the given offset (Entire text). + pub(super) fn line_column(&self, offset: usize) -> LineColumn { + if self.lines.is_empty() { + return LineColumn::default(); + } + + let line = self + .lines + .binary_search_by_key(&offset, |line| line.range.end) + .unwrap_or_else(|i| i); + let column = offset.saturating_sub(self.lines[line].range.start); + + (line + 1, column + 1).into() + } }