From 271116926c1a21deeb325d51e0cf52ed0653ff0f Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 23 Jul 2025 13:33:09 +0800 Subject: [PATCH] code-editor: Add `go_to_line` method to CodeEditor. (#1081) - Fix to emit `enter` press key in Input single-line mode. --- crates/story/examples/code-editor.rs | 86 ++++++++++++++++++++++------ crates/ui/src/input/state.rs | 30 +++++++++- crates/ui/src/input/text_wrapper.rs | 28 +++++++++ 3 files changed, 126 insertions(+), 18 deletions(-) diff --git a/crates/story/examples/code-editor.rs b/crates/story/examples/code-editor.rs index 50911f2d..32feb79d 100644 --- a/crates/story/examples/code-editor.rs +++ b/crates/story/examples/code-editor.rs @@ -1,11 +1,11 @@ -use gpui::*; +use gpui::{prelude::FluentBuilder, *}; use gpui_component::{ button::{Button, ButtonVariants as _}, dropdown::{Dropdown, DropdownEvent, DropdownState}, h_flex, highlighter::{Language, LanguageConfig, LanguageRegistry}, input::{InputEvent, InputState, Marker, TabSize, TextInput}, - v_flex, ActiveTheme, Selectable, Sizable, + v_flex, ActiveTheme, ContextModal, IconName, Sizable, }; use story::Assets; @@ -24,7 +24,8 @@ fn init(cx: &mut App) { } pub struct Example { - input_state: Entity, + editor: Entity, + go_to_line_state: Entity, language_state: Entity>>, language: Lang, line_number: bool, @@ -90,7 +91,7 @@ const LANGUAGES: [(Lang, &'static str); 10] = [ impl Example { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let default_language = LANGUAGES[0].clone(); - let input_state = cx.new(|cx| { + let editor = cx.new(|cx| { InputState::new(window, cx) .code_editor(default_language.0.name().to_string()) .line_number(true) @@ -101,6 +102,7 @@ impl Example { .default_value(default_language.1) .placeholder("Enter your code here...") }); + let go_to_line_state = cx.new(|cx| InputState::new(window, cx)); let language_state = cx.new(|cx| { DropdownState::new( LANGUAGES.iter().map(|s| s.0.name().into()).collect(), @@ -111,7 +113,7 @@ impl Example { }); let _subscribes = vec![ - cx.subscribe(&input_state, |_, _, _: &InputEvent, cx| { + cx.subscribe(&editor, |_, _, _: &InputEvent, cx| { cx.notify(); }), cx.subscribe( @@ -132,7 +134,8 @@ impl Example { ]; Self { - input_state, + editor, + go_to_line_state, language_state, language: default_language.0, line_number: true, @@ -150,7 +153,7 @@ impl Example { return; } - self.input_state.update(cx, |state, cx| { + self.editor.update(cx, |state, cx| { state.set_markers( vec![ Marker::new("warning", (2, 1), (2, 31), "Import but not used."), @@ -171,13 +174,52 @@ impl Example { let language = self.language.name().to_string(); let code = LANGUAGES.iter().find(|s| s.0.name() == language).unwrap().1; - self.input_state.update(cx, |state, cx| { + self.editor.update(cx, |state, cx| { state.set_value(code, window, cx); state.set_highlighter(language, cx); }); self.need_update = false; } + + fn go_to_line(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + let editor = self.editor.clone(); + let input_state = self.go_to_line_state.clone(); + + window.open_modal(cx, move |modal, window, cx| { + input_state.update(cx, |state, cx| { + state.set_placeholder(format!("{}", editor.read(cx).line_column()), window, cx); + state.focus(window, cx); + }); + + modal + .title("Go to line") + .child(TextInput::new(&input_state)) + .confirm() + .on_ok({ + let editor = editor.clone(); + let input_state = input_state.clone(); + move |_, window, cx| { + let query = input_state.read(cx).value(); + let mut parts = query + .split(':') + .map(|s| s.trim().parse::().ok()) + .collect::>() + .into_iter(); + let Some(line) = parts.next().and_then(|l| l) else { + return false; + }; + let column = parts.next().and_then(|c| c); + + editor.update(cx, |state, cx| { + state.go_to_line(line, column, window, cx); + }); + + true + } + }) + }); + } } impl Render for Example { @@ -190,10 +232,10 @@ impl Render for Example { .id("source") .w_full() .flex_1() - .p_4() .gap_2() .child( - TextInput::new(&self.input_state) + TextInput::new(&self.editor) + .bordered(false) .h_full() .font_family("Monaco") .text_size(px(12.)) @@ -203,6 +245,11 @@ impl Render for Example { h_flex() .justify_between() .text_sm() + .bg(cx.theme().secondary) + .py_1p5() + .px_4() + .border_t_1() + .border_color(cx.theme().border) .text_color(cx.theme().muted_foreground) .child( h_flex() @@ -210,17 +257,17 @@ impl Render for Example { .child( Dropdown::new(&self.language_state) .menu_width(px(160.)) - .small(), + .xsmall(), ) .child( Button::new("line-number") .ghost() + .when(self.line_number, |this| this.icon(IconName::Check)) .label("Line Number") - .small() - .selected(self.line_number) + .xsmall() .on_click(cx.listener(|this, _, window, cx| { this.line_number = !this.line_number; - this.input_state.update(cx, |state, cx| { + this.editor.update(cx, |state, cx| { state.set_line_number(this.line_number, window, cx); }); cx.notify(); @@ -228,9 +275,14 @@ impl Render for Example { ), ) .child({ - let loc = self.input_state.read(cx).line_column(); - let cursor = self.input_state.read(cx).cursor(); - format!("{} ({} c)", loc, cursor.offset()) + let loc = self.editor.read(cx).line_column(); + let cursor = self.editor.read(cx).cursor(); + + Button::new("line-column") + .ghost() + .xsmall() + .label(format!("{} ({} c)", loc, cursor.offset())) + .on_click(cx.listener(Self::go_to_line)) }), ), ) diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index f1726269..39c5df13 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -804,11 +804,36 @@ impl InputState { self.mask_pattern.unmask(&self.text).into() } - /// Return the line and column (1-based) of the cursor. + /// Return the (1-based) line and column of the cursor. pub fn line_column(&self) -> LineColumn { self.text_wrapper.line_column(self.cursor().offset) } + /// Set (1-based) line and column of the cursor. + /// + /// This will move the cursor to the specified line and column, and update the selection range. + /// + /// - The `column` is optional, if it is `None`, it will return the start of the line. + /// - If the `line` is 0, it will return 0. + /// - If the `line` is greater than the number of lines, it will return + /// the length of the text. + /// + /// Ignore, if the line, column is invalid. + pub fn go_to_line( + &mut self, + line: usize, + column: Option, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(offset) = self + .text_wrapper + .offset_for_line_column(line, column.unwrap_or(1)) + { + self.move_to(Cursor::new(offset), window, cx); + } + } + /// Focus the input field. pub fn focus(&self, window: &mut Window, _: &mut Context) { self.focus_handle.focus(window); @@ -1319,6 +1344,9 @@ impl InputState { // Add newline and indent let new_line_text = format!("\n{}", indent); self.replace_text_in_range(None, &new_line_text, window, cx); + } else { + // Single line input, just emit the event (e.g.: In a modal dialog to confirm). + cx.propagate(); } cx.emit(InputEvent::PressEnter { diff --git a/crates/ui/src/input/text_wrapper.rs b/crates/ui/src/input/text_wrapper.rs index 405515e0..e0d8f5c3 100644 --- a/crates/ui/src/input/text_wrapper.rs +++ b/crates/ui/src/input/text_wrapper.rs @@ -115,4 +115,32 @@ impl TextWrapper { (line + 1, column + 1).into() } + + /// Returns the offset of the given line and column (1-based). + /// + /// - If the `line` is 0, it will return 0. + /// - If the `line` is greater than the number of lines, it will return + /// the length of the text. + pub(super) fn offset_for_line_column(&self, line: usize, column: usize) -> Option { + if line == 0 || self.lines.is_empty() { + return None; + } + + let line = line.saturating_sub(1); + if line >= self.lines.len() { + return Some(self.text.len()); + } + + let Some(line_wrap) = &self.lines.get(line) else { + return None; + }; + + let offset = line_wrap.range.start; + if column == 0 { + return Some(offset); + } + let offset = offset + column.saturating_sub(1).min(line_wrap.range.len()); + + Some(offset) + } }