text elements in textarea for pasted content (#2302)
This improves handling of pasted content in the textarea. It's no longer possible to partially delete a placeholder (e.g. by ^W or ^D), nor is it possible to place the cursor inside a placeholder. Also, we now render placeholders in a different color to make them more clearly differentiated. https://github.com/user-attachments/assets/2051b3c3-963d-4781-a610-3afee522ae29
This commit is contained in:
@@ -166,7 +166,7 @@ impl ChatComposer {
|
|||||||
let char_count = pasted.chars().count();
|
let char_count = pasted.chars().count();
|
||||||
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
|
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
|
||||||
let placeholder = format!("[Pasted Content {char_count} chars]");
|
let placeholder = format!("[Pasted Content {char_count} chars]");
|
||||||
self.textarea.insert_str(&placeholder);
|
self.textarea.insert_element(&placeholder);
|
||||||
self.pending_pastes.push((placeholder, pasted));
|
self.pending_pastes.push((placeholder, pasted));
|
||||||
} else {
|
} else {
|
||||||
self.textarea.insert_str(&pasted);
|
self.textarea.insert_str(&pasted);
|
||||||
@@ -532,17 +532,6 @@ impl ChatComposer {
|
|||||||
|
|
||||||
/// Handle generic Input events that modify the textarea content.
|
/// Handle generic Input events that modify the textarea content.
|
||||||
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
||||||
// Special handling for backspace on placeholders
|
|
||||||
if let KeyEvent {
|
|
||||||
code: KeyCode::Backspace,
|
|
||||||
..
|
|
||||||
} = input
|
|
||||||
{
|
|
||||||
if self.try_remove_placeholder_at_cursor() {
|
|
||||||
return (InputResult::None, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal input handling
|
// Normal input handling
|
||||||
self.textarea.input(input);
|
self.textarea.input(input);
|
||||||
let text_after = self.textarea.text();
|
let text_after = self.textarea.text();
|
||||||
@@ -554,34 +543,6 @@ impl ChatComposer {
|
|||||||
(InputResult::None, true)
|
(InputResult::None, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempts to remove a placeholder if the cursor is at the end of one.
|
|
||||||
/// Returns true if a placeholder was removed.
|
|
||||||
fn try_remove_placeholder_at_cursor(&mut self) -> bool {
|
|
||||||
let p = self.textarea.cursor();
|
|
||||||
let text = self.textarea.text();
|
|
||||||
|
|
||||||
// Find any placeholder that ends at the cursor position
|
|
||||||
let placeholder_to_remove = self.pending_pastes.iter().find_map(|(ph, _)| {
|
|
||||||
if p < ph.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let potential_ph_start = p - ph.len();
|
|
||||||
if text[potential_ph_start..p] == *ph {
|
|
||||||
Some(ph.clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(placeholder) = placeholder_to_remove {
|
|
||||||
self.textarea.replace_range(p - placeholder.len()..p, "");
|
|
||||||
self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Synchronize `self.command_popup` with the current text in the
|
/// Synchronize `self.command_popup` with the current text in the
|
||||||
/// textarea. This must be called after every modification that can change
|
/// textarea. This must be called after every modification that can change
|
||||||
/// the text so the popup is shown/updated/hidden as appropriate.
|
/// the text so the popup is shown/updated/hidden as appropriate.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use crossterm::event::KeyEvent;
|
|||||||
use crossterm::event::KeyModifiers;
|
use crossterm::event::KeyModifiers;
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::Color;
|
||||||
use ratatui::style::Style;
|
use ratatui::style::Style;
|
||||||
use ratatui::widgets::StatefulWidgetRef;
|
use ratatui::widgets::StatefulWidgetRef;
|
||||||
use ratatui::widgets::WidgetRef;
|
use ratatui::widgets::WidgetRef;
|
||||||
@@ -13,12 +14,18 @@ use textwrap::Options;
|
|||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct TextElement {
|
||||||
|
range: Range<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct TextArea {
|
pub(crate) struct TextArea {
|
||||||
text: String,
|
text: String,
|
||||||
cursor_pos: usize,
|
cursor_pos: usize,
|
||||||
wrap_cache: RefCell<Option<WrapCache>>,
|
wrap_cache: RefCell<Option<WrapCache>>,
|
||||||
preferred_col: Option<usize>,
|
preferred_col: Option<usize>,
|
||||||
|
elements: Vec<TextElement>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -40,6 +47,7 @@ impl TextArea {
|
|||||||
cursor_pos: 0,
|
cursor_pos: 0,
|
||||||
wrap_cache: RefCell::new(None),
|
wrap_cache: RefCell::new(None),
|
||||||
preferred_col: None,
|
preferred_col: None,
|
||||||
|
elements: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +56,7 @@ impl TextArea {
|
|||||||
self.cursor_pos = self.cursor_pos.clamp(0, self.text.len());
|
self.cursor_pos = self.cursor_pos.clamp(0, self.text.len());
|
||||||
self.wrap_cache.replace(None);
|
self.wrap_cache.replace(None);
|
||||||
self.preferred_col = None;
|
self.preferred_col = None;
|
||||||
|
self.elements.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn text(&self) -> &str {
|
pub fn text(&self) -> &str {
|
||||||
@@ -59,15 +68,22 @@ impl TextArea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn insert_str_at(&mut self, pos: usize, text: &str) {
|
pub fn insert_str_at(&mut self, pos: usize, text: &str) {
|
||||||
|
let pos = self.clamp_pos_for_insertion(pos);
|
||||||
self.text.insert_str(pos, text);
|
self.text.insert_str(pos, text);
|
||||||
self.wrap_cache.replace(None);
|
self.wrap_cache.replace(None);
|
||||||
if pos <= self.cursor_pos {
|
if pos <= self.cursor_pos {
|
||||||
self.cursor_pos += text.len();
|
self.cursor_pos += text.len();
|
||||||
}
|
}
|
||||||
|
self.shift_elements(pos, 0, text.len());
|
||||||
self.preferred_col = None;
|
self.preferred_col = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn replace_range(&mut self, range: std::ops::Range<usize>, text: &str) {
|
pub fn replace_range(&mut self, range: std::ops::Range<usize>, text: &str) {
|
||||||
|
let range = self.expand_range_to_element_boundaries(range);
|
||||||
|
self.replace_range_raw(range, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_range_raw(&mut self, range: std::ops::Range<usize>, text: &str) {
|
||||||
assert!(range.start <= range.end);
|
assert!(range.start <= range.end);
|
||||||
let start = range.start.clamp(0, self.text.len());
|
let start = range.start.clamp(0, self.text.len());
|
||||||
let end = range.end.clamp(0, self.text.len());
|
let end = range.end.clamp(0, self.text.len());
|
||||||
@@ -81,6 +97,7 @@ impl TextArea {
|
|||||||
self.text.replace_range(range, text);
|
self.text.replace_range(range, text);
|
||||||
self.wrap_cache.replace(None);
|
self.wrap_cache.replace(None);
|
||||||
self.preferred_col = None;
|
self.preferred_col = None;
|
||||||
|
self.update_elements_after_replace(start, end, inserted_len);
|
||||||
|
|
||||||
// Update the cursor position to account for the edit.
|
// Update the cursor position to account for the edit.
|
||||||
self.cursor_pos = if self.cursor_pos < start {
|
self.cursor_pos = if self.cursor_pos < start {
|
||||||
@@ -94,6 +111,9 @@ impl TextArea {
|
|||||||
((self.cursor_pos as isize) + diff) as usize
|
((self.cursor_pos as isize) + diff) as usize
|
||||||
}
|
}
|
||||||
.min(self.text.len());
|
.min(self.text.len());
|
||||||
|
|
||||||
|
// Ensure cursor is not inside an element
|
||||||
|
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cursor(&self) -> usize {
|
pub fn cursor(&self) -> usize {
|
||||||
@@ -102,6 +122,7 @@ impl TextArea {
|
|||||||
|
|
||||||
pub fn set_cursor(&mut self, pos: usize) {
|
pub fn set_cursor(&mut self, pos: usize) {
|
||||||
self.cursor_pos = pos.clamp(0, self.text.len());
|
self.cursor_pos = pos.clamp(0, self.text.len());
|
||||||
|
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
|
||||||
self.preferred_col = None;
|
self.preferred_col = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,10 +176,13 @@ impl TextArea {
|
|||||||
width_so_far += g.width();
|
width_so_far += g.width();
|
||||||
if width_so_far > target_col {
|
if width_so_far > target_col {
|
||||||
self.cursor_pos = line_start + i;
|
self.cursor_pos = line_start + i;
|
||||||
|
// Avoid landing inside an element; round to nearest boundary
|
||||||
|
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.cursor_pos = line_end;
|
self.cursor_pos = line_end;
|
||||||
|
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn beginning_of_line(&self, pos: usize) -> usize {
|
fn beginning_of_line(&self, pos: usize) -> usize {
|
||||||
@@ -178,30 +202,6 @@ impl TextArea {
|
|||||||
self.end_of_line(self.cursor_pos)
|
self.end_of_line(self.cursor_pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn beginning_of_previous_word(&self) -> usize {
|
|
||||||
if let Some(first_non_ws) = self.text[..self.cursor_pos].rfind(|c: char| !c.is_whitespace())
|
|
||||||
{
|
|
||||||
self.text[..first_non_ws]
|
|
||||||
.rfind(|c: char| c.is_whitespace())
|
|
||||||
.map(|i| i + 1)
|
|
||||||
.unwrap_or(0)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn end_of_next_word(&self) -> usize {
|
|
||||||
let Some(first_non_ws) = self.text[self.cursor_pos..].find(|c: char| !c.is_whitespace())
|
|
||||||
else {
|
|
||||||
return self.text.len();
|
|
||||||
};
|
|
||||||
let word_start = self.cursor_pos + first_non_ws;
|
|
||||||
match self.text[word_start..].find(|c: char| c.is_whitespace()) {
|
|
||||||
Some(rel_idx) => word_start + rel_idx,
|
|
||||||
None => self.text.len(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn input(&mut self, event: KeyEvent) {
|
pub fn input(&mut self, event: KeyEvent) {
|
||||||
match event {
|
match event {
|
||||||
KeyEvent {
|
KeyEvent {
|
||||||
@@ -385,19 +385,11 @@ impl TextArea {
|
|||||||
if n == 0 || self.cursor_pos == 0 {
|
if n == 0 || self.cursor_pos == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut gc =
|
|
||||||
unicode_segmentation::GraphemeCursor::new(self.cursor_pos, self.text.len(), false);
|
|
||||||
let mut target = self.cursor_pos;
|
let mut target = self.cursor_pos;
|
||||||
for _ in 0..n {
|
for _ in 0..n {
|
||||||
match gc.prev_boundary(&self.text, 0) {
|
target = self.prev_atomic_boundary(target);
|
||||||
Ok(Some(b)) => target = b,
|
if target == 0 {
|
||||||
Ok(None) => {
|
break;
|
||||||
target = 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
target = target.saturating_sub(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.replace_range(target..self.cursor_pos, "");
|
self.replace_range(target..self.cursor_pos, "");
|
||||||
@@ -407,26 +399,19 @@ impl TextArea {
|
|||||||
if n == 0 || self.cursor_pos >= self.text.len() {
|
if n == 0 || self.cursor_pos >= self.text.len() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut gc =
|
|
||||||
unicode_segmentation::GraphemeCursor::new(self.cursor_pos, self.text.len(), false);
|
|
||||||
let mut target = self.cursor_pos;
|
let mut target = self.cursor_pos;
|
||||||
for _ in 0..n {
|
for _ in 0..n {
|
||||||
match gc.next_boundary(&self.text, 0) {
|
target = self.next_atomic_boundary(target);
|
||||||
Ok(Some(b)) => target = b,
|
if target >= self.text.len() {
|
||||||
Ok(None) => {
|
break;
|
||||||
target = self.text.len();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
target = target.saturating_add(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.replace_range(self.cursor_pos..target, "");
|
self.replace_range(self.cursor_pos..target, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_backward_word(&mut self) {
|
pub fn delete_backward_word(&mut self) {
|
||||||
self.replace_range(self.beginning_of_previous_word()..self.cursor_pos, "");
|
let start = self.beginning_of_previous_word();
|
||||||
|
self.replace_range(start..self.cursor_pos, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn kill_to_end_of_line(&mut self) {
|
pub fn kill_to_end_of_line(&mut self) {
|
||||||
@@ -453,25 +438,13 @@ impl TextArea {
|
|||||||
|
|
||||||
/// Move the cursor left by a single grapheme cluster.
|
/// Move the cursor left by a single grapheme cluster.
|
||||||
pub fn move_cursor_left(&mut self) {
|
pub fn move_cursor_left(&mut self) {
|
||||||
let mut gc =
|
self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos);
|
||||||
unicode_segmentation::GraphemeCursor::new(self.cursor_pos, self.text.len(), false);
|
|
||||||
match gc.prev_boundary(&self.text, 0) {
|
|
||||||
Ok(Some(boundary)) => self.cursor_pos = boundary,
|
|
||||||
Ok(None) => self.cursor_pos = 0, // Already at start.
|
|
||||||
Err(_) => self.cursor_pos = self.cursor_pos.saturating_sub(1),
|
|
||||||
}
|
|
||||||
self.preferred_col = None;
|
self.preferred_col = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move the cursor right by a single grapheme cluster.
|
/// Move the cursor right by a single grapheme cluster.
|
||||||
pub fn move_cursor_right(&mut self) {
|
pub fn move_cursor_right(&mut self) {
|
||||||
let mut gc =
|
self.cursor_pos = self.next_atomic_boundary(self.cursor_pos);
|
||||||
unicode_segmentation::GraphemeCursor::new(self.cursor_pos, self.text.len(), false);
|
|
||||||
match gc.next_boundary(&self.text, 0) {
|
|
||||||
Ok(Some(boundary)) => self.cursor_pos = boundary,
|
|
||||||
Ok(None) => self.cursor_pos = self.text.len(), // Already at end.
|
|
||||||
Err(_) => self.cursor_pos = self.cursor_pos.saturating_add(1),
|
|
||||||
}
|
|
||||||
self.preferred_col = None;
|
self.preferred_col = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,6 +599,208 @@ impl TextArea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Text elements support =====
|
||||||
|
|
||||||
|
pub fn insert_element(&mut self, text: &str) {
|
||||||
|
let start = self.clamp_pos_for_insertion(self.cursor_pos);
|
||||||
|
self.insert_str_at(start, text);
|
||||||
|
let end = start + text.len();
|
||||||
|
self.add_element(start..end);
|
||||||
|
// Place cursor at end of inserted element
|
||||||
|
self.set_cursor(end);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_element(&mut self, range: Range<usize>) {
|
||||||
|
let elem = TextElement {
|
||||||
|
range: range.clone(),
|
||||||
|
};
|
||||||
|
self.elements.push(elem);
|
||||||
|
self.elements.sort_by_key(|e| e.range.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_element_containing(&self, pos: usize) -> Option<usize> {
|
||||||
|
self.elements
|
||||||
|
.iter()
|
||||||
|
.position(|e| pos > e.range.start && pos < e.range.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_pos_to_nearest_boundary(&self, mut pos: usize) -> usize {
|
||||||
|
if pos > self.text.len() {
|
||||||
|
pos = self.text.len();
|
||||||
|
}
|
||||||
|
if let Some(idx) = self.find_element_containing(pos) {
|
||||||
|
let e = &self.elements[idx];
|
||||||
|
let dist_start = pos.saturating_sub(e.range.start);
|
||||||
|
let dist_end = e.range.end.saturating_sub(pos);
|
||||||
|
if dist_start <= dist_end {
|
||||||
|
e.range.start
|
||||||
|
} else {
|
||||||
|
e.range.end
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_pos_for_insertion(&self, pos: usize) -> usize {
|
||||||
|
// Do not allow inserting into the middle of an element
|
||||||
|
if let Some(idx) = self.find_element_containing(pos) {
|
||||||
|
let e = &self.elements[idx];
|
||||||
|
// Choose closest edge for insertion
|
||||||
|
let dist_start = pos.saturating_sub(e.range.start);
|
||||||
|
let dist_end = e.range.end.saturating_sub(pos);
|
||||||
|
if dist_start <= dist_end {
|
||||||
|
e.range.start
|
||||||
|
} else {
|
||||||
|
e.range.end
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_range_to_element_boundaries(&self, mut range: Range<usize>) -> Range<usize> {
|
||||||
|
// Expand to include any intersecting elements fully
|
||||||
|
loop {
|
||||||
|
let mut changed = false;
|
||||||
|
for e in &self.elements {
|
||||||
|
if e.range.start < range.end && e.range.end > range.start {
|
||||||
|
let new_start = range.start.min(e.range.start);
|
||||||
|
let new_end = range.end.max(e.range.end);
|
||||||
|
if new_start != range.start || new_end != range.end {
|
||||||
|
range.start = new_start;
|
||||||
|
range.end = new_end;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
range
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shift_elements(&mut self, at: usize, removed: usize, inserted: usize) {
|
||||||
|
// Generic shift: for pure insert, removed = 0; for delete, inserted = 0.
|
||||||
|
let end = at + removed;
|
||||||
|
let diff = inserted as isize - removed as isize;
|
||||||
|
// Remove elements fully deleted by the operation and shift the rest
|
||||||
|
self.elements
|
||||||
|
.retain(|e| !(e.range.start >= at && e.range.end <= end));
|
||||||
|
for e in &mut self.elements {
|
||||||
|
if e.range.end <= at {
|
||||||
|
// before edit
|
||||||
|
} else if e.range.start >= end {
|
||||||
|
// after edit
|
||||||
|
e.range.start = ((e.range.start as isize) + diff) as usize;
|
||||||
|
e.range.end = ((e.range.end as isize) + diff) as usize;
|
||||||
|
} else {
|
||||||
|
// Overlap with element but not fully contained (shouldn't happen when using
|
||||||
|
// element-aware replace, but degrade gracefully by snapping element to new bounds)
|
||||||
|
let new_start = at.min(e.range.start);
|
||||||
|
let new_end = at + inserted.max(e.range.end.saturating_sub(end));
|
||||||
|
e.range.start = new_start;
|
||||||
|
e.range.end = new_end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_elements_after_replace(&mut self, start: usize, end: usize, inserted_len: usize) {
|
||||||
|
self.shift_elements(start, end.saturating_sub(start), inserted_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prev_atomic_boundary(&self, pos: usize) -> usize {
|
||||||
|
if pos == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// If currently at an element end or inside, jump to start of that element.
|
||||||
|
if let Some(idx) = self
|
||||||
|
.elements
|
||||||
|
.iter()
|
||||||
|
.position(|e| pos > e.range.start && pos <= e.range.end)
|
||||||
|
{
|
||||||
|
return self.elements[idx].range.start;
|
||||||
|
}
|
||||||
|
let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false);
|
||||||
|
match gc.prev_boundary(&self.text, 0) {
|
||||||
|
Ok(Some(b)) => {
|
||||||
|
if let Some(idx) = self.find_element_containing(b) {
|
||||||
|
self.elements[idx].range.start
|
||||||
|
} else {
|
||||||
|
b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => 0,
|
||||||
|
Err(_) => pos.saturating_sub(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_atomic_boundary(&self, pos: usize) -> usize {
|
||||||
|
if pos >= self.text.len() {
|
||||||
|
return self.text.len();
|
||||||
|
}
|
||||||
|
// If currently at an element start or inside, jump to end of that element.
|
||||||
|
if let Some(idx) = self
|
||||||
|
.elements
|
||||||
|
.iter()
|
||||||
|
.position(|e| pos >= e.range.start && pos < e.range.end)
|
||||||
|
{
|
||||||
|
return self.elements[idx].range.end;
|
||||||
|
}
|
||||||
|
let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false);
|
||||||
|
match gc.next_boundary(&self.text, 0) {
|
||||||
|
Ok(Some(b)) => {
|
||||||
|
if let Some(idx) = self.find_element_containing(b) {
|
||||||
|
self.elements[idx].range.end
|
||||||
|
} else {
|
||||||
|
b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => self.text.len(),
|
||||||
|
Err(_) => pos.saturating_add(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn beginning_of_previous_word(&self) -> usize {
|
||||||
|
if let Some(first_non_ws) = self.text[..self.cursor_pos].rfind(|c: char| !c.is_whitespace())
|
||||||
|
{
|
||||||
|
let candidate = self.text[..first_non_ws]
|
||||||
|
.rfind(|c: char| c.is_whitespace())
|
||||||
|
.map(|i| i + 1)
|
||||||
|
.unwrap_or(0);
|
||||||
|
self.adjust_pos_out_of_elements(candidate, true)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn end_of_next_word(&self) -> usize {
|
||||||
|
let Some(first_non_ws) = self.text[self.cursor_pos..].find(|c: char| !c.is_whitespace())
|
||||||
|
else {
|
||||||
|
return self.text.len();
|
||||||
|
};
|
||||||
|
let word_start = self.cursor_pos + first_non_ws;
|
||||||
|
let candidate = match self.text[word_start..].find(|c: char| c.is_whitespace()) {
|
||||||
|
Some(rel_idx) => word_start + rel_idx,
|
||||||
|
None => self.text.len(),
|
||||||
|
};
|
||||||
|
self.adjust_pos_out_of_elements(candidate, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize {
|
||||||
|
if let Some(idx) = self.find_element_containing(pos) {
|
||||||
|
let e = &self.elements[idx];
|
||||||
|
if prefer_start {
|
||||||
|
e.range.start
|
||||||
|
} else {
|
||||||
|
e.range.end
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::unwrap_used)]
|
#[allow(clippy::unwrap_used)]
|
||||||
fn wrapped_lines(&self, width: u16) -> Ref<'_, Vec<Range<usize>>> {
|
fn wrapped_lines(&self, width: u16) -> Ref<'_, Vec<Range<usize>>> {
|
||||||
// Ensure cache is ready (potentially mutably borrow, then drop)
|
// Ensure cache is ready (potentially mutably borrow, then drop)
|
||||||
@@ -700,10 +875,7 @@ impl TextArea {
|
|||||||
impl WidgetRef for &TextArea {
|
impl WidgetRef for &TextArea {
|
||||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||||
let lines = self.wrapped_lines(area.width);
|
let lines = self.wrapped_lines(area.width);
|
||||||
for (i, ls) in lines.iter().enumerate() {
|
self.render_lines(area, buf, &lines, 0..lines.len());
|
||||||
let s = &self.text[ls.start..ls.end - 1];
|
|
||||||
buf.set_string(area.x, area.y + i as u16, s, Style::default());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,10 +889,38 @@ impl StatefulWidgetRef for &TextArea {
|
|||||||
|
|
||||||
let start = scroll as usize;
|
let start = scroll as usize;
|
||||||
let end = (scroll + area.height).min(lines.len() as u16) as usize;
|
let end = (scroll + area.height).min(lines.len() as u16) as usize;
|
||||||
for (row, ls) in (start..end).enumerate() {
|
self.render_lines(area, buf, &lines, start..end);
|
||||||
let r = &lines[ls];
|
}
|
||||||
let s = &self.text[r.start..r.end - 1];
|
}
|
||||||
buf.set_string(area.x, area.y + row as u16, s, Style::default());
|
|
||||||
|
impl TextArea {
|
||||||
|
fn render_lines(
|
||||||
|
&self,
|
||||||
|
area: Rect,
|
||||||
|
buf: &mut Buffer,
|
||||||
|
lines: &[Range<usize>],
|
||||||
|
range: std::ops::Range<usize>,
|
||||||
|
) {
|
||||||
|
for (row, idx) in range.enumerate() {
|
||||||
|
let r = &lines[idx];
|
||||||
|
let y = area.y + row as u16;
|
||||||
|
let line_range = r.start..r.end - 1;
|
||||||
|
// Draw base line with default style.
|
||||||
|
buf.set_string(area.x, y, &self.text[line_range.clone()], Style::default());
|
||||||
|
|
||||||
|
// Overlay styled segments for elements that intersect this line.
|
||||||
|
for elem in &self.elements {
|
||||||
|
// Compute overlap with displayed slice.
|
||||||
|
let overlap_start = elem.range.start.max(line_range.start);
|
||||||
|
let overlap_end = elem.range.end.min(line_range.end);
|
||||||
|
if overlap_start >= overlap_end {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let styled = &self.text[overlap_start..overlap_end];
|
||||||
|
let x_off = self.text[line_range.start..overlap_start].width() as u16;
|
||||||
|
let style = Style::default().fg(Color::Cyan);
|
||||||
|
buf.set_string(area.x + x_off, y, styled, style);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1244,6 +1444,10 @@ mod tests {
|
|||||||
for _case in 0..10_000 {
|
for _case in 0..10_000 {
|
||||||
let mut ta = TextArea::new();
|
let mut ta = TextArea::new();
|
||||||
let mut state = TextAreaState::default();
|
let mut state = TextAreaState::default();
|
||||||
|
// Track element payloads we insert. Payloads use characters '[' and ']' which
|
||||||
|
// are not produced by rand_grapheme(), avoiding accidental collisions.
|
||||||
|
let mut elem_texts: Vec<String> = Vec::new();
|
||||||
|
let mut next_elem_id: usize = 0;
|
||||||
// Start with a random base string
|
// Start with a random base string
|
||||||
let base_len = rng.gen_range(0..30);
|
let base_len = rng.gen_range(0..30);
|
||||||
let mut base = String::new();
|
let mut base = String::new();
|
||||||
@@ -1271,7 +1475,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pick an operation
|
// Pick an operation
|
||||||
match rng.gen_range(0..14) {
|
match rng.gen_range(0..18) {
|
||||||
0 => {
|
0 => {
|
||||||
// insert small random string at cursor
|
// insert small random string at cursor
|
||||||
let len = rng.gen_range(0..6);
|
let len = rng.gen_range(0..6);
|
||||||
@@ -1299,12 +1503,24 @@ mod tests {
|
|||||||
s.push_str(&rand_grapheme(&mut rng));
|
s.push_str(&rand_grapheme(&mut rng));
|
||||||
}
|
}
|
||||||
let before = ta.text().len();
|
let before = ta.text().len();
|
||||||
|
// If the chosen range intersects an element, replace_range will expand to
|
||||||
|
// element boundaries, so the naive size delta assertion does not hold.
|
||||||
|
let intersects_element = elem_texts.iter().any(|payload| {
|
||||||
|
if let Some(pstart) = ta.text().find(payload) {
|
||||||
|
let pend = pstart + payload.len();
|
||||||
|
pstart < end && pend > start
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
});
|
||||||
ta.replace_range(start..end, &s);
|
ta.replace_range(start..end, &s);
|
||||||
let after = ta.text().len();
|
if !intersects_element {
|
||||||
assert_eq!(
|
let after = ta.text().len();
|
||||||
after as isize,
|
assert_eq!(
|
||||||
before as isize + (s.len() as isize) - ((end - start) as isize)
|
after as isize,
|
||||||
);
|
before as isize + (s.len() as isize) - ((end - start) as isize)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
2 => ta.delete_backward(rng.gen_range(0..=3)),
|
2 => ta.delete_backward(rng.gen_range(0..=3)),
|
||||||
3 => ta.delete_forward(rng.gen_range(0..=3)),
|
3 => ta.delete_forward(rng.gen_range(0..=3)),
|
||||||
@@ -1317,6 +1533,66 @@ mod tests {
|
|||||||
10 => ta.move_cursor_down(),
|
10 => ta.move_cursor_down(),
|
||||||
11 => ta.move_cursor_to_beginning_of_line(true),
|
11 => ta.move_cursor_to_beginning_of_line(true),
|
||||||
12 => ta.move_cursor_to_end_of_line(true),
|
12 => ta.move_cursor_to_end_of_line(true),
|
||||||
|
13 => {
|
||||||
|
// Insert an element with a unique sentinel payload
|
||||||
|
let payload =
|
||||||
|
format!("[[EL#{}:{}]]", next_elem_id, rng.gen_range(1000..9999));
|
||||||
|
next_elem_id += 1;
|
||||||
|
ta.insert_element(&payload);
|
||||||
|
elem_texts.push(payload);
|
||||||
|
}
|
||||||
|
14 => {
|
||||||
|
// Try inserting inside an existing element (should clamp to boundary)
|
||||||
|
if let Some(payload) = elem_texts.choose(&mut rng).cloned() {
|
||||||
|
if let Some(start) = ta.text().find(&payload) {
|
||||||
|
let end = start + payload.len();
|
||||||
|
if end - start > 2 {
|
||||||
|
let pos = rng.gen_range(start + 1..end - 1);
|
||||||
|
let ins = rand_grapheme(&mut rng);
|
||||||
|
ta.insert_str_at(pos, &ins);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
15 => {
|
||||||
|
// Replace a range that intersects an element -> whole element should be replaced
|
||||||
|
if let Some(payload) = elem_texts.choose(&mut rng).cloned() {
|
||||||
|
if let Some(start) = ta.text().find(&payload) {
|
||||||
|
let end = start + payload.len();
|
||||||
|
// Create an intersecting range [start-δ, end-δ2)
|
||||||
|
let mut s = start.saturating_sub(rng.gen_range(0..=2));
|
||||||
|
let mut e = (end + rng.gen_range(0..=2)).min(ta.text().len());
|
||||||
|
// Align to char boundaries to satisfy String::replace_range contract
|
||||||
|
let txt = ta.text();
|
||||||
|
while s > 0 && !txt.is_char_boundary(s) {
|
||||||
|
s -= 1;
|
||||||
|
}
|
||||||
|
while e < txt.len() && !txt.is_char_boundary(e) {
|
||||||
|
e += 1;
|
||||||
|
}
|
||||||
|
if s < e {
|
||||||
|
// Small replacement text
|
||||||
|
let mut srep = String::new();
|
||||||
|
for _ in 0..rng.gen_range(0..=2) {
|
||||||
|
srep.push_str(&rand_grapheme(&mut rng));
|
||||||
|
}
|
||||||
|
ta.replace_range(s..e, &srep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
16 => {
|
||||||
|
// Try setting the cursor to a position inside an element; it should clamp out
|
||||||
|
if let Some(payload) = elem_texts.choose(&mut rng).cloned() {
|
||||||
|
if let Some(start) = ta.text().find(&payload) {
|
||||||
|
let end = start + payload.len();
|
||||||
|
if end - start > 2 {
|
||||||
|
let pos = rng.gen_range(start + 1..end - 1);
|
||||||
|
ta.set_cursor(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Jump to word boundaries
|
// Jump to word boundaries
|
||||||
if rng.gen_bool(0.5) {
|
if rng.gen_bool(0.5) {
|
||||||
@@ -1332,6 +1608,21 @@ mod tests {
|
|||||||
// Sanity invariants
|
// Sanity invariants
|
||||||
assert!(ta.cursor() <= ta.text().len());
|
assert!(ta.cursor() <= ta.text().len());
|
||||||
|
|
||||||
|
// Element invariants
|
||||||
|
for payload in &elem_texts {
|
||||||
|
if let Some(start) = ta.text().find(payload) {
|
||||||
|
let end = start + payload.len();
|
||||||
|
// 1) Text inside elements matches the initially set payload
|
||||||
|
assert_eq!(&ta.text()[start..end], payload);
|
||||||
|
// 2) Cursor is never strictly inside an element
|
||||||
|
let c = ta.cursor();
|
||||||
|
assert!(
|
||||||
|
c <= start || c >= end,
|
||||||
|
"cursor inside element: {start}..{end} at {c}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render and compute cursor positions; ensure they are in-bounds and do not panic
|
// Render and compute cursor positions; ensure they are in-bounds and do not panic
|
||||||
let area = Rect::new(0, 0, width, height);
|
let area = Rect::new(0, 0, width, height);
|
||||||
// Stateless render into an area tall enough for all wrapped lines
|
// Stateless render into an area tall enough for all wrapped lines
|
||||||
|
|||||||
Reference in New Issue
Block a user