using ropey for textarea
This commit is contained in:
28
Cargo.lock
generated
28
Cargo.lock
generated
@@ -477,8 +477,10 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
|
"once_cell",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"regex",
|
"regex",
|
||||||
|
"ropey",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -994,7 +996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
|
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2763,6 +2765,16 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"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]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.9.8"
|
version = "0.9.8"
|
||||||
@@ -2880,7 +2892,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.4.15",
|
"linux-raw-sys 0.4.15",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2893,7 +2905,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.9.4",
|
"linux-raw-sys 0.9.4",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3557,6 +3569,12 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "str_indices"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stringprep"
|
name = "stringprep"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -3803,7 +3821,7 @@ dependencies = [
|
|||||||
"getrandom 0.3.3",
|
"getrandom 0.3.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.0.8",
|
"rustix 1.0.8",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4484,7 +4502,7 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ tracing = "0.1.41"
|
|||||||
tracing-subscriber = "0.3.19"
|
tracing-subscriber = "0.3.19"
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
regex = { workspace = true, optional = true }
|
regex = { workspace = true, optional = true }
|
||||||
|
ropey = { version = "1.6.1", optional = true }
|
||||||
|
once_cell = "1.21.3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = "0.4.4"
|
tokio-test = "0.4.4"
|
||||||
@@ -34,7 +36,7 @@ suggestions = ["tokio"]
|
|||||||
cursor-style = ["crossterm"]
|
cursor-style = ["crossterm"]
|
||||||
validation = ["regex"]
|
validation = ["regex"]
|
||||||
computed = []
|
computed = []
|
||||||
textarea = ["gui"]
|
textarea = ["dep:ropey","gui"]
|
||||||
|
|
||||||
# text modes (mutually exclusive; default to vim)
|
# text modes (mutually exclusive; default to vim)
|
||||||
textmode-vim = []
|
textmode-vim = []
|
||||||
@@ -48,6 +50,7 @@ all-nontextmodes = [
|
|||||||
"computed",
|
"computed",
|
||||||
"textarea"
|
"textarea"
|
||||||
]
|
]
|
||||||
|
ropey = ["dep:ropey"]
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "suggestions"
|
name = "suggestions"
|
||||||
|
|||||||
@@ -1,121 +1,218 @@
|
|||||||
// src/textarea/provider.rs
|
// src/textarea/provider.rs
|
||||||
use crate::DataProvider;
|
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 {
|
pub struct TextAreaProvider {
|
||||||
lines: Vec<String>,
|
rope: Rope,
|
||||||
name: String,
|
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 {
|
impl Default for TextAreaProvider {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
let rope = Rope::from_str("");
|
||||||
Self {
|
Self {
|
||||||
lines: vec![String::new()],
|
rope,
|
||||||
name: "Text".to_string(),
|
name: "Text".to_string(),
|
||||||
|
line_cache: vec![OnceCell::new()], // at least 1 logical line
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextAreaProvider {
|
impl TextAreaProvider {
|
||||||
pub fn from_text<S: Into<String>>(text: S) -> Self {
|
pub fn from_text<S: Into<String>>(text: S) -> Self {
|
||||||
let text = text.into();
|
let s = text.into();
|
||||||
let mut lines: Vec<String> =
|
let rope = Rope::from_str(&s);
|
||||||
text.split('\n').map(|s| s.to_string()).collect();
|
let lines = rope.len_lines().max(1);
|
||||||
if lines.is_empty() {
|
|
||||||
lines.push(String::new());
|
|
||||||
}
|
|
||||||
Self {
|
Self {
|
||||||
lines,
|
rope,
|
||||||
name: "Text".to_string(),
|
name: "Text".to_string(),
|
||||||
|
line_cache: vec![(); lines].into_iter().map(|_| OnceCell::new()).collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_text(&self) -> String {
|
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) {
|
pub fn set_text<S: Into<String>>(&mut self, text: S) {
|
||||||
let text = text.into();
|
let s = text.into();
|
||||||
self.lines = text.split('\n').map(|s| s.to_string()).collect();
|
self.rope = Rope::from_str(&s);
|
||||||
if self.lines.is_empty() {
|
self.resize_cache();
|
||||||
self.lines.push(String::new());
|
self.invalidate_cache_from(0);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn line_count(&self) -> usize {
|
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]
|
#[inline]
|
||||||
fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
|
fn line_bounds_chars(&self, line_idx: usize) -> (usize, usize) {
|
||||||
s.char_indices()
|
// Returns [start, end) in char indices for content only (excluding newline).
|
||||||
.nth(char_idx)
|
let total_lines = self.line_count();
|
||||||
.map(|(i, _)| i)
|
let start = self.rope.line_to_char(line_idx);
|
||||||
.unwrap_or_else(|| s.len())
|
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 {
|
fn line_content_len_chars(&self, line_idx: usize) -> usize {
|
||||||
if line_idx >= self.lines.len() {
|
let slice = self.rope.line(line_idx);
|
||||||
return self.lines.len().saturating_sub(1);
|
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];
|
len
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
let left_len = self.lines[line_idx].chars().count();
|
let newline_pos = self.rope.line_to_char(line_idx + 1) - 1; // index of '\n'
|
||||||
let right = self.lines.remove(line_idx + 1);
|
let left_len = self.line_content_len_chars(line_idx);
|
||||||
self.lines[line_idx].push_str(&right);
|
self.rope.remove(newline_pos..newline_pos + 1); // remove the newline
|
||||||
|
|
||||||
|
self.resize_cache();
|
||||||
|
self.invalidate_cache_from(line_idx);
|
||||||
Some(left_len)
|
Some(left_len)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn join_with_prev(
|
/// Join current line with the previous by removing the previous newline.
|
||||||
&mut self,
|
/// Returns Some((new_prev_index, cursor_col)) or None if at line 0.
|
||||||
line_idx: usize,
|
pub fn join_with_prev(&mut self, line_idx: usize) -> Option<(usize, usize)> {
|
||||||
) -> Option<(usize, usize)> {
|
if line_idx == 0 || line_idx >= self.line_count() {
|
||||||
if line_idx == 0 || line_idx >= self.lines.len() {
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let prev_idx = line_idx - 1;
|
let prev_idx = line_idx - 1;
|
||||||
let prev_len = self.lines[prev_idx].chars().count();
|
let prev_len = self.line_content_len_chars(prev_idx);
|
||||||
let curr = self.lines.remove(line_idx);
|
let newline_pos = self.rope.line_to_char(line_idx) - 1; // index of '\n' before current line
|
||||||
self.lines[prev_idx].push_str(&curr);
|
self.rope.remove(newline_pos..newline_pos + 1);
|
||||||
|
|
||||||
|
self.resize_cache();
|
||||||
|
self.invalidate_cache_from(prev_idx);
|
||||||
Some((prev_idx, prev_len))
|
Some((prev_idx, prev_len))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn insert_blank_line_after(&mut self, idx: usize) -> usize {
|
/// Insert an empty line after given index.
|
||||||
let clamped = idx.min(self.lines.len());
|
/// Returns the index of the inserted blank line (line_idx + 1).
|
||||||
let insert_at = if clamped >= self.lines.len() {
|
pub fn insert_blank_line_after(&mut self, line_idx: usize) -> usize {
|
||||||
self.lines.len()
|
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 {
|
} else {
|
||||||
clamped + 1
|
self.rope.len_chars()
|
||||||
};
|
};
|
||||||
if insert_at == self.lines.len() {
|
self.rope.insert(pos, "\n");
|
||||||
self.lines.push(String::new());
|
|
||||||
} else {
|
self.resize_cache();
|
||||||
self.lines.insert(insert_at, String::new());
|
self.invalidate_cache_from(clamped);
|
||||||
}
|
clamped + 1
|
||||||
insert_at
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn insert_blank_line_before(&mut self, idx: usize) -> usize {
|
/// Insert an empty line before given index.
|
||||||
let insert_at = idx.min(self.lines.len());
|
/// Returns the index of the inserted blank line (line_idx).
|
||||||
self.lines.insert(insert_at, String::new());
|
pub fn insert_blank_line_before(&mut self, line_idx: usize) -> usize {
|
||||||
insert_at
|
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 {
|
impl DataProvider for TextAreaProvider {
|
||||||
fn field_count(&self) -> usize {
|
fn field_count(&self) -> usize {
|
||||||
self.lines.len()
|
self.line_count()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn field_name(&self, _index: usize) -> &str {
|
fn field_name(&self, _index: usize) -> &str {
|
||||||
@@ -123,12 +220,31 @@ impl DataProvider for TextAreaProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn field_value(&self, index: usize) -> &str {
|
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) {
|
fn set_field_value(&mut self, index: usize, value: String) {
|
||||||
if index < self.lines.len() {
|
if index >= self.line_count() {
|
||||||
self.lines[index] = value;
|
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