diff --git a/crates/engine/src/cmd/text/caret.rs b/crates/engine/src/cmd/text/caret.rs index c7305d62e..db7cfd48e 100644 --- a/crates/engine/src/cmd/text/caret.rs +++ b/crates/engine/src/cmd/text/caret.rs @@ -56,7 +56,7 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result { ed::delete(body, a, b) } else { let other = match dir.as_str() { - "forward" => ed::char_move(body, a, true), + "forward" => ed::cluster_end(body, a), _ => { // Backspace at the start of an indented/bulleted paragraph outdents first. if a.1 == 0 diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5d2310328..3ad7dd759 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -98,6 +98,22 @@ fn text_editing_flow() { assert_eq!(s.doc().unwrap().selection.shapes.len(), 1); } +/// Forward Delete removes a whole grapheme cluster, not just its first scalar. +#[test] +fn forward_delete_removes_whole_grapheme_cluster() { + for (input, rest) in [("\u{1F44D}\u{1F3FD}y", "y"), ("e\u{301}x", "x")] { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.selectAll", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": input})).unwrap(); + s.execute("text.move", &json!({"to": "start"})).unwrap(); + s.execute("text.delete", &json!({"dir": "forward"})).unwrap(); + let t = s.execute("text.get", &json!({"id": title})).unwrap(); + assert_eq!(t["text"], rest); + } +} + /// `text.set` treats CRLF (or a lone CR) as one paragraph break and keeps no `\r` in the text. #[test] fn text_set_crlf_makes_clean_paragraphs() { diff --git a/crates/model/src/edit.rs b/crates/model/src/edit.rs index 8f79d9c4c..080dade69 100644 --- a/crates/model/src/edit.rs +++ b/crates/model/src/edit.rs @@ -3,6 +3,7 @@ use crate::text::{Paragraph, Run, RunKind, RunProps, TextBody}; use crate::words; +use unicode_segmentation::UnicodeSegmentation; /// (paragraph, char offset in the paragraph). pub type Pos = (usize, usize); @@ -419,6 +420,20 @@ fn smart_space(body: &mut TextBody, at: Pos) -> Pos { at } +/// The position after the grapheme cluster at `pos`, or the next paragraph's start at a paragraph end. +pub fn cluster_end(body: &TextBody, pos: Pos) -> Pos { + let (pi, ch) = clamp(body, pos); + let text = body.paragraphs.get(pi).map(Paragraph::text).unwrap_or_default(); + match text.chars().nth(ch) { + Some(_) => { + let rest: String = text.chars().skip(ch).collect(); + let n = rest.graphemes(true).next().map_or(1, |g| g.chars().count()); + (pi, ch + n) + } + None => char_move(body, pos, true), + } +} + /// One character left/right across paragraph boundaries. pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos { let (pi, ch) = clamp(body, pos);