From 274727d37fa69f113d28be4e0654deddcdef4318 Mon Sep 17 00:00:00 2001 From: kevinlin-openai Date: Fri, 14 Aug 2026 22:30:19 +0000 Subject: [PATCH] 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 --- ...i_backspace_preserves_remaining_marks.snap | 6 ++ codex-rs/tui/src/bottom_pane/textarea.rs | 74 ++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_thai_backspace_preserves_remaining_marks.snap diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_thai_backspace_preserves_remaining_marks.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_thai_backspace_preserves_remaining_marks.snap new file mode 100644 index 0000000000..4d276f81c5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_thai_backspace_preserves_remaining_marks.snap @@ -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)) +"ที " diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 95ef9ddc82..d821477fc0 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -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();