From 5658db42c7087544509b615ce91d4b2fbbcdfb36 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 20 May 2025 15:51:00 +0800 Subject: [PATCH] input: Add `CodeEditor` mode. (#863) ## TODO - [x] Cache highlight result. - [x] Add a `code-editor` example with more languages test. - [x] Support setup font to Input (Base on window scope text style). - [x] Line number - [x] Change font size and font family. - [x] Enter newline to keep indent. - [x] Height with flex_1 - [x] Add to support press `up`, `down` to move cursor to start or end of the selection, if there is selected. - [x] Add `Tab`, `Shift-Tab` to indent or outdent for cursor and selection. - [x] Double click to select at least 1 char. https://github.com/user-attachments/assets/af74862d-15a9-4802-b4b4-2aeb606227ec --- crates/story/examples/code-editor.rs | 151 ++++++++ crates/story/examples/html.rs | 27 +- crates/story/examples/markdown.rs | 41 ++- crates/ui/src/highlighter/highlight.rs | 2 +- crates/ui/src/input/element.rs | 156 +++++++- crates/ui/src/input/mod.rs | 2 + crates/ui/src/input/mode.rs | 194 ++++++++++ crates/ui/src/input/state.rs | 482 ++++++++++++++++++------- crates/ui/src/input/text_input.rs | 9 +- crates/ui/src/input/text_wrapper.rs | 59 ++- 10 files changed, 952 insertions(+), 171 deletions(-) create mode 100644 crates/story/examples/code-editor.rs create mode 100644 crates/ui/src/input/mode.rs diff --git a/crates/story/examples/code-editor.rs b/crates/story/examples/code-editor.rs new file mode 100644 index 00000000..1c3d6edb --- /dev/null +++ b/crates/story/examples/code-editor.rs @@ -0,0 +1,151 @@ +use std::sync::LazyLock; + +use gpui::*; +use gpui_component::{ + checkbox::Checkbox, + dropdown::{Dropdown, DropdownEvent, DropdownState}, + h_flex, + highlighter::{HighlightTheme, Highlighter}, + input::{InputEvent, InputState, TabSize, TextInput}, + v_flex, ActiveTheme as _, +}; +use story::Assets; + +static LIGHT_THEME: LazyLock = LazyLock::new(|| HighlightTheme::default_light()); +static DARK_THEME: LazyLock = LazyLock::new(|| HighlightTheme::default_dark()); + +pub struct Example { + input_state: Entity, + language_state: Entity>>, + language: SharedString, + is_dark: bool, + line_number: bool, + _subscribes: Vec, +} + +const EXAMPLE: &str = include_str!("./code-editor.rs"); +const LANGUAGES: [&str; 7] = ["rust", "javascript", "html", "css", "go", "python", "ruby"]; + +impl Example { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let default_language: SharedString = LANGUAGES[0].into(); + let input_state = cx.new(|cx| { + InputState::new(window, cx) + .code_editor(Some(&default_language), &LIGHT_THEME) + .line_number(true) + .tab_size(TabSize { + tab_size: 4, + hard_tabs: false, + }) + .default_value(EXAMPLE) + .placeholder("Enter your code here...") + }); + let language_state = cx.new(|cx| { + DropdownState::new( + LANGUAGES.iter().map(|s| s.to_string().into()).collect(), + Some(0), + window, + cx, + ) + }); + + let _subscribes = vec![ + cx.subscribe(&input_state, |_, _, _: &InputEvent, cx| { + cx.notify(); + }), + cx.subscribe( + &language_state, + |this, state, _: &DropdownEvent>, cx| { + if let Some(val) = state.read(cx).selected_value() { + this.update_highlighter(Some(val.clone()), cx); + cx.notify(); + } + }, + ), + ]; + + Self { + input_state, + language_state, + language: default_language, + is_dark: false, + line_number: true, + _subscribes, + } + } + + fn view(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Self::new(window, cx)) + } + + fn update_highlighter(&mut self, new_language: Option, cx: &mut Context) { + let is_dark = cx.theme().mode.is_dark(); + let is_language_changed = new_language.is_some(); + if new_language.is_some() { + self.language = new_language.unwrap(); + } + let language = self.language.as_ref(); + if self.is_dark != is_dark || is_language_changed { + self.is_dark = is_dark; + self.input_state.update(cx, |state, cx| { + if is_dark { + state.set_highlighter(Highlighter::new(Some(language), &DARK_THEME), cx); + } else { + state.set_highlighter(Highlighter::new(Some(language), &LIGHT_THEME), cx); + } + }); + } + } +} + +impl Render for Example { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + self.update_highlighter(None, 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() + .font_family("Menlo") + .p_4() + .text_size(px(13.)) + .child(TextInput::new(&self.input_state).h_full()), + ) + } +} + +fn main() { + let app = Application::new().with_assets(Assets); + + app.run(move |cx| { + story::init(cx); + cx.activate(true); + + story::create_new_window("Code Editor", Example::view, cx); + }); +} diff --git a/crates/story/examples/html.rs b/crates/story/examples/html.rs index e38d0990..e6cc589d 100644 --- a/crates/story/examples/html.rs +++ b/crates/story/examples/html.rs @@ -1,24 +1,32 @@ +use std::sync::LazyLock; + use gpui::*; use gpui_component::{ h_flex, + highlighter::{HighlightTheme, Highlighter}, input::{InputState, TextInput}, text::TextView, ActiveTheme as _, }; use story::Assets; +static LIGHT_THEME: LazyLock = LazyLock::new(|| HighlightTheme::default_light()); +static DARK_THEME: LazyLock = LazyLock::new(|| HighlightTheme::default_dark()); + pub struct Example { input_state: Entity, + is_dark: bool, _subscribe: Subscription, } const EXAMPLE: &str = include_str!("./html.html"); +const LANG: &str = "html"; impl Example { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let input_state = cx.new(|cx| { InputState::new(window, cx) - .multi_line() + .code_editor(Some(LANG), &LIGHT_THEME) .default_value(EXAMPLE) .placeholder("Enter your HTML here...") }); @@ -32,6 +40,7 @@ impl Example { Self { input_state, + is_dark: false, _subscribe, } } @@ -42,7 +51,19 @@ impl Example { } impl Render for Example { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_dark = cx.theme().mode.is_dark(); + if self.is_dark != is_dark { + self.is_dark = is_dark; + self.input_state.update(cx, |state, cx| { + if is_dark { + state.set_highlighter(Highlighter::new(Some(LANG), &DARK_THEME), cx); + } else { + state.set_highlighter(Highlighter::new(Some(LANG), &LIGHT_THEME), cx); + } + }); + } + h_flex() .h_full() .child( @@ -52,6 +73,8 @@ impl Render for Example { .w_1_2() .border_r_1() .border_color(cx.theme().border) + .font_family("Menlo") + .text_size(px(13.)) .child(TextInput::new(&self.input_state).h_full().appearance(false)), ) .child( diff --git a/crates/story/examples/markdown.rs b/crates/story/examples/markdown.rs index 40dae0b0..f2ee3f9e 100644 --- a/crates/story/examples/markdown.rs +++ b/crates/story/examples/markdown.rs @@ -1,16 +1,21 @@ -use std::rc::Rc; +use std::{rc::Rc, sync::LazyLock}; use gpui::*; use gpui_component::{ - highlighter::HighlightTheme, - input::{InputState, TextInput}, + highlighter::{HighlightTheme, Highlighter}, + input::{InputEvent, InputState, TextInput}, text::{TextView, TextViewStyle}, ActiveTheme as _, }; use story::Assets; +static LIGHT_THEME: LazyLock = LazyLock::new(|| HighlightTheme::default_light()); +static DARK_THEME: LazyLock = LazyLock::new(|| HighlightTheme::default_dark()); +const LANG: &str = "markdown"; + pub struct Example { input_state: Entity, + is_dark: bool, } const EXAMPLE: &str = include_str!("./markdown.md"); @@ -19,19 +24,19 @@ impl Example { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let input_state = cx.new(|cx| { InputState::new(window, cx) - .multi_line() + .code_editor(Some(LANG), &LIGHT_THEME) .placeholder("Enter your Markdown here...") .default_value(EXAMPLE) }); - let _subscribe = cx.subscribe( - &input_state, - |_, _, _: &gpui_component::input::InputEvent, cx| { - cx.notify(); - }, - ); + let _subscribe = cx.subscribe(&input_state, |_, _, _: &InputEvent, cx| { + cx.notify(); + }); - Self { input_state } + Self { + input_state, + is_dark: false, + } } fn view(window: &mut Window, cx: &mut App) -> Entity { @@ -40,13 +45,25 @@ impl Example { } impl Render for Example { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = if cx.theme().mode.is_dark() { HighlightTheme::default_dark() } else { HighlightTheme::default_light() }; + let is_dark = cx.theme().mode.is_dark(); + if self.is_dark != is_dark { + self.is_dark = is_dark; + self.input_state.update(cx, |state, cx| { + if is_dark { + state.set_highlighter(Highlighter::new(Some(LANG), &DARK_THEME), cx); + } else { + state.set_highlighter(Highlighter::new(Some(LANG), &LIGHT_THEME), cx); + } + }); + } + div() .flex() .flex_row() diff --git a/crates/ui/src/highlighter/highlight.rs b/crates/ui/src/highlighter/highlight.rs index fb294586..f8def525 100644 --- a/crates/ui/src/highlighter/highlight.rs +++ b/crates/ui/src/highlighter/highlight.rs @@ -6,7 +6,7 @@ use std::{ use syntect::{highlighting, parsing}; static SYNTAXES: LazyLock = - LazyLock::new(parsing::SyntaxSet::load_defaults_nonewlines); + LazyLock::new(parsing::SyntaxSet::load_defaults_newlines); static DEFAULT_LIGHT: LazyLock> = LazyLock::new(|| { let mut cursor = std::io::Cursor::new(include_bytes!("./themes/light.tmTheme")); diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 45de14dd..0119c5a9 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -1,16 +1,20 @@ +use std::ops::Range; + use gpui::{ - fill, point, px, relative, size, App, Bounds, Corners, Element, ElementId, ElementInputHandler, - Entity, GlobalElementId, IntoElement, LayoutId, MouseButton, MouseMoveEvent, PaintQuad, Path, - Pixels, Point, SharedString, Style, TextAlign, TextRun, UnderlineStyle, Window, WrappedLine, + fill, hash, point, px, relative, size, App, Bounds, Corners, Element, ElementId, + ElementInputHandler, Entity, GlobalElementId, HighlightStyle, IntoElement, LayoutId, + MouseButton, MouseMoveEvent, PaintQuad, Path, Pixels, Point, SharedString, Style, TextAlign, + TextRun, UnderlineStyle, Window, WrappedLine, }; use smallvec::SmallVec; use crate::{ActiveTheme as _, Root}; -use super::InputState; +use super::{mode::InputMode, InputState}; const RIGHT_MARGIN: Pixels = px(5.); const BOTTOM_MARGIN_ROWS: usize = 1; +const LINE_NUMBER_MARGIN_RIGHT: Pixels = px(10.); pub(super) struct TextElement { input: Entity, @@ -50,6 +54,7 @@ impl TextElement { lines: &[WrappedLine], line_height: Pixels, bounds: &mut Bounds, + line_number_width: Pixels, window: &mut Window, cx: &mut App, ) -> (Option, Point) { @@ -162,7 +167,7 @@ impl TextElement { cursor = Some(fill( Bounds::new( point( - bounds.left() + cursor_pos.x, + bounds.left() + cursor_pos.x + line_number_width, bounds.top() + cursor_pos.y + ((line_height - cursor_height) / 2.), ), size(px(1.), cursor_height), @@ -180,6 +185,7 @@ impl TextElement { lines: &[WrappedLine], line_height: Pixels, bounds: &mut Bounds, + line_number_width: Pixels, _: &mut Window, cx: &mut App, ) -> Option> { @@ -293,19 +299,53 @@ impl TextElement { // print_points_as_svg_path(&line_corners, &points); + let path_origin = bounds.origin + point(line_number_width, px(0.)); let first_p = *points.get(0).unwrap(); let mut builder = gpui::PathBuilder::fill(); - builder.move_to(bounds.origin + first_p); + builder.move_to(path_origin + first_p); for p in points.iter().skip(1) { - builder.line_to(bounds.origin + *p); + builder.line_to(path_origin + *p); } builder.build().ok() } + + fn highlight_text(&self, cx: &mut App) -> Option, HighlightStyle)>> { + let input = self.input.read(cx); + let text = input.text.as_ref(); + + let cache_key = hash(&text); + + match &input.mode { + InputMode::CodeEditor { + highlighter, cache, .. + } => { + if cache.0 == cache_key { + return Some(cache.1.clone()); + } + + if let Some(highlighter) = highlighter { + let styles = highlighter.highlight(&text); + self.input.update(cx, |input, _cx| { + input + .mode + .set_code_editor_cache((cache_key, styles.clone())); + }); + + Some(styles) + } else { + None + } + } + _ => None, + } + } } pub(super) struct PrepaintState { lines: SmallVec<[WrappedLine; 1]>, + line_numbers: Option>, + line_number_width: Pixels, cursor: Option, cursor_scroll_offset: Point, selection_path: Option>, @@ -392,12 +432,14 @@ impl Element for TextElement { window: &mut Window, cx: &mut App, ) -> Self::PrepaintState { + let highlights = self.highlight_text(cx); let multi_line = self.input.read(cx).is_multi_line(); let line_height = window.line_height(); let input = self.input.read(cx); let text = input.text.clone(); let placeholder = self.placeholder.clone(); let style = window.text_style(); + let font_size = style.font_size.to_pixels(window.rem_size()); let mut bounds = bounds; let (display_text, text_color) = if text.is_empty() { @@ -411,6 +453,52 @@ impl Element for TextElement { (text, cx.theme().foreground) }; + let text_style = window.text_style(); + + // Calculate the width of the line numbers + let mut line_number_width = px(0.); + let line_numbers = if input.mode.line_number() { + let mut line_numbers = SmallVec::new(); + let total_lines = input.text_wrapper.lines.len(); + let run_len = if total_lines > 999 { 4 } else { 3 }; + let runs = vec![TextRun { + len: run_len, + font: style.font(), + color: cx.theme().muted_foreground, + background_color: None, + underline: None, + strikethrough: None, + }]; + + for (i, line_wrap) in input.text_wrapper.lines.iter().enumerate() { + let line_no = if run_len == 4 { + format!("{:>4}", i + 1).into() + } else { + format!("{:>3}", i + 1).into() + }; + + let line = window + .text_system() + .shape_text(line_no, font_size, &runs, None, None) + .unwrap(); + line_number_width = (line.last().unwrap().width() + LINE_NUMBER_MARGIN_RIGHT) + .max(line_number_width); + line_numbers.extend(line); + + for _ in 0..line_wrap.wrap_lines { + // Empty line no for wrapped lines + let line = window + .text_system() + .shape_text(" ".into(), font_size, &runs, None, None) + .unwrap(); + line_numbers.extend(line); + } + } + Some(line_numbers) + } else { + None + }; + let run = TextRun { len: display_text.len(), font: style.font(), @@ -444,12 +532,25 @@ impl Element for TextElement { .filter(|run| run.len > 0) .collect() } else { - vec![run] + if let Some(highlights) = highlights { + let mut runs = vec![]; + for (range, style) in highlights { + let run = text_style + .clone() + .highlight(style) + .to_run(range.end - range.start); + if run.len > 0 { + runs.push(run); + } + } + runs + } else { + vec![run] + } }; - let font_size = style.font_size.to_pixels(window.rem_size()); let wrap_width = if multi_line { - Some(bounds.size.width - RIGHT_MARGIN) + Some(bounds.size.width - line_number_width - RIGHT_MARGIN) } else { None }; @@ -490,14 +591,29 @@ impl Element for TextElement { // Calculate the scroll offset to keep the cursor in view - let (cursor, cursor_scroll_offset) = - self.layout_cursor(&lines, line_height, &mut bounds, window, cx); + let (cursor, cursor_scroll_offset) = self.layout_cursor( + &lines, + line_height, + &mut bounds, + line_number_width, + window, + cx, + ); - let selection_path = self.layout_selections(&lines, line_height, &mut bounds, window, cx); + let selection_path = self.layout_selections( + &lines, + line_height, + &mut bounds, + line_number_width, + window, + cx, + ); PrepaintState { bounds, lines, + line_numbers, + line_number_width, cursor, cursor_scroll_offset, selection_path, @@ -566,8 +682,19 @@ impl Element for TextElement { offset_y = px(2.5); } } + + if let Some(line_numbers) = prepaint.line_numbers.as_ref() { + for line in line_numbers.iter() { + let p = point(origin.x, origin.y + offset_y); + _ = line.paint(p, line_height, TextAlign::Left, None, window, cx); + let line_size = line.size(line_height); + offset_y += line_size.height; + } + } + + let mut offset_y = px(0.); for line in prepaint.lines.iter() { - let p = point(origin.x, origin.y + offset_y); + let p = point(origin.x + prepaint.line_number_width, origin.y + offset_y); _ = line.paint(p, line_height, TextAlign::Left, None, window, cx); offset_y += line.size(line_height).height; } @@ -595,6 +722,7 @@ impl Element for TextElement { input.set_input_bounds(input_bounds, cx); input.last_selected_range = Some(selected_range); input.scroll_size = scroll_size; + input.line_number_width = prepaint.line_number_width; input .scroll_handle .set_offset(prepaint.cursor_scroll_offset); diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index e46a0173..01ed6ee6 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -3,6 +3,7 @@ mod change; mod clear_button; mod element; mod mask_pattern; +mod mode; mod number_input; mod otp_input; mod state; @@ -11,6 +12,7 @@ mod text_wrapper; pub(crate) use clear_button::*; pub use mask_pattern::MaskPattern; +pub use mode::TabSize; pub use number_input::{NumberInput, NumberInputEvent, StepAction}; pub use otp_input::*; pub use state::*; diff --git a/crates/ui/src/input/mode.rs b/crates/ui/src/input/mode.rs new file mode 100644 index 00000000..6c99235d --- /dev/null +++ b/crates/ui/src/input/mode.rs @@ -0,0 +1,194 @@ +use gpui::{DefiniteLength, HighlightStyle, SharedString}; + +use std::ops::Range; +use std::rc::Rc; + +use super::text_wrapper::TextWrapper; +use crate::highlighter::Highlighter; + +#[derive(Debug, Copy, Clone)] +pub struct TabSize { + /// Default is 2 + pub tab_size: usize, + /// Set true to use `\t` as tab indent, default is false + pub hard_tabs: bool, +} + +impl Default for TabSize { + fn default() -> Self { + Self { + tab_size: 2, + hard_tabs: false, + } + } +} + +impl TabSize { + pub(super) fn to_string(&self) -> SharedString { + if self.hard_tabs { + "\t".into() + } else { + " ".repeat(self.tab_size).into() + } + } +} + +#[derive(Default, Clone)] +pub enum InputMode { + #[default] + SingleLine, + MultiLine { + tab: TabSize, + rows: usize, + height: Option, + }, + CodeEditor { + tab: TabSize, + rows: usize, + height: Option, + /// Show line number + line_number: bool, + highlighter: Option>>, + cache: (u64, Vec<(Range, HighlightStyle)>), + }, + AutoGrow { + rows: usize, + min_rows: usize, + max_rows: usize, + }, +} + +impl InputMode { + pub(super) fn set_rows(&mut self, new_rows: usize) { + match self { + InputMode::MultiLine { rows, .. } => { + *rows = new_rows; + } + InputMode::CodeEditor { rows, .. } => { + *rows = new_rows; + } + InputMode::AutoGrow { + rows, + min_rows, + max_rows, + } => { + *rows = new_rows.clamp(*min_rows, *max_rows); + } + _ => {} + } + } + + pub(super) fn set_height(&mut self, new_height: Option) { + match self { + InputMode::MultiLine { height, .. } => { + *height = new_height; + } + InputMode::CodeEditor { height, .. } => { + *height = new_height; + } + _ => {} + } + } + + pub(super) fn update_auto_grow(&mut self, text_wrapper: &TextWrapper) { + let wrapped_lines = text_wrapper.wrapped_lines.len(); + self.set_rows(wrapped_lines); + } + + /// At least 1 row be return. + pub(super) fn rows(&self) -> usize { + match self { + InputMode::MultiLine { rows, .. } => *rows, + InputMode::CodeEditor { rows, .. } => *rows, + InputMode::AutoGrow { rows, .. } => *rows, + _ => 1, + } + .max(1) + } + + /// At least 1 row be return. + #[allow(unused)] + pub(super) fn min_rows(&self) -> usize { + match self { + InputMode::MultiLine { .. } | InputMode::CodeEditor { .. } => 1, + InputMode::AutoGrow { min_rows, .. } => *min_rows, + _ => 1, + } + .max(1) + } + + #[allow(unused)] + pub(super) fn max_rows(&self) -> usize { + match self { + InputMode::MultiLine { .. } | InputMode::CodeEditor { .. } => usize::MAX, + InputMode::AutoGrow { max_rows, .. } => *max_rows, + _ => 1, + } + } + + pub(super) fn height(&self) -> Option { + match self { + InputMode::MultiLine { height, .. } => *height, + InputMode::CodeEditor { height, .. } => *height, + _ => None, + } + } + + pub(super) fn set_code_editor_cache( + &mut self, + cache: (u64, Vec<(Range, HighlightStyle)>), + ) { + if let InputMode::CodeEditor { cache: c, .. } = self { + *c = cache; + } + } + + /// Return false if the mode is not [`InputMode::CodeEditor`]. + #[allow(unused)] + #[inline] + pub(super) fn line_number(&self) -> bool { + match self { + InputMode::CodeEditor { line_number, .. } => *line_number, + _ => false, + } + } + + #[inline] + pub(super) fn tab_size(&self) -> Option<&TabSize> { + match self { + InputMode::MultiLine { tab, .. } => Some(tab), + InputMode::CodeEditor { tab, .. } => Some(tab), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::TabSize; + + #[test] + fn test_tab_size() { + let tab = TabSize { + tab_size: 2, + hard_tabs: false, + }; + assert_eq!(tab.to_string(), " "); + let tab = TabSize { + tab_size: 4, + hard_tabs: false, + }; + assert_eq!(tab.to_string(), " "); + + let tab = TabSize { + tab_size: 2, + hard_tabs: true, + }; + assert_eq!(tab.to_string(), "\t"); + let tab = TabSize { + tab_size: 4, + hard_tabs: true, + }; + assert_eq!(tab.to_string(), "\t"); + } +} diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index d1d2a8f9..40992a24 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -11,8 +11,8 @@ use std::rc::Rc; use unicode_segmentation::*; use gpui::{ - actions, div, impl_internal_actions, point, prelude::FluentBuilder as _, px, App, AppContext, - Bounds, ClipboardItem, Context, DefiniteLength, Entity, EntityInputHandler, EventEmitter, + actions, div, impl_internal_actions, point, prelude::FluentBuilder as _, px, relative, App, + AppContext, Bounds, ClipboardItem, Context, Entity, EntityInputHandler, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, ScrollHandle, ScrollWheelEvent, SharedString, Styled as _, Subscription, @@ -23,10 +23,20 @@ use gpui::{ // - Move cursor to skip line eof empty chars. use super::{ - blink_cursor::BlinkCursor, change::Change, element::TextElement, mask_pattern::MaskPattern, - number_input, text_wrapper::TextWrapper, + blink_cursor::BlinkCursor, + change::Change, + element::TextElement, + mask_pattern::MaskPattern, + mode::{InputMode, TabSize}, + number_input, + text_wrapper::TextWrapper, +}; +use crate::{ + highlighter::{HighlightTheme, Highlighter}, + history::History, + scroll::ScrollbarState, + Root, }; -use crate::{history::History, scroll::ScrollbarState, Root}; #[derive(Clone, PartialEq, Eq, Deserialize)] pub struct Enter { @@ -45,6 +55,8 @@ actions!( DeleteToEndOfLine, DeleteToPreviousWordStart, DeleteToNextWordEnd, + Indent, + Outdent, Up, Down, Left, @@ -112,6 +124,8 @@ pub fn init(cx: &mut App) { KeyBinding::new("down", Down, Some(CONTEXT)), KeyBinding::new("left", Left, Some(CONTEXT)), KeyBinding::new("right", Right, Some(CONTEXT)), + KeyBinding::new("tab", Indent, Some(CONTEXT)), + KeyBinding::new("shift-tab", Outdent, Some(CONTEXT)), KeyBinding::new("shift-left", SelectLeft, Some(CONTEXT)), KeyBinding::new("shift-right", SelectRight, Some(CONTEXT)), KeyBinding::new("shift-up", SelectUp, Some(CONTEXT)), @@ -191,95 +205,6 @@ pub fn init(cx: &mut App) { number_input::init(cx); } -#[derive(Debug, Default, Clone)] -pub enum InputMode { - #[default] - SingleLine, - MultiLine { - rows: usize, - height: Option, - }, - AutoGrow { - rows: usize, - min_rows: usize, - max_rows: usize, - }, -} - -impl InputMode { - pub(super) fn set_rows(&mut self, new_rows: usize) { - match self { - InputMode::MultiLine { rows, .. } => { - *rows = new_rows; - } - InputMode::AutoGrow { - rows, - min_rows, - max_rows, - } => { - *rows = new_rows.clamp(*min_rows, *max_rows); - } - _ => {} - } - } - - pub(super) fn update_auto_grow(&mut self, text_wrapper: &TextWrapper) { - match self { - Self::AutoGrow { .. } => { - let wrapped_lines = text_wrapper.wrapped_lines.len(); - self.set_rows(wrapped_lines); - } - _ => {} - } - } - - /// At least 1 row be return. - pub(super) fn rows(&self) -> usize { - match self { - InputMode::MultiLine { rows, .. } => *rows, - InputMode::AutoGrow { rows, .. } => *rows, - _ => 1, - } - .max(1) - } - - /// At least 1 row be return. - #[allow(unused)] - pub(super) fn min_rows(&self) -> usize { - match self { - InputMode::MultiLine { .. } => 1, - InputMode::AutoGrow { min_rows, .. } => *min_rows, - _ => 1, - } - .max(1) - } - - #[allow(unused)] - pub(super) fn max_rows(&self) -> usize { - match self { - InputMode::MultiLine { .. } => usize::MAX, - InputMode::AutoGrow { max_rows, .. } => *max_rows, - _ => 1, - } - } - - pub(super) fn set_height(&mut self, new_height: Option) { - match self { - InputMode::MultiLine { height, .. } => { - *height = new_height; - } - _ => {} - } - } - - pub(super) fn height(&self) -> Option { - match self { - InputMode::MultiLine { height, .. } => *height, - _ => None, - } - } -} - /// InputState to keep editing state of the [`super::TextInput`]. pub struct InputState { pub(super) focus_handle: FocusHandle, @@ -317,6 +242,7 @@ pub struct InputState { pub(super) scrollbar_state: Rc>, /// The size of the scrollable content. pub(crate) scroll_size: gpui::Size, + pub(crate) line_number_width: Pixels, /// The mask pattern for formatting the input text pub(crate) mask_pattern: MaskPattern, @@ -390,6 +316,7 @@ impl InputState { scrollbar_state: Rc::new(Cell::new(ScrollbarState::default())), scroll_size: gpui::size(px(0.), px(0.)), preferred_x_offset: None, + line_number_width: px(0.), placeholder: SharedString::default(), mask_pattern: MaskPattern::default(), _subscriptions, @@ -403,6 +330,7 @@ impl InputState { self.mode = InputMode::MultiLine { rows: 2, height: None, + tab: TabSize::default(), }; self } @@ -417,12 +345,105 @@ impl InputState { self } + /// Set Input to use [`InputMode::CodeEditor`] mode. + /// + /// Default options: + /// + /// - line_number: true + /// - tab_size: 2 + /// - hard_tabs: false + /// - height: full + /// + /// Code Editor aim for help used to simple code editing or display, not a full-featured code editor. + /// + /// ## Features + /// + /// - Syntax Highlighting + /// - Auto Indent + /// - Line Number + pub fn code_editor(mut self, language: Option<&str>, theme: &'static HighlightTheme) -> Self { + let highlighter = Highlighter::new(language, theme); + self.mode = InputMode::CodeEditor { + rows: 2, + tab: TabSize::default(), + highlighter: Some(Rc::new(highlighter)), + cache: (0, vec![]), + line_number: true, + height: Some(relative(1.)), + }; + self + } + /// Set placeholder pub fn placeholder(mut self, placeholder: impl Into) -> Self { self.placeholder = placeholder.into(); self } + /// Set enable/disable line number, only for [`InputMode::CodeEditor`] mode. + pub fn line_number(mut self, line_number: bool) -> Self { + if let InputMode::CodeEditor { line_number: l, .. } = &mut self.mode { + *l = line_number; + } + self + } + + /// Set line number, only for [`InputMode::CodeEditor`] mode. + pub fn set_line_number(&mut self, line_number: bool, _: &mut Window, cx: &mut Context) { + if let InputMode::CodeEditor { line_number: l, .. } = &mut self.mode { + *l = line_number; + } + cx.notify(); + } + + /// Set the tab size for the input. + /// + /// Only for [`InputMode::MultiLine`] and [`InputMode::CodeEditor`] mode. + pub fn tab_size(mut self, tab: TabSize) -> Self { + match &mut self.mode { + InputMode::MultiLine { tab: t, .. } => *t = tab, + InputMode::CodeEditor { tab: t, .. } => *t = tab, + _ => {} + } + self + } + + /// Set the number of rows for the multi-line Textarea. + /// + /// This is only used when `multi_line` is set to true. + /// + /// default: 2 + pub fn rows(mut self, rows: usize) -> Self { + match &mut self.mode { + InputMode::MultiLine { rows: r, .. } => *r = rows, + InputMode::AutoGrow { + max_rows: max_r, + rows: r, + .. + } => { + *r = rows; + *max_r = rows; + } + _ => {} + } + self + } + + /// Set highlighter, only for [`InputMode::CodeEditor`] mode. + pub fn set_highlighter(&mut self, highlighter: Highlighter<'static>, cx: &mut Context) { + let new_highlighter = Rc::new(highlighter); + match &mut self.mode { + InputMode::CodeEditor { + highlighter, cache, .. + } => { + *highlighter = Some(new_highlighter); + *cache = (0, vec![]); + } + _ => {} + } + cx.notify(); + } + /// Set placeholder pub fn set_placeholder( &mut self, @@ -565,7 +586,7 @@ impl InputState { pub(super) fn is_multi_line(&self) -> bool { matches!( self.mode, - InputMode::MultiLine { .. } | InputMode::AutoGrow { .. } + InputMode::MultiLine { .. } | InputMode::AutoGrow { .. } | InputMode::CodeEditor { .. } ) } @@ -579,28 +600,6 @@ impl InputState { matches!(self.mode, InputMode::AutoGrow { .. }) } - /// Set the number of rows for the multi-line Textarea. - /// - /// This is only used when `multi_line` is set to true. - /// - /// default: 2 - pub fn rows(mut self, rows: usize) -> Self { - match self.mode { - InputMode::MultiLine { height, .. } => { - self.mode = InputMode::MultiLine { rows, height }; - } - InputMode::AutoGrow { max_rows, .. } => { - self.mode = InputMode::AutoGrow { - rows, - min_rows: rows, - max_rows, - }; - } - _ => {} - } - self - } - /// Set the text of the input field. /// /// And the selection_range will be reset to 0..0. @@ -714,6 +713,7 @@ impl InputState { /// Set the default value of the input field. pub fn default_value(mut self, value: impl Into) -> Self { self.text = value.into(); + self.text_wrapper.text = self.text.clone(); self } @@ -758,6 +758,10 @@ impl InputState { if self.is_single_line() { return; } + + if !self.selected_range.is_empty() { + self.move_to(self.selected_range.start.saturating_sub(1), window, cx); + } self.pause_blink_cursor(cx); self.move_vertical(-1, window, cx); } @@ -766,6 +770,11 @@ impl InputState { if self.is_single_line() { return; } + + if !self.selected_range.is_empty() { + self.move_to(self.selected_range.end.saturating_sub(1), window, cx); + } + self.pause_blink_cursor(cx); self.move_vertical(1, window, cx); } @@ -966,6 +975,24 @@ impl InputState { line } + /// Get start line of selection start or end (The min value). + /// + /// This is means is always get the first line of selection. + fn start_of_line_of_selection(&mut self, window: &mut Window, cx: &mut Context) -> usize { + if self.is_single_line() { + return 0; + } + + let offset = self.previous_boundary(self.selected_range.start.min(self.selected_range.end)); + let line = self + .text_for_range(self.range_to_utf16(&(0..offset + 1)), &mut None, window, cx) + .unwrap_or_default() + .rfind('\n') + .map(|i| i + 1) + .unwrap_or(0); + line + } + /// Get end of line fn end_of_line(&mut self, window: &mut Window, cx: &mut Context) -> usize { if self.is_single_line() { @@ -1001,6 +1028,49 @@ impl InputState { line } + /// Get indent string of next line. + /// + /// To get current and next line indent, to return more depth one. + pub(super) fn indent_of_next_line( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> String { + if self.is_single_line() { + return "".into(); + } + + let mut current_indent = String::new(); + let mut next_indent = String::new(); + let current_line_start_pos = self.start_of_line(window, cx); + let next_line_start_pos = self.end_of_line(window, cx); + for c in self.text.chars().skip(current_line_start_pos) { + if !c.is_whitespace() { + break; + } + if c == '\n' || c == '\r' { + break; + } + current_indent.push(c); + } + + for c in self.text.chars().skip(next_line_start_pos) { + if !c.is_whitespace() { + break; + } + if c == '\n' || c == '\r' { + break; + } + next_indent.push(c); + } + + if next_indent.len() > current_indent.len() { + return next_indent; + } else { + return current_indent; + } + } + 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) @@ -1091,6 +1161,9 @@ impl InputState { pub(super) fn enter(&mut self, action: &Enter, window: &mut Window, cx: &mut Context) { if self.is_multi_line() { let is_eof = self.selected_range.end == self.text.len(); + + // Get current line indent + let indent = self.indent_of_next_line(window, cx); self.replace_text_in_range(None, "\n", window, cx); // Move cursor to the start of the next line @@ -1099,6 +1172,14 @@ impl InputState { new_offset += 1; } self.move_to(new_offset, window, cx); + + // Add indent + self.replace_text_in_range( + Some(self.range_to_utf16(&(self.cursor_offset()..self.cursor_offset()))), + &indent, + window, + cx, + ); } cx.emit(InputEvent::PressEnter { @@ -1106,6 +1187,125 @@ impl InputState { }); } + pub(super) fn indent(&mut self, _: &Indent, window: &mut Window, cx: &mut Context) { + let Some(tab_size) = self.mode.tab_size() else { + return; + }; + + let tab_indent = tab_size.to_string(); + let selected_range = self.selected_range.clone(); + let mut added_len = 0; + + if !self.selected_range.is_empty() { + let mut offset = self.start_of_line_of_selection(window, cx); + + let selected_text = self + .text_for_range( + self.range_to_utf16(&(offset..selected_range.end)), + &mut None, + window, + cx, + ) + .unwrap_or("".into()); + + let mut lines_count = 0; + for line in selected_text.lines() { + lines_count += 1; + self.replace_text_in_range( + Some(self.range_to_utf16(&(offset..offset))), + &tab_indent, + window, + cx, + ); + added_len += tab_indent.len(); + // +1 for "\n" + offset += line.len() + tab_indent.len() + 1; + } + + if lines_count > 1 { + self.selected_range = + selected_range.start + tab_indent.len()..selected_range.end + added_len; + } else { + self.selected_range = + selected_range.start + added_len..selected_range.end + added_len; + } + } else { + // Selected none + let offset = self.selected_range.start; + self.replace_text_in_range( + Some(self.range_to_utf16(&(offset..offset))), + &tab_indent, + window, + cx, + ); + added_len = tab_indent.len(); + + self.selected_range = selected_range.start + added_len..selected_range.end + added_len; + } + } + + pub(super) fn outdent(&mut self, _: &Outdent, window: &mut Window, cx: &mut Context) { + let Some(tab_size) = self.mode.tab_size() else { + return; + }; + + let tab_indent = tab_size.to_string(); + let selected_range = self.selected_range.clone(); + let mut removed_len = 0; + + if !self.selected_range.is_empty() { + let mut offset = self.start_of_line_of_selection(window, cx); + + let selected_text = self + .text_for_range( + self.range_to_utf16(&(offset..selected_range.end)), + &mut None, + window, + cx, + ) + .unwrap_or("".into()); + + let mut lines_count = 0; + for line in selected_text.lines() { + lines_count += 1; + if line.starts_with(tab_indent.as_ref()) { + self.replace_text_in_range( + Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))), + "", + window, + cx, + ); + removed_len += tab_indent.len(); + } + // +1 for "\n" + offset += line.len().saturating_sub(tab_indent.len()) + 1; + } + + if lines_count > 1 { + self.selected_range = selected_range.start.saturating_sub(tab_indent.len()) + ..selected_range.end.saturating_sub(removed_len); + } else { + self.selected_range = selected_range.start.saturating_sub(tab_indent.len()) + ..selected_range.end.saturating_sub(tab_indent.len()); + } + } else { + // Selected none + let offset = self.start_of_line_of_selection(window, cx); + if self.text[offset..].starts_with(tab_indent.as_ref()) { + self.replace_text_in_range( + Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))), + "", + window, + cx, + ); + removed_len = tab_indent.len(); + + self.selected_range = selected_range.start.saturating_sub(removed_len) + ..selected_range.end.saturating_sub(removed_len); + } + } + } + pub(super) fn clean(&mut self, window: &mut Window, cx: &mut Context) { self.replace_text("", window, cx); } @@ -1312,7 +1512,7 @@ impl InputState { // // - included the input padding. // - included the scroll offset. - let inner_position = position - bounds.origin; + let inner_position = position - bounds.origin - point(self.line_number_width, px(0.)); let mut index = 0; let mut y_offset = px(0.); @@ -1418,40 +1618,66 @@ impl InputState { /// Select the word at the given offset. /// /// The offset is the UTF-8 offset. + /// + /// FIXME: When click on a non-word character, the word is not selected. fn select_word(&mut self, offset: usize, window: &mut Window, cx: &mut Context) { + #[inline(always)] fn is_word(c: char) -> bool { c.is_alphanumeric() || matches!(c, '_') } - let mut start = self.offset_to_utf16(offset); + let mut start = offset; let mut end = start; let prev_text = self - .text_for_range(0..start, &mut None, window, cx) + .text_for_range(self.range_to_utf16(&(0..start + 1)), &mut None, window, cx) .unwrap_or_default(); let next_text = self - .text_for_range(end..self.text.len(), &mut None, window, cx) + .text_for_range( + self.range_to_utf16(&(end..self.text.len())), + &mut None, + window, + cx, + ) .unwrap_or_default(); - let prev_chars = prev_text.chars().rev().peekable(); - let next_chars = next_text.chars().peekable(); + let prev_chars = prev_text.chars().rev(); + let next_chars = next_text.chars(); + let mut last_char_len = 0; for (_, c) in prev_chars.enumerate() { if !is_word(c) { break; } - start -= c.len_utf16(); + last_char_len = c.len_utf8(); + start = start.saturating_sub(last_char_len); } + start += last_char_len; for (_, c) in next_chars.enumerate() { if !is_word(c) { break; } - end += c.len_utf16(); + end += c.len_utf8(); } - self.selected_range = self.range_from_utf16(&(start..end)); + // Ensure at least one character is selected + if start == end { + end = end + 1; + + // Avoid select empty range + match self.text.get(start..end) { + None => return, + Some(part) => { + if part.trim().len() == 0 { + return; + } + } + } + } + + self.selected_range = start..end; self.selected_word_range = Some(self.selected_range.clone()); cx.notify() } @@ -1711,7 +1937,7 @@ impl EntityInputHandler for InputState { self.push_history(&range, &new_text, window, cx); self.text = mask_text; - self.text_wrapper.update(self.text.clone(), cx); + self.text_wrapper.update(self.text.clone(), false, cx); self.selected_range = new_pos..new_pos; self.marked_range.take(); self.update_preferred_x_offset(cx); @@ -1832,6 +2058,8 @@ impl Focusable for InputState { impl Render for InputState { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + self.text_wrapper.update(self.text.clone(), false, cx); + div() .id("text-element") .flex_1() diff --git a/crates/ui/src/input/text_input.rs b/crates/ui/src/input/text_input.rs index 092f10a5..ad5204e1 100644 --- a/crates/ui/src/input/text_input.rs +++ b/crates/ui/src/input/text_input.rs @@ -134,9 +134,12 @@ impl TextInput { impl RenderOnce for TextInput { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { const LINE_HEIGHT: Rems = Rems(1.25); + let font = window.text_style().font(); + let font_size = window.text_style().font_size.to_pixels(window.rem_size()); - self.state.update(cx, |state, _| { + self.state.update(cx, |state, cx| { state.mode.set_height(self.height); + state.text_wrapper.set_font(font, font_size, cx); state.disabled = self.disabled; }); @@ -187,6 +190,8 @@ impl RenderOnce for TextInput { .on_action(window.listener_for(&self.state, InputState::down)) .on_action(window.listener_for(&self.state, InputState::select_up)) .on_action(window.listener_for(&self.state, InputState::select_down)) + .on_action(window.listener_for(&self.state, InputState::indent)) + .on_action(window.listener_for(&self.state, InputState::outdent)) }) .on_action(window.listener_for(&self.state, InputState::select_all)) .on_action(window.listener_for(&self.state, InputState::select_to_start_of_line)) @@ -222,6 +227,7 @@ impl RenderOnce for TextInput { .input_py(self.size) .input_h(self.size) .cursor_text() + .text_size(font_size) .when(state.is_multi_line(), |this| { this.h_auto() .when_some(self.height, |this, height| this.h(height)) @@ -239,7 +245,6 @@ impl RenderOnce for TextInput { .items_center() .gap(gap_x) .children(prefix) - // TODO: Define height here, and use it in the input element .child(self.state.clone()) .child( h_flex() diff --git a/crates/ui/src/input/text_wrapper.rs b/crates/ui/src/input/text_wrapper.rs index 660d29ce..048e42f8 100644 --- a/crates/ui/src/input/text_wrapper.rs +++ b/crates/ui/src/input/text_wrapper.rs @@ -2,6 +2,14 @@ use std::ops::Range; use gpui::{App, Font, LineFragment, Pixels, SharedString}; +#[allow(unused)] +pub(super) struct LineWrap { + /// The number of soft wrapped lines of this line (Not include first line.) + pub(super) wrap_lines: usize, + /// The range of the line text in the entire text. + pub(super) range: Range, +} + /// Used to prepare the text with soft_wrap to be get lines to displayed in the TextArea /// /// After use lines to calculate the scroll size of the TextArea @@ -9,6 +17,8 @@ pub(super) struct TextWrapper { pub(super) text: SharedString, /// The wrapped lines, value is start and end index of the line (by split \n). pub(super) wrapped_lines: Vec>, + /// The lines by split \n + pub(super) lines: Vec, pub(super) font: Font, pub(super) font_size: Pixels, /// If is none, it means the text is not wrapped @@ -24,49 +34,72 @@ impl TextWrapper { font_size, wrap_width, wrapped_lines: Vec::new(), + lines: Vec::new(), } } pub(super) fn set_wrap_width(&mut self, wrap_width: Option, cx: &mut App) { - if self.wrap_width == wrap_width { + self.wrap_width = wrap_width; + self.update(self.text.clone(), true, cx); + } + + pub(super) fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) { + self.font = font; + self.font_size = font_size; + self.update(self.text.clone(), true, cx); + } + + /// Update the text wrapper and recalculate the wrapped lines. + /// + /// If the `text` is the same as the current text, do nothing. + pub(super) fn update(&mut self, text: SharedString, force: bool, cx: &mut App) { + if self.text == text && !force { return; } - self.wrap_width = wrap_width; - self.update(self.text.clone(), cx); - } - - pub(super) fn set_font(&mut self, font: Font, cx: &mut App) { - self.font = font; - self.update(self.text.clone(), cx); - } - - pub(super) fn update(&mut self, text: SharedString, cx: &mut App) { let mut wrapped_lines = vec![]; + let mut lines = vec![]; let wrap_width = self.wrap_width.unwrap_or(Pixels::MAX); let mut line_wrapper = cx .text_system() .line_wrapper(self.font.clone(), self.font_size); + let mut prev_line_ix = 0; for line in text.lines() { + let mut line_wraps = vec![]; let mut prev_boundary_ix = 0; + + // Here only have wrapped line, if there is no wrap meet, the `line_wraps` result will empty. for boundary in line_wrapper.wrap_line(&[LineFragment::text(line)], wrap_width) { - wrapped_lines.push(prev_boundary_ix..boundary.ix); + line_wraps.push(prev_boundary_ix..boundary.ix); prev_boundary_ix = boundary.ix; } + lines.push(LineWrap { + wrap_lines: line_wraps.len(), + range: prev_line_ix..line.len(), + }); + + wrapped_lines.extend(line_wraps); // Reset of the line if !line[prev_boundary_ix..].is_empty() || prev_boundary_ix == 0 { - wrapped_lines.push(prev_boundary_ix..line.len()); + wrapped_lines.push(prev_line_ix + prev_boundary_ix..prev_line_ix + line.len()); } + + prev_line_ix += line.len(); } // Add last empty line. if text.chars().last().unwrap_or('\n') == '\n' { wrapped_lines.push(text.len()..text.len()); + lines.push(LineWrap { + wrap_lines: 0, + range: text.len()..text.len(), + }); } self.text = text; self.wrapped_lines = wrapped_lines; + self.lines = lines; } }