Delete Thai combining marks one at a time in the composer (#38662)

## Why

Backspace should let users remove Thai vowel and tone marks without deleting the
entire grapheme cluster.

## What changed

- Treat Thai nonspacing marks as individual backward-deletion boundaries.
- Keep embedded text elements atomic and preserve the existing behavior for
  other grapheme clusters, including decomposed Latin text and joined emoji.

## Testing

Add coverage for successive deletion of Thai marks, cursor rendering after
deletion, unchanged non-Thai grapheme handling, and atomic Thai text elements.

GitOrigin-RevId: f4834183909d93f4b4dd92f5d93e89235dc2b621
This commit is contained in:
kevinlin-openai
2026-08-14 22:35:23 +00:00
committed by copyberry
parent 848cbad7f4
commit 274727d37f
2 changed files with 79 additions and 1 deletions
@@ -0,0 +1,6 @@
---
source: tui/src/bottom_pane/textarea.rs
expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())"
---
cursor: Some((1, 0))
"ที "
+73 -1
View File
@@ -1007,7 +1007,22 @@ impl TextArea {
}
let mut target = self.cursor_pos;
for _ in 0..n {
target = self.prev_atomic_boundary(target);
if let Some((boundary, ch)) = self.text[..target].char_indices().next_back()
&& matches!(
ch,
// Thai-only special casing is not ideal; refactor if it becomes more complex.
// All Thai nonspacing marks: vowel signs, tone marks, and other diacritics.
'\u{0e31}' | '\u{0e34}'..='\u{0e3a}' | '\u{0e47}'..='\u{0e4e}'
)
&& !self
.elements
.iter()
.any(|element| target > element.range.start && target <= element.range.end)
{
target = boundary;
} else {
target = self.prev_atomic_boundary(target);
}
if target == 0 {
break;
}
@@ -2245,6 +2260,63 @@ mod tests {
assert_eq!(t.text(), "b");
}
#[test]
fn delete_backward_removes_thai_marks_one_at_a_time() {
let mut t = ta_with("ที่");
t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
let area = Rect::new(0, 0, /*width*/ 8, /*height*/ 1);
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
let mut state = TextAreaState::default();
terminal
.draw(|frame| {
StatefulWidgetRef::render_ref(&(&t), frame.area(), frame.buffer_mut(), &mut state);
})
.unwrap();
insta::assert_snapshot!(
"textarea_thai_backspace_preserves_remaining_marks",
format!("cursor: {:?}\n{}", t.cursor_pos(area), terminal.backend())
);
assert_eq!(t.text(), "ที");
t.delete_backward(/*n*/ 1);
assert_eq!(t.text(), "ท");
t.delete_backward(/*n*/ 1);
assert_eq!(t.text(), "");
}
#[test]
fn delete_backward_preserves_other_grapheme_behavior() {
let mut thai_spacing_vowel = ta_with("ซ้ำ");
thai_spacing_vowel.delete_backward(/*n*/ 1);
assert_eq!(thai_spacing_vowel.text(), "ซ้");
let mut decomposed_latin = ta_with("e\u{301}");
decomposed_latin.delete_backward(/*n*/ 1);
assert_eq!(decomposed_latin.text(), "");
let mut family_emoji = ta_with("👨\u{200d}👩\u{200d}👧\u{200d}👦");
family_emoji.delete_backward(/*n*/ 1);
assert_eq!(family_emoji.text(), "");
}
#[test]
fn delete_backward_keeps_thai_elements_atomic() {
let mut t = TextArea::new();
t.insert_str("before ");
t.insert_element("ที่");
t.insert_str(" after");
t.set_cursor(t.elements[0].range.end);
t.delete_backward(/*n*/ 1);
assert_eq!(t.text(), "before after");
assert_eq!(t.cursor(), "before ".len());
assert!(t.elements.is_empty());
}
#[test]
fn delete_forward_deletes_element_at_left_edge() {
let mut t = TextArea::new();