diff --git a/Cargo.lock b/Cargo.lock index 4213401a..ed9deeed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -709,7 +709,7 @@ dependencies = [ "bitflags 2.9.1", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.11.0", "lazy_static", "lazycell", "log", @@ -732,7 +732,7 @@ dependencies = [ "bitflags 2.9.1", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.11.0", "log", "prettyplease", "proc-macro2", @@ -797,7 +797,7 @@ dependencies = [ [[package]] name = "blade-graphics" version = "0.6.0" -source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5" +source = "git+https://github.com/kvark/blade?rev=bfa594ea697d4b6326ea29f747525c85ecf933b9#bfa594ea697d4b6326ea29f747525c85ecf933b9" dependencies = [ "ash", "ash-window", @@ -830,7 +830,7 @@ dependencies = [ [[package]] name = "blade-macros" version = "0.3.0" -source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5" +source = "git+https://github.com/kvark/blade?rev=bfa594ea697d4b6326ea29f747525c85ecf933b9#bfa594ea697d4b6326ea29f747525c85ecf933b9" dependencies = [ "proc-macro2", "quote", @@ -840,7 +840,7 @@ dependencies = [ [[package]] name = "blade-util" version = "0.2.0" -source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5" +source = "git+https://github.com/kvark/blade?rev=bfa594ea697d4b6326ea29f747525c85ecf933b9#bfa594ea697d4b6326ea29f747525c85ecf933b9" dependencies = [ "blade-graphics", "bytemuck", @@ -1401,7 +1401,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#a852bcc09410b47dcabbe9b089725777024d125e" +source = "git+https://github.com/zed-industries/zed.git#5c30578c4961d3766ee991b119658b02741b85e5" dependencies = [ "indexmap 2.10.0", "rustc-hash 2.1.1", @@ -1870,7 +1870,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#a852bcc09410b47dcabbe9b089725777024d125e" +source = "git+https://github.com/zed-industries/zed.git#5c30578c4961d3766ee991b119658b02741b85e5" dependencies = [ "proc-macro2", "quote", @@ -3049,7 +3049,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#a852bcc09410b47dcabbe9b089725777024d125e" +source = "git+https://github.com/zed-industries/zed.git#5c30578c4961d3766ee991b119658b02741b85e5" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -3101,7 +3101,7 @@ dependencies = [ "pathfinder_geometry", "postage", "profiling", - "rand 0.8.5", + "rand 0.9.2", "raw-window-handle", "refineable", "resvg", @@ -3160,7 +3160,7 @@ dependencies = [ "once_cell", "paste", "regex", - "ropey", + "rope", "rust-i18n", "rust_decimal", "schemars", @@ -3232,7 +3232,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#a852bcc09410b47dcabbe9b089725777024d125e" +source = "git+https://github.com/zed-industries/zed.git#5c30578c4961d3766ee991b119658b02741b85e5" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3501,7 +3501,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#a852bcc09410b47dcabbe9b089725777024d125e" +source = "git+https://github.com/zed-industries/zed.git#5c30578c4961d3766ee991b119658b02741b85e5" dependencies = [ "anyhow", "bytes", @@ -3521,7 +3521,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#a852bcc09410b47dcabbe9b089725777024d125e" +source = "git+https://github.com/zed-industries/zed.git#5c30578c4961d3766ee991b119658b02741b85e5" dependencies = [ "rustls", "rustls-platform-verifier", @@ -3625,7 +3625,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.58.0", ] [[package]] @@ -4466,7 +4466,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#a852bcc09410b47dcabbe9b089725777024d125e" +source = "git+https://github.com/zed-industries/zed.git#5c30578c4961d3766ee991b119658b02741b85e5" dependencies = [ "anyhow", "bindgen 0.71.1", @@ -4902,7 +4902,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" dependencies = [ - "proc-macro-crate 3.3.0", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.105", @@ -6380,7 +6380,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#a852bcc09410b47dcabbe9b089725777024d125e" +source = "git+https://github.com/zed-industries/zed.git#5c30578c4961d3766ee991b119658b02741b85e5" dependencies = [ "derive_refineable", "workspace-hack", @@ -6533,7 +6533,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#a852bcc09410b47dcabbe9b089725777024d125e" +source = "git+https://github.com/zed-industries/zed.git#5c30578c4961d3766ee991b119658b02741b85e5" dependencies = [ "anyhow", "bytes", @@ -6544,7 +6544,6 @@ dependencies = [ "regex", "reqwest 0.12.15", "serde", - "smol 2.0.2", "tokio", "workspace-hack", ] @@ -6616,13 +6615,18 @@ dependencies = [ ] [[package]] -name = "ropey" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +name = "rope" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git#5c30578c4961d3766ee991b119658b02741b85e5" dependencies = [ + "arrayvec", + "log", + "rayon", "smallvec", - "str_indices", + "sum_tree", + "unicode-segmentation", + "util", + "workspace-hack", ] [[package]] @@ -7120,7 +7124,7 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] name = "semantic_version" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#a852bcc09410b47dcabbe9b089725777024d125e" +source = "git+https://github.com/zed-industries/zed.git#5c30578c4961d3766ee991b119658b02741b85e5" dependencies = [ "anyhow", "serde", @@ -7645,12 +7649,6 @@ dependencies = [ "unindent", ] -[[package]] -name = "str_indices" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" - [[package]] name = "streaming-iterator" version = "0.1.9" @@ -7749,7 +7747,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#a852bcc09410b47dcabbe9b089725777024d125e" +source = "git+https://github.com/zed-industries/zed.git#5c30578c4961d3766ee991b119658b02741b85e5" dependencies = [ "arrayvec", "log", @@ -9059,7 +9057,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#a852bcc09410b47dcabbe9b089725777024d125e" +source = "git+https://github.com/zed-industries/zed.git#5c30578c4961d3766ee991b119658b02741b85e5" dependencies = [ "anyhow", "async-fs 2.1.3", diff --git a/Cargo.toml b/Cargo.toml index 7a08103e..5ee4a4b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ resolver = "2" [workspace.dependencies] gpui = { git = "https://github.com/zed-industries/zed.git" } gpui_macros = { git = "https://github.com/zed-industries/zed.git" } +rope = { git = "https://github.com/zed-industries/zed.git" } reqwest_client = { git = "https://github.com/zed-industries/zed.git" } sum_tree = { git = "https://github.com/zed-industries/zed.git" } gpui-component = { path = "crates/ui" } diff --git a/README.md b/README.md index fa5d030b..dc19189b 100644 --- a/README.md +++ b/README.md @@ -67,29 +67,30 @@ Check out [CONTRIBUTING.md](CONTRIBUTING.md) for more details. ## Compare to others -| Features | GPUI Component | [Iced] | [egui] | [QT 6] | -| --------------------- | ------------------------------ | --------- | --------------------- | ------------------------------ | -| Language | Rust | Rust | Rust | C++/QML | -| Core Render | GPUI | wgpu | wgpu | QT | -| License | Apache 2.0 | MIT | MIT/Apache 2.0 | Commercial | -| Min Binary Size [^1] | 12MB | 11MB | 5M | 20MB [^2] | -| Cross-Platform | Yes | Yes | Yes | Yes | -| Documentation | No | Simple | Simple | Good | -| Web | No | Yes | Yes | Yes | -| UI Style | Modern | Basic | Basic | Basic | -| CJK Support | Yes | Yes | Bad | Yes | -| Chart | Yes | No | No | Yes | -| Table (Large dataset) | Yes
(Virtual Rows, Columns) | No | Yes
(Virtual Rows) | Yes
(Virtual Rows, Columns) | -| Table Column Resize | Yes | No | Yes | Yes | -| CodeEditor | Simple | Simple | Simple | Basic API | -| Dock Layout | Yes | Yes | Yes | Yes | -| Syntax Highlight | [Tree Sitter] | [Syntect] | [Syntect] | [QSyntaxHighlighter] | -| Markdown Rendering | Yes | Yes | Basic | No | -| Markdown mix HTML | Yes | No | No | No | -| HTML Rendering | Basic | No | No | Yes | -| Text Selection | TextView | No | Any Label | No | -| Themes | Yes | No | No | No | -| I18n | Yes | Yes | Yes | Yes | +| Features | GPUI Component | [Iced] | [egui] | [QT 6] | +| --------------------- | ------------------------------ | ----------- | --------------------- | ------------------------------ | +| Language | Rust | Rust | Rust | C++/QML | +| Core Render | GPUI | wgpu | wgpu | QT | +| License | Apache 2.0 | MIT | MIT/Apache 2.0 | Commercial | +| Min Binary Size [^1] | 12MB | 11MB | 5M | 20MB [^2] | +| Cross-Platform | Yes | Yes | Yes | Yes | +| Documentation | No | Simple | Simple | Good | +| Web | No | Yes | Yes | Yes | +| UI Style | Modern | Basic | Basic | Basic | +| CJK Support | Yes | Yes | Bad | Yes | +| Chart | Yes | No | No | Yes | +| Table (Large dataset) | Yes
(Virtual Rows, Columns) | No | Yes
(Virtual Rows) | Yes
(Virtual Rows, Columns) | +| Table Column Resize | Yes | No | Yes | Yes | +| Text base | Rope | String [^3] | trait TextBuffer [^4] | [QTextDocument] | +| CodeEditor | Simple | Simple | Simple | Basic API | +| Dock Layout | Yes | Yes | Yes | Yes | +| Syntax Highlight | [Tree Sitter] | [Syntect] | [Syntect] | [QSyntaxHighlighter] | +| Markdown Rendering | Yes | Yes | Basic | No | +| Markdown mix HTML | Yes | No | No | No | +| HTML Rendering | Basic | No | No | Yes | +| Text Selection | TextView | No | Any Label | No | +| Themes | Yes | No | No | No | +| I18n | Yes | Yes | Yes | Yes | > Please submit an issue or PR if any mistakes or outdated are found. @@ -99,11 +100,16 @@ Check out [CONTRIBUTING.md](CONTRIBUTING.md) for more details. [Tree Sitter]: https://tree-sitter.github.io/tree-sitter/ [Syntect]: https://github.com/trishume/syntect [QSyntaxHighlighter]: https://doc.qt.io/qt-6/qsyntaxhighlighter.html +[QTextDocument]: https://doc.qt.io/qt-6/qtextdocument.html [^1]: Release builds by use simple hello world example. [^2]: [Reducing Binary Size of Qt Applications](https://www.qt.io/blog/reducing-binary-size-of-qt-applications-part-3-more-platforms) +[^3]: Iced TextInput: https://github.com/iced-rs/iced/blob/c65bfd1afb399a74eddb3f66853f3cc385a08826/widget/src/text_input.rs#L111 + +[^4]: egui TextBuffer: https://github.com/emilk/egui/blob/0a81372cfd3a4deda640acdecbbaf24bf78bb6a2/crates/egui/src/widgets/text_edit/text_buffer.rs#L20 + ## License Apache-2.0 diff --git a/crates/story/examples/code-editor.rs b/crates/story/examples/code-editor.rs index 5a82ad7c..cc0fa154 100644 --- a/crates/story/examples/code-editor.rs +++ b/crates/story/examples/code-editor.rs @@ -331,8 +331,9 @@ fn main() { init(cx); cx.activate(true); - story::create_new_window( + story::create_new_window_with_size( "Code Editor", + Some(size(px(1200.), px(960.))), |window, cx| cx.new(|cx| Example::new(name, window, cx)), cx, ); diff --git a/crates/story/examples/large-text.rs b/crates/story/examples/large-text.rs new file mode 100644 index 00000000..28ae23f5 --- /dev/null +++ b/crates/story/examples/large-text.rs @@ -0,0 +1,155 @@ +use gpui::*; +use gpui_component::{ + button::{Button, ButtonVariants as _}, + h_flex, + input::{InputEvent, InputState, TabSize, TextInput}, + v_flex, ActiveTheme, ContextModal, Selectable, Sizable, +}; +use story::Assets; + +pub struct Example { + editor: Entity, + go_to_line_state: Entity, + soft_wrap: bool, + _subscribes: Vec, +} + +impl Example { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + // 10K lines + let text = "这是一个中文演示段落,用于展示更多的 [Markdown GFM] 内容。您可以在此尝试使用使用**粗体**、*斜体*和`代码`等样式。これは日本語のデモ段落です。Markdown の多言語サポートを示すためのテキストが含まれています。例えば、、**ボールド**、_イタリック_、および`コード`のスタイルなどを試すことができます。\n".repeat(10000); + + let editor = cx.new(|cx| { + InputState::new(window, cx) + .multi_line() + .line_number(true) + .tab_size(TabSize { + tab_size: 4, + hard_tabs: false, + }) + .soft_wrap(false) + .placeholder("Enter your code here...") + .default_value(text) + }); + let go_to_line_state = cx.new(|cx| InputState::new(window, cx)); + + let _subscribes = vec![cx.subscribe(&editor, |_, _, _: &InputEvent, cx| { + cx.notify(); + })]; + + Self { + editor, + go_to_line_state, + soft_wrap: false, + _subscribes, + } + } + + fn view(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Self::new(window, cx)) + } + + 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 + } + }) + }); + } + + fn toggle_soft_wrap(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + self.soft_wrap = !self.soft_wrap; + self.editor.update(cx, |state, cx| { + state.set_soft_wrap(self.soft_wrap, window, cx); + }); + cx.notify(); + } +} + +impl Render for Example { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex().size_full().child( + v_flex() + .id("source") + .w_full() + .flex_1() + .child( + TextInput::new(&self.editor) + .bordered(false) + .h_full() + .focus_bordered(false), + ) + .child( + 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().gap_3().child({ + Button::new("soft-wrap") + .ghost() + .xsmall() + .label("Soft Wrap") + .selected(self.soft_wrap) + .on_click(cx.listener(Self::toggle_soft_wrap)) + })) + .child({ + 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)) + }), + ), + ) + } +} + +fn main() { + let app = Application::new().with_assets(Assets); + + app.run(move |cx| { + story::init(cx); + cx.activate(true); + + story::create_new_window("Large Text Editor", Example::view, cx); + }); +} diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index e7e3e6a5..9d09246a 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -52,9 +52,9 @@ pub use assets::Assets; use gpui::{ actions, div, prelude::FluentBuilder as _, px, rems, size, Action, AnyElement, AnyView, App, AppContext, Bounds, Context, Div, Entity, EventEmitter, Focusable, Global, Hsla, - InteractiveElement, IntoElement, KeyBinding, Menu, MenuItem, ParentElement, Render, RenderOnce, - SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, WindowBounds, - WindowKind, WindowOptions, + InteractiveElement, IntoElement, KeyBinding, Menu, MenuItem, ParentElement, Pixels, Render, + RenderOnce, SharedString, Size, StatefulInteractiveElement, StyleRefinement, Styled, Window, + WindowBounds, WindowKind, WindowOptions, }; pub use accordion_story::AccordionStory; @@ -166,7 +166,19 @@ where E: Into, F: FnOnce(&mut Window, &mut App) -> E + Send + 'static, { - let mut window_size = size(px(1600.0), px(1200.0)); + create_new_window_with_size(title, None, crate_view_fn, cx); +} + +pub fn create_new_window_with_size( + title: &str, + window_size: Option>, + crate_view_fn: F, + cx: &mut App, +) where + E: Into, + F: FnOnce(&mut Window, &mut App) -> E + Send + 'static, +{ + let mut window_size = window_size.unwrap_or(size(px(1600.0), px(1200.0))); if let Some(display) = cx.primary_display() { let display_size = display.bounds().size; window_size.width = window_size.width.min(display_size.width * 0.85); diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index ffa02480..4e3f98f0 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -51,6 +51,7 @@ tree-sitter-languages = [ gpui.workspace = true sum_tree.workspace = true gpui_macros.workspace = true +rope.workspace = true gpui-component-macros.workspace = true rust-i18n.workspace = true schemars.workspace = true @@ -70,7 +71,6 @@ paste = "1" regex = "1" unicode-segmentation = "1.12.0" uuid = "1.10" -ropey = "1.6.1" # WebView wry = { version = "0.48.0", optional = true } diff --git a/crates/ui/src/highlighter/highlighter.rs b/crates/ui/src/highlighter/highlighter.rs index b8eb1104..746edf2f 100644 --- a/crates/ui/src/highlighter/highlighter.rs +++ b/crates/ui/src/highlighter/highlighter.rs @@ -1,13 +1,13 @@ use super::HighlightTheme; -use crate::highlighter::LanguageRegistry; +use crate::{highlighter::LanguageRegistry, input::RopeExt as _}; use anyhow::{anyhow, Context, Result}; use gpui::{App, HighlightStyle, SharedString}; -use ropey::Rope; + +use rope::Rope; use std::{ collections::{BTreeSet, HashMap}, ops::Range, - slice::Chunks, usize, }; use sum_tree::{Bias, SumTree}; @@ -42,12 +42,20 @@ pub struct SyntaxHighlighter { } struct TextProvider<'a>(&'a Rope); +struct ByteChunks<'a>(rope::Chunks<'a>); impl<'a> tree_sitter::TextProvider<&'a [u8]> for TextProvider<'a> { - type I = Chunks<'a, u8>; + type I = ByteChunks<'a>; fn text(&mut self, node: tree_sitter::Node) -> Self::I { - let slice = self.0.byte_slice(node.start_byte()..node.end_byte()); - slice.as_str().unwrap_or_default().as_bytes().chunks(64) + ByteChunks(self.0.chunks_in_range(node.byte_range())) + } +} + +impl<'a> Iterator for ByteChunks<'a> { + type Item = &'a [u8]; + + fn next(&mut self) -> Option { + self.0.next().map(str::as_bytes) } } @@ -283,20 +291,20 @@ impl SyntaxHighlighter { } pub fn is_empty(&self) -> bool { - self.text.len_bytes() == 0 + self.text.len() == 0 } /// Highlight the given text, returning a map from byte ranges to highlight captures. /// Uses incremental parsing, detects changed ranges, and caches unchanged results. pub fn update(&mut self, edit: Option, text: &Rope, cx: &App) { - if &self.text == text { + if self.text.eq(text) { return; } let edit = edit.unwrap_or(InputEdit { start_byte: 0, old_end_byte: 0, - new_end_byte: text.len_bytes(), + new_end_byte: text.len(), start_position: Point::new(0, 0), old_end_position: Point::new(0, 0), new_end_position: Point::new(0, 0), @@ -308,14 +316,11 @@ impl SyntaxHighlighter { .unwrap_or(self.parser.parse("", None).unwrap()); old_tree.edit(&edit); + let mut chunks = text.chunks(); let new_tree = self.parser.parse_with_options( - &mut |offset, _| { - if offset >= text.len_bytes() { - "" - } else { - let (chunk, chunk_byte_ix, _, _) = text.chunk_at_byte(offset); - &chunk[offset - chunk_byte_ix..] - } + &mut move |offset, _| { + chunks.seek(offset); + chunks.next().unwrap_or("").as_bytes() }, Some(&old_tree), None, @@ -437,10 +442,13 @@ impl SyntaxHighlighter { return cache; }; - let content = self.text.byte_slice(node.start_byte()..node.end_byte()); - if content.len_bytes() == 0 { + let content = self.text.slice(node.byte_range()); + if content.len() == 0 { return cache; }; + // FIXME: Avoid to_string. + let content = content.to_string(); + let Some(config) = LanguageRegistry::global(cx).language(injection_language) else { return cache; }; @@ -449,7 +457,7 @@ impl SyntaxHighlighter { return cache; } - let source = content.as_str().unwrap_or_default().as_bytes(); + let source = content.as_bytes(); let Some(tree) = parser.parse(source, None) else { return cache; }; diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 943b8ea4..b39259bc 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -9,7 +9,9 @@ use gpui::{ use smallvec::SmallVec; use crate::{ - highlighter::SyntaxHighlighter, input::blink_cursor::CURSOR_WIDTH, ActiveTheme as _, Root, + highlighter::SyntaxHighlighter, + input::{blink_cursor::CURSOR_WIDTH, RopeExt as _}, + ActiveTheme as _, Root, }; use super::{mode::InputMode, InputState, LastLayout}; @@ -391,9 +393,9 @@ impl TextElement { let mut skipped_offset = 0; let mut styles = vec![]; - // The Rope line has includes `\n` and `\r`. for (ix, line) in state.text.lines().enumerate() { - let line_len = line.len_bytes(); + // +1 for `\n` + let line_len = line.len() + 1; if ix < visible_range.start { offset += line_len; skipped_offset = offset; @@ -547,7 +549,7 @@ impl Element for TextElement { let state = self.state.read(cx); let multi_line = state.mode.is_multi_line(); let text = state.text.clone(); - let is_empty = text.len_bytes() == 0; + let is_empty = text.len() == 0; let placeholder = self.placeholder.clone(); let style = window.text_style(); let font_size = style.font_size.to_pixels(window.rem_size()); @@ -556,7 +558,7 @@ impl Element for TextElement { let (display_text, text_color) = if is_empty { (placeholder, cx.theme().muted_foreground) } else if state.masked { - ("*".repeat(text.len_chars()).into(), cx.theme().foreground) + ("*".repeat(text.chars_count()).into(), cx.theme().foreground) } else { (text.to_string().into(), cx.theme().foreground) }; @@ -564,25 +566,21 @@ impl Element for TextElement { let text_style = window.text_style(); // Calculate the width of the line numbers - let empty_line_number = window - .text_system() - .shape_text( - "++++".into(), - font_size, - &[TextRun { - len: 4, - font: style.font(), - color: gpui::black(), - background_color: None, - underline: None, - strikethrough: None, - }], - None, - None, - ) - .unwrap(); + let empty_line_number = window.text_system().shape_line( + "++++".into(), + font_size, + &[TextRun { + len: 4, + font: style.font(), + color: gpui::black(), + background_color: None, + underline: None, + strikethrough: None, + }], + None, + ); let line_number_width = if state.mode.line_number() { - empty_line_number.last().unwrap().width() + LINE_NUMBER_RIGHT_MARGIN + empty_line_number.width + LINE_NUMBER_RIGHT_MARGIN } else { px(0.) }; diff --git a/crates/ui/src/input/marker.rs b/crates/ui/src/input/marker.rs index c2fba985..eb0874aa 100644 --- a/crates/ui/src/input/marker.rs +++ b/crates/ui/src/input/marker.rs @@ -44,7 +44,7 @@ impl Marker { return; }; - let start_line_str = state.text.byte_slice(start_line.range.clone()); + let start_line_str = state.text.slice(start_line.range.clone()); let Some(end_line) = state .text_wrapper @@ -53,7 +53,7 @@ impl Marker { else { return; }; - let end_line_str = state.text.byte_slice(end_line.range.clone()); + let end_line_str = state.text.slice(end_line.range.clone()); let start_byte = start_line.range.start + start_line_str diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index 0a02d6c1..f9ea1c57 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -21,6 +21,6 @@ pub use mask_pattern::MaskPattern; pub use mode::TabSize; pub use number_input::{NumberInput, NumberInputEvent, StepAction}; pub use otp_input::*; -pub(crate) use rope_ext::*; +pub use rope_ext::*; pub use state::*; pub use text_input::*; diff --git a/crates/ui/src/input/mode.rs b/crates/ui/src/input/mode.rs index 168b284b..a13be6a1 100644 --- a/crates/ui/src/input/mode.rs +++ b/crates/ui/src/input/mode.rs @@ -2,10 +2,9 @@ use std::rc::Rc; use std::{cell::RefCell, ops::Range}; use gpui::{App, SharedString}; -use ropey::Rope; +use rope::Rope; use tree_sitter::{InputEdit, Point}; -use crate::input::RopeExt as _; use crate::{highlighter::SyntaxHighlighter, input::marker::Marker}; use super::text_wrapper::TextWrapper; @@ -190,7 +189,7 @@ impl InputMode { // When full text changed, the selected_range may be out of bound (The before version). let mut selected_range = selected_range.clone(); - selected_range.end = selected_range.end.min(text.len_bytes()); + selected_range.end = selected_range.end.min(text.len()); // If insert a chart, this is 1. // If backspace or delete, this is -1. @@ -199,17 +198,23 @@ impl InputMode { let changed_len = new_text.len() as isize - selected_range.len() as isize; let new_end = (selected_range.end as isize + changed_len) as usize; - let start_pos = text.line_column(selected_range.start); - let old_end_pos = text.line_column(selected_range.end); - let new_end_pos = text.line_column(new_end); + let start_pos = text.offset_to_point(selected_range.start); + let old_end_pos = text.offset_to_point(selected_range.end); + let new_end_pos = text.offset_to_point(new_end); let edit = InputEdit { start_byte: selected_range.start, old_end_byte: selected_range.end, new_end_byte: new_end, - start_position: Point::new(start_pos.0, start_pos.1), - old_end_position: Point::new(old_end_pos.0, old_end_pos.1), - new_end_position: Point::new(new_end_pos.0, new_end_pos.1), + start_position: Point::new(start_pos.row as usize, start_pos.column as usize), + old_end_position: Point::new( + old_end_pos.row as usize, + old_end_pos.column as usize, + ), + new_end_position: Point::new( + new_end_pos.row as usize, + new_end_pos.column as usize, + ), }; highlighter.update(Some(edit), text, cx); diff --git a/crates/ui/src/input/rope_ext.rs b/crates/ui/src/input/rope_ext.rs index e334af92..44cf111c 100644 --- a/crates/ui/src/input/rope_ext.rs +++ b/crates/ui/src/input/rope_ext.rs @@ -1,40 +1,132 @@ -pub(crate) trait RopeExt { - /// Get the index of (line, column) (0-based) from the byte offset (0-based). - /// If the offset is out of bounds, return the last line and column. +use rope::{Point, Rope}; + +/// An extension trait for `Rope` to provide additional utility methods. +pub trait RopeExt { + /// Get the line at the given row index, including the `\r` at the end, but not `\n`. /// - /// If the `offset` is out of bounds, it returns (0, 0). - fn line_column(&self, byte_offset: usize) -> (usize, usize); - /// Get the byte offset (0-based) from the line, column (0-based). + /// Return empty rope if the row is out of bounds. + fn line(&self, row: usize) -> Rope; + + /// Return the number of lines in the rope. + fn lines_len(&self) -> usize; + + /// Return the lines iterator. /// - /// Return the last line, if line is out of bounds. - /// Return the end column of line, if the column is out of bounds. - fn line_column_to_byte(&self, line_ix: usize, column_ix: usize) -> usize; + /// Each line is including the `\n` at the end, but not `\n`. + fn lines(&self) -> impl Iterator; + + /// Check is equal to another rope. + fn eq(&self, other: &Rope) -> bool; + + /// Total number of characters in the rope. + fn chars_count(&self) -> usize; + + /// Get char at the given offset (byte). + /// + /// If the offset is in the middle of a multi-byte character will panic. + /// + /// If the offset is out of bounds, return None. + fn char_at(&self, offset: usize) -> Option; } -impl RopeExt for ropey::Rope { - fn line_column(&self, offset: usize) -> (usize, usize) { - let Ok(line_ix) = self.try_byte_to_line(offset) else { - return (0, 0); - }; - - let line = self.line(line_ix); - let line_start_byte = self.line_to_byte(line_ix); - let line_offset = offset.saturating_sub(line_start_byte); - - let column_ix = line - .try_byte_to_char(line_offset) - .unwrap_or(line.len_chars()); - - (line_ix, column_ix) +impl RopeExt for Rope { + fn line(&self, row: usize) -> Rope { + let row = row as u32; + let start = self.point_to_offset(Point::new(row, 0)); + let end = start + self.line_len(row) as usize; + self.slice(start..end) } - fn line_column_to_byte(&self, line_ix: usize, column_ix: usize) -> usize { - let line_ix = self.len_lines().saturating_sub(1).min(line_ix); - let line = self.line(line_ix); + fn lines_len(&self) -> usize { + self.max_point().row as usize + 1 + } - self.line_to_byte(line_ix) - + line - .try_char_to_byte(column_ix) - .unwrap_or(line.len_bytes().saturating_sub(1)) + fn lines(&self) -> impl Iterator { + (0..self.lines_len()).map(move |row| self.line(row)) + } + + fn eq(&self, other: &Rope) -> bool { + self.summary() == other.summary() + } + + fn chars_count(&self) -> usize { + self.chars().count() + } + + fn char_at(&self, offset: usize) -> Option { + if offset > self.len() { + return None; + } + + self.slice(offset..self.len()).chars().next() + } +} + +#[cfg(test)] +mod tests { + use rope::Rope; + + use crate::input::RopeExt as _; + + #[test] + fn test_line() { + let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); + assert_eq!(rope.line(0).to_string(), "Hello"); + assert_eq!(rope.line(1).to_string(), "World\r"); + assert_eq!(rope.line(2).to_string(), "This is a test 中文"); + assert_eq!(rope.line(3).to_string(), "Rope"); + assert_eq!(rope.line(4).to_string(), ""); + } + + #[test] + fn test_lines_len() { + let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); + assert_eq!(rope.lines_len(), 4); + let rope = Rope::from(""); + assert_eq!(rope.lines_len(), 1); + let rope = Rope::from("Single line"); + assert_eq!(rope.lines_len(), 1); + } + + #[test] + fn test_eq() { + let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); + assert!(rope.eq(&Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"))); + assert!(!rope.eq(&Rope::from("Hello\nWorld"))); + + let rope1 = rope.clone(); + assert!(rope.eq(&rope1)); + } + + #[test] + fn test_lines() { + let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); + let lines: Vec<_> = rope.lines().into_iter().map(|r| r.to_string()).collect(); + assert_eq!( + lines, + vec!["Hello", "World\r", "This is a test 中文", "Rope"] + ); + } + + #[test] + fn test_chars_count() { + let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文🎉\nRope"); + assert_eq!(rope.chars_count(), 36); + let rope = Rope::from(""); + assert_eq!(rope.chars_count(), 0); + let rope = Rope::from("Single line"); + assert_eq!(rope.chars_count(), 11); + } + + #[test] + fn test_char_at() { + let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文🎉\nRope"); + assert_eq!(rope.char_at(0), Some('H')); + assert_eq!(rope.char_at(5), Some('\n')); + assert_eq!(rope.char_at(13), Some('T')); + assert_eq!(rope.char_at(28), Some('中')); + assert_eq!(rope.char_at(34), Some('🎉')); + assert_eq!(rope.char_at(38), Some('\n')); + assert_eq!(rope.char_at(50), None); } } diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index 3dbbbd20..997f411f 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -3,12 +3,13 @@ //! Based on the `Input` example from the `gpui` crate. //! https://github.com/zed-industries/zed/blob/main/crates/gpui/examples/input.rs use gpui::Action; -use ropey::{Rope, RopeSlice}; +use rope::Rope; use serde::Deserialize; use smallvec::SmallVec; use std::cell::RefCell; use std::ops::{Deref, Range}; use std::rc::Rc; +use sum_tree::Bias; use unicode_segmentation::*; use gpui::{ @@ -19,9 +20,6 @@ use gpui::{ ScrollWheelEvent, SharedString, Styled as _, Subscription, UTF16Selection, Window, WrappedLine, }; -// TODO: -// - Move cursor to skip line eof empty chars. - use super::{ blink_cursor::BlinkCursor, change::Change, @@ -33,7 +31,7 @@ use super::{ }; use crate::input::hover_popover::DiagnosticPopover; use crate::input::marker::Marker; -use crate::input::{Cursor, LineColumn, RopeExt, Selection}; +use crate::input::{Cursor, LineColumn, RopeExt as _, Selection}; use crate::{history::History, scroll::ScrollbarState, Root}; #[derive(Action, Clone, PartialEq, Eq, Deserialize)] @@ -518,8 +516,8 @@ impl InputState { /// Called after moving the cursor. Updates preferred_column if we know where the cursor now is. fn update_preferred_column(&mut self) { - let column_ix = self.text.line_column(self.cursor().offset).1; - self.preferred_column = Some(column_ix); + let column_ix = self.text.offset_to_point(self.cursor().offset).column; + self.preferred_column = Some(column_ix as usize); } /// Find which line and sub-line the given offset belongs to, along with the position within that sub-line. @@ -565,22 +563,12 @@ impl InputState { let offset = self.cursor().offset; let was_preferred_column = self.preferred_column; - let Ok(line_ix) = self.text.try_byte_to_line(offset) else { - return; - }; + let row = self.text.offset_to_point(offset).row; + let new_row = row.saturating_add_signed(move_lines as i32); + let line = self.text.line(new_row as usize); + let line_start_offset = self.text.point_to_offset(rope::Point::new(new_row, 0)); - let new_line_ix = line_ix.saturating_add_signed(move_lines); - let Some(line) = self.text.get_line(new_line_ix) else { - return; - }; - let Ok(line_start_offset) = self.text.try_line_to_byte(new_line_ix) else { - return; - }; - - let new_column = self - .preferred_column - .unwrap_or_default() - .min(line.len_bytes().saturating_sub(1)); + let new_column = self.preferred_column.unwrap_or_default().min(line.len()); let new_offset = line_start_offset + new_column; self.pause_blink_cursor(cx); @@ -607,7 +595,7 @@ impl InputState { // Ensure cursor to start when set text if self.mode.is_single_line() { self.selected_range = - (Cursor::new(self.text.len_bytes())..Cursor::new(self.text.len_bytes())).into(); + (Cursor::new(self.text.len())..Cursor::new(self.text.len())).into(); } else { self.selected_range = (Cursor::new(0)..Cursor::new(0)).into(); } @@ -728,14 +716,14 @@ impl InputState { /// Set the default value of the input field. pub fn default_value(mut self, value: impl Into) -> Self { let text: SharedString = value.into(); - self.text = Rope::from_str(text.as_str()); + self.text = Rope::from(text.as_str()); self.text_wrapper.text = self.text.clone(); self } /// Return the value of the input field. pub fn value(&self) -> SharedString { - SharedString::new(self.text.slice(..).as_str().unwrap_or_default()) + SharedString::new(self.text.to_string()) } /// Return the value without mask. @@ -746,11 +734,11 @@ impl InputState { /// Return the (1-based) line and column of the cursor. pub fn line_column(&self) -> LineColumn { let offset = self.cursor().offset; - let (line_ix, column_ix) = self.text.line_column(offset); + let point = self.text.offset_to_point(offset); LineColumn { - line: line_ix + 1, - column: column_ix + 1, + line: point.row as usize + 1, + column: point.column as usize + 1, } } @@ -771,11 +759,20 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - let line_ix = line.saturating_sub(1); - let column_ix = column.unwrap_or(1).saturating_sub(1); - let offset = self.text.line_column_to_byte(line_ix, column_ix); + let max_point = self.text.max_point(); + let line_ix = line.saturating_sub(1).min(max_point.row as usize); + let column_ix = column + .unwrap_or(1) + .saturating_sub(1) + .min(self.text.line_len(line_ix as u32) as usize); + + let offset = self + .text + .point_to_offset(rope::Point::new(line_ix as u32, column_ix as u32)); self.move_to(Cursor::new(offset), window, cx); + self.update_preferred_column(); + self.focus(window, cx); } /// Focus the input field. @@ -916,7 +913,7 @@ impl InputState { if self.mode.is_single_line() { return; } - let offset = (self.end_of_line(window, cx) + 1).min(self.text.len_bytes()); + let offset = (self.end_of_line(window, cx) + 1).min(self.text.len()); self.select_to(Cursor::new(self.next_boundary(offset)), window, cx); } @@ -927,7 +924,7 @@ impl InputState { cx: &mut Context, ) { self.move_to(Cursor::new(0), window, cx); - self.select_to(Cursor::new(self.text.len_bytes()), 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) { @@ -957,7 +954,7 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - let end = self.text.len_bytes(); + let end = self.text.len(); self.move_to(Cursor::new(end), window, cx); } @@ -996,7 +993,7 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - let end = self.text.len_bytes(); + let end = self.text.len(); self.select_to(Cursor::new(end), window, cx); } @@ -1043,11 +1040,10 @@ impl InputState { /// Return the start offset of the previous word. fn previous_start_of_word(&mut self) -> usize { let offset = self.selected_range.start.offset; - let prev_str = self - .text_for_range_utf8(0..offset) - .as_str() - .unwrap_or_default(); - UnicodeSegmentation::split_word_bound_indices(prev_str) + // FIXME: Avoid to_string + let left_part = self.text.slice(0..offset).to_string(); + + UnicodeSegmentation::split_word_bound_indices(left_part.as_str()) .filter(|(_, s)| !s.trim_start().is_empty()) .next_back() .map(|(i, _)| i) @@ -1057,14 +1053,12 @@ 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 next_str = self - .text_for_range_utf8(offset..self.text.len_bytes()) - .as_str() - .unwrap_or_default(); - UnicodeSegmentation::split_word_bound_indices(next_str) + let right_part = self.text.slice(offset..self.text.len()).to_string(); + + UnicodeSegmentation::split_word_bound_indices(right_part.as_str()) .find(|(_, s)| !s.trim_start().is_empty()) .map(|(i, s)| offset + i + s.len()) - .unwrap_or(self.text.len_bytes()) + .unwrap_or(self.text.len()) } /// Get start of line @@ -1096,7 +1090,7 @@ impl InputState { .start .min(self.selected_range.end.offset), ); - if self.text.chars().nth(offset) == Some('\r') { + if self.text.char_at(offset) == Some('\r') { offset += 1; } @@ -1112,11 +1106,11 @@ impl InputState { /// Get end of line fn end_of_line(&mut self, window: &mut Window, cx: &mut Context) -> usize { if self.mode.is_single_line() { - return self.text.len_bytes(); + return self.text.len(); } // let line = self.text.byte_to_line(self.cursor().offset); - // let offset = self.text.line_to_byte(line) + self.text.line(line).len_bytes(); + // let offset = self.text.line_to_byte(line) + self.text.line(line).len(); let offset = self.next_boundary(self.cursor().offset); // ignore if offset is "\n" @@ -1135,7 +1129,7 @@ impl InputState { let line = self .text_for_range( - self.range_to_utf16(&(offset..self.text.len_bytes())), + self.range_to_utf16(&(offset..self.text.len())), &mut None, window, cx, @@ -1143,7 +1137,7 @@ impl InputState { .unwrap_or_default() .find('\n') .map(|i| i + offset) - .unwrap_or(self.text.len_bytes()); + .unwrap_or(self.text.len()); line } @@ -1242,7 +1236,7 @@ impl InputState { ) { let mut offset = self.end_of_line(window, cx); if offset == self.cursor().offset { - offset = (offset + 1).clamp(0, self.text.len_bytes()); + offset = (offset + 1).clamp(0, self.text.len()); } self.replace_text_in_range( Some(self.range_to_utf16(&(self.cursor().offset..offset))), @@ -1450,10 +1444,11 @@ impl InputState { // Selected none let start_offset = self.selected_range.start; let offset = self.start_of_line_of_selection(window, cx); + // FIXME: To improve performance if self - .text_for_range_utf8(offset..self.text.len_bytes()) - .as_str() - .unwrap_or("") + .text + .slice(offset..self.text.len()) + .to_string() .starts_with(tab_indent.as_ref()) { self.replace_text_in_range( @@ -1600,7 +1595,7 @@ impl InputState { return; } - let selected_text = self.text_for_range_utf8(self.selected_range).to_string(); + let selected_text = self.text.slice(self.selected_range.into()).to_string(); cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); } @@ -1609,7 +1604,7 @@ impl InputState { return; } - let selected_text = self.text_for_range_utf8(self.selected_range).to_string(); + let selected_text = self.text.slice(self.selected_range.into()).to_string(); cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); self.replace_text_in_range(None, "", window, cx); } @@ -1625,20 +1620,12 @@ impl InputState { } } - fn push_history( - &mut self, - range: &Range, - new_text: &str, - window: &mut Window, - cx: &mut Context, - ) { + fn push_history(&mut self, text: &Rope, range: &Range, new_text: &str) { if self.history.ignore { return; } - let old_text = self - .text_for_range(self.range_to_utf16(&range), &mut None, window, cx) - .unwrap_or("".to_string()); + let old_text = text.slice(range.clone()).to_string(); let new_range = range.start..range.start + new_text.len(); @@ -1674,7 +1661,7 @@ impl InputState { /// /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. fn move_to(&mut self, cursor: Cursor, _: &mut Window, cx: &mut Context) { - let cursor = Cursor::new(cursor.offset.clamp(0, self.text.len_bytes())); + 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_column(); @@ -1703,7 +1690,7 @@ impl InputState { _cx: &App, ) -> usize { // If the text is empty, always return 0 - if self.text.len_bytes() == 0 { + if self.text.len() == 0 { return 0; } @@ -1768,8 +1755,8 @@ impl InputState { index += 1; } - if index > self.text.len_bytes() { - self.text.len_bytes() + if index > self.text.len() { + self.text.len() } else { index } @@ -1802,7 +1789,7 @@ impl InputState { /// /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. fn select_to(&mut self, offset: Cursor, _: &mut Window, cx: &mut Context) { - let offset = offset.clamp(0, self.text.len_bytes()); + let offset = offset.clamp(0, self.text.len()); if self.selection_reversed { self.selected_range.start = Cursor::new(offset) } else { @@ -1847,7 +1834,7 @@ impl InputState { .unwrap_or_default(); let next_text = self .text_for_range( - self.range_to_utf16(&(end..self.text.len_bytes())), + self.range_to_utf16(&(end..self.text.len())), &mut None, window, cx, @@ -1931,24 +1918,11 @@ impl InputState { } fn previous_boundary(&self, offset: usize) -> usize { - let char_ix = self.text.byte_to_char(offset).saturating_sub(1); - self.text.char_to_byte(char_ix) - - // self.text - // .grapheme_indices(true) - // .rev() - // .find_map(|(idx, _)| (idx < offset).then_some(idx)) - // .unwrap_or(0) + self.text.clip_offset(offset.saturating_sub(1), Bias::Left) } fn next_boundary(&self, offset: usize) -> usize { - let char_ix = self.text.byte_to_char(offset) + 1; - self.text.char_to_byte(char_ix).min(self.text.len_bytes()) - - // self.text - // .grapheme_indices(true) - // .find_map(|(idx, _)| (idx > offset).then_some(idx)) - // .unwrap_or(self.text.len()) + self.text.clip_offset(offset + 1, Bias::Right) } /// Returns the true to let InputElement to render cursor, when Input is focused and current BlinkCursor is visible. @@ -1991,7 +1965,7 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - if self.text.len_bytes() == 0 { + if self.text.len() == 0 { return; } @@ -2026,10 +2000,11 @@ impl InputState { return false; } - self.pattern - .as_ref() - .map(|p| p.is_match(new_text)) - .unwrap_or(true) + let Some(pattern) = &self.pattern else { + return true; + }; + + pattern.is_match(new_text) } /// Set the mask pattern for formatting the input text. @@ -2082,11 +2057,6 @@ impl InputState { } } } - - fn text_for_range_utf8(&'_ self, range: impl Into>) -> RopeSlice<'_> { - let range = self.range_from_utf16(&self.range_to_utf16(&range.into())); - self.text.byte_slice(range) - } } impl EntityInputHandler for InputState { @@ -2099,7 +2069,7 @@ impl EntityInputHandler for InputState { ) -> Option { let range = self.range_from_utf16(&range_utf16); adjusted_range.replace(self.range_to_utf16(&range)); - Some(self.text.byte_slice(range).to_string()) + Some(self.text.slice(range).to_string()) } fn selected_text_range( @@ -2135,7 +2105,7 @@ impl EntityInputHandler for InputState { &mut self, range_utf16: Option>, new_text: &str, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { if self.disabled { @@ -2148,23 +2118,26 @@ impl EntityInputHandler for InputState { .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_string() - + new_text - + &self - .text_for_range_utf8(range.end..self.text.len_bytes()) - .to_string()) - .into(); + let old_text = self.text.clone(); + self.text.replace(range.clone(), new_text); + + let pending_text = self.text.to_string(); // Check if the new text is valid if !self.is_valid_input(&pending_text, cx) { + self.text = old_text; return; } - let mask_text = self.mask_pattern.mask(&pending_text); - let new_text_len = (new_text.len() + mask_text.len()).saturating_sub(pending_text.len()); - let new_offset = (range.start + new_text_len).min(mask_text.len()); + let mut new_offset = (range.start + new_text.len()).min(self.text.len()); + if !self.mask_pattern.is_none() { + let mask_text = self.mask_pattern.mask(&pending_text); + self.text = Rope::from(mask_text.as_str()); + let new_text_len = + (new_text.len() + mask_text.len()).saturating_sub(pending_text.len()); + new_offset = (range.start + new_text_len).min(mask_text.len()); + } - self.push_history(&range, &new_text, window, cx); - self.text = Rope::from_str(&mask_text); + self.push_history(&old_text, &range, &new_text); self.mode.clear_markers(); self.text_wrapper.update(&self.text, false, cx); @@ -2185,7 +2158,7 @@ impl EntityInputHandler for InputState { range_utf16: Option>, new_text: &str, new_selected_range_utf16: Option>, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { if self.disabled { @@ -2197,18 +2170,17 @@ impl EntityInputHandler for InputState { .map(|range_utf16| self.range_from_utf16(range_utf16)) .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_string() - + new_text - + &self - .text_for_range_utf8(range.end..self.text.len_bytes()) - .to_string()) - .into(); + + let old_text = self.text.clone(); + self.text.replace(range.clone(), new_text); + let pending_text = self.text.to_string(); + if !self.is_valid_input(&pending_text, cx) { + self.text = old_text; return; } - self.push_history(&range, new_text, window, cx); - self.text = Rope::from_str(&pending_text); + self.push_history(&old_text, &range, new_text); self.mode.clear_markers(); self.text_wrapper.update(&self.text, false, cx); self.mode @@ -2232,7 +2204,6 @@ impl EntityInputHandler for InputState { } /// Used to position IME candidates. - /// TODO: Fix position of IME candidates in multi-line text input. fn bounds_for_range( &mut self, range_utf16: Range, diff --git a/crates/ui/src/input/text_input.rs b/crates/ui/src/input/text_input.rs index c2fc305f..d1f73a14 100644 --- a/crates/ui/src/input/text_input.rs +++ b/crates/ui/src/input/text_input.rs @@ -174,10 +174,8 @@ impl RenderOnce for TextInput { let prefix = self.prefix; let suffix = self.suffix; - let show_clear_button = self.cleanable - && !state.loading - && state.text.len_bytes() > 0 - && state.mode.is_single_line(); + let show_clear_button = + self.cleanable && !state.loading && state.text.len() > 0 && state.mode.is_single_line(); let has_suffix = suffix.is_some() || state.loading || self.mask_toggle || show_clear_button; div() diff --git a/crates/ui/src/input/text_wrapper.rs b/crates/ui/src/input/text_wrapper.rs index 38c2a848..823b4627 100644 --- a/crates/ui/src/input/text_wrapper.rs +++ b/crates/ui/src/input/text_wrapper.rs @@ -1,7 +1,9 @@ use std::ops::Range; use gpui::{App, Font, LineFragment, Pixels}; -use ropey::Rope; +use rope::Rope; + +use crate::input::RopeExt as _; #[allow(unused)] pub(super) struct LineWrap { @@ -44,11 +46,19 @@ impl TextWrapper { } pub(super) fn set_wrap_width(&mut self, wrap_width: Option, cx: &mut App) { + if wrap_width == self.wrap_width { + return; + } + 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) { + if self.font.eq(&font) && self.font_size == font_size { + return; + } + self.font = font; self.font_size = font_size; self.update(&self.text.clone(), true, cx); @@ -58,7 +68,7 @@ impl TextWrapper { /// /// If the `text` is the same as the current text, do nothing. pub(super) fn update(&mut self, text: &Rope, force: bool, cx: &mut App) { - if &self.text == text && !force { + if self.text.eq(text) && !force { return; } @@ -70,15 +80,15 @@ impl TextWrapper { .line_wrapper(self.font.clone(), self.font_size); let mut prev_line_ix = 0; - // FIXME: here may need use from Rope - for line in text.to_string().split('\n') { + for line in text.lines() { + let line = line.to_string(); let mut line_wraps = vec![]; let mut prev_boundary_ix = 0; // If wrap_width is Pixels::MAX, skip wrapping to disable word wrap if let Some(wrap_width) = wrap_width { // 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) { + for boundary in line_wrapper.wrap_line(&[LineFragment::text(&line)], wrap_width) { line_wraps.push(prev_boundary_ix..boundary.ix); prev_boundary_ix = boundary.ix; } @@ -95,6 +105,7 @@ impl TextWrapper { wrapped_lines.push(prev_line_ix + prev_boundary_ix..prev_line_ix + line.len()); } + // +1 for \n prev_line_ix += line.len() + 1; } diff --git a/crates/ui/src/text/node.rs b/crates/ui/src/text/node.rs index da266144..46ea1232 100644 --- a/crates/ui/src/text/node.rs +++ b/crates/ui/src/text/node.rs @@ -7,7 +7,7 @@ use gpui::{ StatefulInteractiveElement, Styled, StyledImage as _, Window, }; use markdown::mdast; -use ropey::Rope; +use rope::Rope; use crate::{ h_flex, @@ -292,7 +292,7 @@ impl CodeBlock { let mut styles = vec![]; if let Some(lang) = &lang { let mut highlighter = SyntaxHighlighter::new(&lang, cx); - highlighter.update(None, &Rope::from_str(code.as_str()), cx); + highlighter.update(None, &Rope::from(code.as_str()), cx); styles = highlighter.styles(&(0..code.len()), &theme); };