using ropey for textarea
This commit is contained in:
28
Cargo.lock
generated
28
Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String> is not Clone
|
||||
pub struct TextAreaProvider {
|
||||
lines: Vec<String>,
|
||||
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<OnceCell<String>>,
|
||||
}
|
||||
|
||||
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<S: Into<String>>(text: S) -> Self {
|
||||
let text = text.into();
|
||||
let mut lines: Vec<String> =
|
||||
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<P: AsRef<Path>>(path: P) -> io::Result<Self> {
|
||||
let f = std::fs::File::open(path)?;
|
||||
let mut reader = BufReader::new(f);
|
||||
Self::from_reader(&mut reader)
|
||||
}
|
||||
|
||||
pub fn from_reader<R: Read>(reader: &mut R) -> io::Result<Self> {
|
||||
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<S: Into<String>>(&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<usize> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user