From a3f578ebac098bf4649b067beb34033aa7ad5fab Mon Sep 17 00:00:00 2001 From: Priec Date: Tue, 19 Aug 2025 00:03:04 +0200 Subject: [PATCH] using ropey for textarea --- Cargo.lock | 28 +++- canvas/Cargo.toml | 5 +- canvas/src/textarea/provider.rs | 242 +++++++++++++++++++++++--------- 3 files changed, 206 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1b5306..3664fe1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -477,8 +477,10 @@ dependencies = [ "anyhow", "async-trait", "crossterm", + "once_cell", "ratatui", "regex", + "ropey", "serde", "thiserror", "tokio", @@ -994,7 +996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -2763,6 +2765,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ropey" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +dependencies = [ + "smallvec", + "str_indices", +] + [[package]] name = "rsa" version = "0.9.8" @@ -2880,7 +2892,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2893,7 +2905,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3557,6 +3569,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + [[package]] name = "stringprep" version = "0.1.5" @@ -3803,7 +3821,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix 1.0.8", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4484,7 +4502,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/canvas/Cargo.toml b/canvas/Cargo.toml index d82199d..13b6830 100644 --- a/canvas/Cargo.toml +++ b/canvas/Cargo.toml @@ -23,6 +23,8 @@ tracing = "0.1.41" tracing-subscriber = "0.3.19" async-trait.workspace = true regex = { workspace = true, optional = true } +ropey = { version = "1.6.1", optional = true } +once_cell = "1.21.3" [dev-dependencies] tokio-test = "0.4.4" @@ -34,7 +36,7 @@ suggestions = ["tokio"] cursor-style = ["crossterm"] validation = ["regex"] computed = [] -textarea = ["gui"] +textarea = ["dep:ropey","gui"] # text modes (mutually exclusive; default to vim) textmode-vim = [] @@ -48,6 +50,7 @@ all-nontextmodes = [ "computed", "textarea" ] +ropey = ["dep:ropey"] [[example]] name = "suggestions" diff --git a/canvas/src/textarea/provider.rs b/canvas/src/textarea/provider.rs index 59c9040..e275b75 100644 --- a/canvas/src/textarea/provider.rs +++ b/canvas/src/textarea/provider.rs @@ -1,121 +1,218 @@ // src/textarea/provider.rs use crate::DataProvider; +use once_cell::unsync::OnceCell; +use ropey::Rope; +use std::io::{self, BufReader, Read}; +use std::path::Path; -#[derive(Debug, Clone)] +#[derive(Debug)] // Clone removed: OnceCell is not Clone pub struct TextAreaProvider { - lines: Vec, + rope: Rope, name: String, + // Lazy per-line cache; only lines that are actually used get materialized. + // This keeps memory low even for very large files. + line_cache: Vec>, } impl Default for TextAreaProvider { fn default() -> Self { + let rope = Rope::from_str(""); Self { - lines: vec![String::new()], + rope, name: "Text".to_string(), + line_cache: vec![OnceCell::new()], // at least 1 logical line } } } impl TextAreaProvider { pub fn from_text>(text: S) -> Self { - let text = text.into(); - let mut lines: Vec = - text.split('\n').map(|s| s.to_string()).collect(); - if lines.is_empty() { - lines.push(String::new()); - } + let s = text.into(); + let rope = Rope::from_str(&s); + let lines = rope.len_lines().max(1); Self { - lines, + rope, name: "Text".to_string(), + line_cache: vec![(); lines].into_iter().map(|_| OnceCell::new()).collect(), } } pub fn to_text(&self) -> String { - self.lines.join("\n") + self.rope.to_string() + } + + pub fn from_file>(path: P) -> io::Result { + let f = std::fs::File::open(path)?; + let mut reader = BufReader::new(f); + Self::from_reader(&mut reader) + } + + pub fn from_reader(reader: &mut R) -> io::Result { + let rope = Rope::from_reader(reader)?; + let lines = rope.len_lines().max(1); + Ok(Self { + rope, + name: "Text".to_string(), + line_cache: vec![(); lines].into_iter().map(|_| OnceCell::new()).collect(), + }) } pub fn set_text>(&mut self, text: S) { - let text = text.into(); - self.lines = text.split('\n').map(|s| s.to_string()).collect(); - if self.lines.is_empty() { - self.lines.push(String::new()); - } + let s = text.into(); + self.rope = Rope::from_str(&s); + self.resize_cache(); + self.invalidate_cache_from(0); } pub fn line_count(&self) -> usize { - self.lines.len() + self.rope.len_lines().max(1) + } + + fn resize_cache(&mut self) { + let want = self.line_count(); + if self.line_cache.len() < want { + self.line_cache + .extend((0..(want - self.line_cache.len())).map(|_| OnceCell::new())); + } else if self.line_cache.len() > want { + self.line_cache.truncate(want); + } + } + + fn invalidate_cache_from(&mut self, line_idx: usize) { + self.resize_cache(); + if line_idx < self.line_cache.len() { + for cell in &mut self.line_cache[line_idx..] { + let _ = cell.take(); + } + } } #[inline] - fn char_to_byte_index(s: &str, char_idx: usize) -> usize { - s.char_indices() - .nth(char_idx) - .map(|(i, _)| i) - .unwrap_or_else(|| s.len()) + fn line_bounds_chars(&self, line_idx: usize) -> (usize, usize) { + // Returns [start, end) in char indices for content only (excluding newline). + let total_lines = self.line_count(); + let start = self.rope.line_to_char(line_idx); + let end_exclusive = if line_idx + 1 < total_lines { + // Next line start is at the char index right after the newline. + // Exclude the newline itself by not including it in the range. + self.rope.line_to_char(line_idx + 1) - 1 + } else { + self.rope.len_chars() + }; + (start, end_exclusive) } - pub fn split_line_at(&mut self, line_idx: usize, at_char: usize) -> usize { - if line_idx >= self.lines.len() { - return self.lines.len().saturating_sub(1); + fn line_content_len_chars(&self, line_idx: usize) -> usize { + let slice = self.rope.line(line_idx); + let mut len = slice.len_chars(); + if line_idx + 1 < self.line_count() && len > 0 { + // Non-final lines include a trailing '\n' char in rope; exclude it. + len -= 1; } - let line = &mut self.lines[line_idx]; - let byte_idx = Self::char_to_byte_index(line, at_char); - let right = line[byte_idx..].to_string(); - line.truncate(byte_idx); - let insert_at = line_idx + 1; - self.lines.insert(insert_at, right); - insert_at + len } + fn compute_line_string(&self, index: usize) -> String { + let mut s = self.rope.line(index).to_string(); + // Trim trailing newline/CR if present (for non-final lines) + if s.ends_with('\n') { + s.pop(); + if s.ends_with('\r') { + s.pop(); + } + } + s + } + + // -------------------------- + // Editing helpers for TextAreaState (unchanged API) + // -------------------------- + + /// Split line at a character offset (within that line). + /// Returns the index of the newly created line (line_idx + 1). + pub fn split_line_at(&mut self, line_idx: usize, at_char: usize) -> usize { + let lines = self.line_count(); + let clamped_line = line_idx.min(lines.saturating_sub(1)); + let (start, end) = self.line_bounds_chars(clamped_line); + let line_len = end.saturating_sub(start); + let at = at_char.min(line_len); + + let insert_at = start + at; + self.rope.insert(insert_at, "\n"); // rope insert at char index + + self.resize_cache(); + self.invalidate_cache_from(clamped_line); + clamped_line + 1 + } + + /// Join current line with the next by removing the newline. + /// Returns Some(new_cursor_col_on_merged_line) or None if no next line. pub fn join_with_next(&mut self, line_idx: usize) -> Option { - if line_idx + 1 >= self.lines.len() { + if line_idx + 1 >= self.line_count() { return None; } - let left_len = self.lines[line_idx].chars().count(); - let right = self.lines.remove(line_idx + 1); - self.lines[line_idx].push_str(&right); + let newline_pos = self.rope.line_to_char(line_idx + 1) - 1; // index of '\n' + let left_len = self.line_content_len_chars(line_idx); + self.rope.remove(newline_pos..newline_pos + 1); // remove the newline + + self.resize_cache(); + self.invalidate_cache_from(line_idx); Some(left_len) } - pub fn join_with_prev( - &mut self, - line_idx: usize, - ) -> Option<(usize, usize)> { - if line_idx == 0 || line_idx >= self.lines.len() { + /// Join current line with the previous by removing the previous newline. + /// Returns Some((new_prev_index, cursor_col)) or None if at line 0. + pub fn join_with_prev(&mut self, line_idx: usize) -> Option<(usize, usize)> { + if line_idx == 0 || line_idx >= self.line_count() { return None; } let prev_idx = line_idx - 1; - let prev_len = self.lines[prev_idx].chars().count(); - let curr = self.lines.remove(line_idx); - self.lines[prev_idx].push_str(&curr); + let prev_len = self.line_content_len_chars(prev_idx); + let newline_pos = self.rope.line_to_char(line_idx) - 1; // index of '\n' before current line + self.rope.remove(newline_pos..newline_pos + 1); + + self.resize_cache(); + self.invalidate_cache_from(prev_idx); Some((prev_idx, prev_len)) } - pub fn insert_blank_line_after(&mut self, idx: usize) -> usize { - let clamped = idx.min(self.lines.len()); - let insert_at = if clamped >= self.lines.len() { - self.lines.len() + /// Insert an empty line after given index. + /// Returns the index of the inserted blank line (line_idx + 1). + pub fn insert_blank_line_after(&mut self, line_idx: usize) -> usize { + let lines = self.line_count(); + let clamped = line_idx.min(lines.saturating_sub(1)); + let pos = if clamped + 1 < lines { + self.rope.line_to_char(clamped + 1) } else { - clamped + 1 + self.rope.len_chars() }; - if insert_at == self.lines.len() { - self.lines.push(String::new()); - } else { - self.lines.insert(insert_at, String::new()); - } - insert_at + self.rope.insert(pos, "\n"); + + self.resize_cache(); + self.invalidate_cache_from(clamped); + clamped + 1 } - pub fn insert_blank_line_before(&mut self, idx: usize) -> usize { - let insert_at = idx.min(self.lines.len()); - self.lines.insert(insert_at, String::new()); - insert_at + /// Insert an empty line before given index. + /// Returns the index of the inserted blank line (line_idx). + pub fn insert_blank_line_before(&mut self, line_idx: usize) -> usize { + let clamped = line_idx.min(self.line_count()); + let pos = if clamped < self.line_count() { + self.rope.line_to_char(clamped) + } else { + self.rope.len_chars() + }; + self.rope.insert(pos, "\n"); + + self.resize_cache(); + self.invalidate_cache_from(clamped); + clamped } } impl DataProvider for TextAreaProvider { fn field_count(&self) -> usize { - self.lines.len() + self.line_count() } fn field_name(&self, _index: usize) -> &str { @@ -123,12 +220,31 @@ impl DataProvider for TextAreaProvider { } fn field_value(&self, index: usize) -> &str { - self.lines.get(index).map(|s| s.as_str()).unwrap_or("") + if index >= self.line_cache.len() { + return ""; + } + let cell = &self.line_cache[index]; + // Fill lazily on first read, from &self (no &mut needed). + let s_ref = cell.get_or_init(|| self.compute_line_string(index)); + s_ref.as_str() } fn set_field_value(&mut self, index: usize, value: String) { - if index < self.lines.len() { - self.lines[index] = value; + if index >= self.line_count() { + return; + } + // Enforce single-line invariant: strip embedded newlines + let clean = value.replace('\n', ""); + + let (start, end) = self.line_bounds_chars(index); + self.rope.remove(start..end); + self.rope.insert(start, &clean); + + self.resize_cache(); + if index < self.line_cache.len() { + // Replace this line’s cached string only; other lines unchanged + let _ = self.line_cache[index].take(); + let _ = self.line_cache[index].set(clean); } } }