From 8989e7b253c8983c73901f8ecf25f5338a0df646 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:19:47 +0200 Subject: [PATCH] Text: Backspace removes Thai and other marks one at a time (#1220) Co-Authored-By: GPT-6 Luna --- crates/engine/src/text_behaviour_tests.rs | 5 ++++- crates/engine/src/textedit.rs | 12 ++++++++++- crates/text/src/behaviour_tests.rs | 12 +++++++++++ crates/text/src/breaks.rs | 25 ++++++++++++++++++++++- crates/text/src/lib.rs | 2 +- 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index b9a76fbc..ceaad8a7 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -60,11 +60,14 @@ fn arrows_and_deletes_step_over_whole_clusters() { key(&mut s, "Right", true, false, false).unwrap(); assert_eq!(caret(&s), (1, 6)); key(&mut s, "Left", false, false, false).unwrap(); - // Delete and Backspace remove a whole cluster. + // Delete removes a whole cluster; Backspace takes a letter's marks one at a time (#1220) and + // an emoji cluster whole. key(&mut s, "Delete", false, false, false).unwrap(); assert_eq!(chars(&s, t), "ab\u{1F1EF}\u{1F1F5}e\u{0301}"); key(&mut s, "Right", false, false, true).unwrap(); // to the end key(&mut s, "Backspace", false, false, false).unwrap(); + assert_eq!(chars(&s, t), "ab\u{1F1EF}\u{1F1F5}e"); + key(&mut s, "Backspace", false, false, false).unwrap(); assert_eq!(chars(&s, t), "ab\u{1F1EF}\u{1F1F5}"); key(&mut s, "Backspace", false, false, false).unwrap(); assert_eq!(chars(&s, t), "ab"); diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 85ea535b..7f1d3af6 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -243,7 +243,7 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R } else if word { word_left(&chars, a) } else { - figcraft_text::grapheme_step(&t.characters, a, false) + figcraft_text::backspace_step(&t.characters, a) }; (from, a) } else { @@ -574,6 +574,16 @@ mod tests { end(&mut s).unwrap(); } + #[test] + fn backspace_removes_only_the_final_thai_mark() { + let (mut s, id) = setup("น้ำ"); + begin(&mut s, id, Some(3), false).unwrap(); + + key(&mut s, "Backspace", false, false, false).unwrap(); + + assert_eq!(data(&s, id).characters, "น้"); + } + fn ev(kind: PointerKind, x: f64, y: f64, shift: bool) -> PointerEvent { let mut e = PointerEvent::at(kind, x, y, 1.0); e.shift = shift; diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index b3cb4884..a9baf22c 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -361,6 +361,18 @@ fn grapheme_clusters() { assert!(grapheme_starts("plain ascii").iter().all(|b| *b)); } +#[test] +fn backspace_removes_marks_but_keeps_other_graphemes_atomic() { + assert_eq!(crate::backspace_step("น้ำ", 3), 2); // Thai spacing mark + assert_eq!(crate::backspace_step("e\u{0301}", 2), 1); // Latin combining mark + assert_eq!(crate::backspace_step("👨‍👩‍👧", 5), 0); // Emoji ZWJ sequence + assert_eq!(crate::backspace_step("🇯🇵", 2), 0); // Flag + assert_eq!(crate::backspace_step("x", 1), 0); // Ordinary character + assert_eq!(crate::backspace_step("\u{1112}\u{1161}\u{11AB}", 3), 0); // Hangul syllable as conjoining jamo + assert_eq!(crate::backspace_step("a\u{200D}", 2), 0); // A letter ending in ZWJ + assert_eq!(crate::backspace_step("1\u{FE0F}\u{20E3}", 3), 0); // Keycap +} + #[test] fn a_cluster_is_shaped_with_one_face() { if face().is_none() { diff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs index 52284d0b..5d1ceaec 100644 --- a/crates/text/src/breaks.rs +++ b/crates/text/src/breaks.rs @@ -89,7 +89,7 @@ pub(crate) fn grapheme_starts(text: &str) -> Vec { } /// The char index of the grapheme cluster boundary after (`forward`) or before char `index` in -/// `text`: where an arrow key, Delete or Backspace moves the caret to. +/// `text`: where an arrow key or Delete moves the caret to. pub fn grapheme_step(text: &str, index: usize, forward: bool) -> usize { let mut at = 0; for g in text.graphemes(true) { @@ -102,6 +102,29 @@ pub fn grapheme_step(text: &str, index: usize, forward: bool) -> usize { at.min(index) } +/// The start of the text deleted by Backspace at char `index`. +/// +/// A letter's trailing mark (Grapheme_Cluster_Break Extend or SpacingMark, such as Thai MAI THO or +/// SARA AM) is deleted on its own; other grapheme clusters remain atomic, so emoji sequences, flags, +/// keycaps and Hangul syllables written as conjoining jamo stay intact. +pub fn backspace_step(text: &str, index: usize) -> usize { + let end = text.chars().count().min(index); + let start = grapheme_step(text, end, false); + if start == end { + return end; + } + + let cluster = text.chars().skip(start).take(end - start).collect::>(); + let letter_cluster = cluster.len() > 1 && cluster.first().is_some_and(|c| c.is_alphabetic()); + if letter_cluster && cluster.last().is_some_and(|&c| is_trailing_mark(c)) { end - 1 } else { start } +} + +/// Whether `c` is Extend or SpacingMark: it joins a plain letter's cluster. ZWJ also joins one but +/// is a joiner, not a mark. +fn is_trailing_mark(c: char) -> bool { + c != '\u{200D}' && format!("a{c}").graphemes(true).count() == 1 +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 50cd28dd..36566602 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -13,7 +13,7 @@ pub mod fontdb; mod layout; mod onpath; -pub use breaks::grapheme_step; +pub use breaks::{backspace_step, grapheme_step}; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face,