diff --git a/render-wasm/Cargo.lock b/render-wasm/Cargo.lock index 05fbd20e6a..ee162a0029 100644 --- a/render-wasm/Cargo.lock +++ b/render-wasm/Cargo.lock @@ -465,6 +465,7 @@ dependencies = [ "macros", "skia-safe", "thiserror", + "unicode-segmentation", "uuid", ] @@ -702,6 +703,12 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "uuid" version = "1.11.0" diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index 8c0be81049..22bb7e1590 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -35,6 +35,7 @@ skia-safe = { version = "0.93.1", default-features = false, features = [ "pdf", ] } thiserror = "2.0.18" +unicode-segmentation = "1.12.0" uuid = { version = "1.11.0", features = ["v4", "js"] } [dev-dependencies] diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index d1825aeca8..7adfa5d692 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -162,7 +162,7 @@ fn calculate_cursor_rect( (0.0, 0.0, 1.0, laid_out_para.height()) } else if char_pos == 0 { let rects = laid_out_para.get_rects_for_range( - 0..para.char_utf16_len_at(0), + 0..para.char_offset_to_utf16(para.next_grapheme_offset(0)), RectHeightStyle::Max, RectWidthStyle::Tight, ); @@ -173,10 +173,10 @@ fn calculate_cursor_rect( (0.0, 0.0, 1.0, laid_out_para.height()) } } else if char_pos >= para_char_count { - let last_char = para_char_count.saturating_sub(1); - let last_start = para.char_offset_to_utf16(last_char); + let last_start = + para.char_offset_to_utf16(para.prev_grapheme_offset(para_char_count)); let rects = laid_out_para.get_rects_for_range( - last_start..last_start + para.char_utf16_len_at(last_char), + last_start..para.char_offset_to_utf16(para_char_count), RectHeightStyle::Max, RectWidthStyle::Tight, ); @@ -196,7 +196,7 @@ fn calculate_cursor_rect( } else { let utf16_pos = para.char_offset_to_utf16(char_pos); let rects = laid_out_para.get_rects_for_range( - utf16_pos..utf16_pos + para.char_utf16_len_at(char_pos), + utf16_pos..para.char_offset_to_utf16(para.next_grapheme_offset(char_pos)), RectHeightStyle::Max, RectWidthStyle::Tight, ); diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 8122ee21be..14eb84d347 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -25,6 +25,7 @@ use std::borrow::Cow; use std::cell::Cell; use std::collections::HashSet; use std::rc::Rc; +use unicode_segmentation::UnicodeSegmentation; use super::FontFamily; use crate::math::Point; @@ -1470,10 +1471,37 @@ impl Paragraph { low } - /// UTF-16 length of the character at `char_offset`, so a caret range covers - /// the whole glyph. - pub fn char_utf16_len_at(&self, char_offset: usize) -> usize { - self.char_offset_to_utf16(char_offset + 1) - self.char_offset_to_utf16(char_offset) + fn text(&self) -> String { + self.children + .iter() + .map(|span| span.text.as_str()) + .collect() + } + + /// Char offset of the grapheme boundary before `char_offset`, so emojis + /// made of several chars are one step. + pub fn prev_grapheme_offset(&self, char_offset: usize) -> usize { + let mut boundary = 0; + for grapheme in self.text().graphemes(true) { + let next = boundary + grapheme.chars().count(); + if next >= char_offset { + break; + } + boundary = next; + } + boundary + } + + /// Char offset of the grapheme boundary after `char_offset`. + pub fn next_grapheme_offset(&self, char_offset: usize) -> usize { + let mut boundary = 0; + for grapheme in self.text().graphemes(true) { + boundary += grapheme.chars().count(); + if boundary > char_offset { + break; + } + } + boundary } pub fn line_height(&self) -> f32 { @@ -2166,16 +2194,29 @@ mod tests { assert_eq!(para.char_offset_to_utf16(4), 4); assert_eq!(para.char_offset_to_utf16(6), 7); - assert_eq!(para.char_utf16_len_at(4), 2); + assert_eq!(para.char_offset_to_utf16(5), 6); assert_eq!(para.utf16_offset_to_char(7), 6); } #[test] - fn char_utf16_len_at_covers_the_whole_glyph() { - let para = test_paragraph(&["a😀b"]); - assert_eq!(para.char_utf16_len_at(0), 1); - assert_eq!(para.char_utf16_len_at(1), 2); - assert_eq!(para.char_utf16_len_at(2), 1); + fn grapheme_offsets_cross_whole_emojis() { + let para = test_paragraph(&["A👍🏽👨\u{200d}👩\u{200d}👧e\u{301}"]); + let boundaries = [0, 1, 3, 8, 10]; + for pair in boundaries.windows(2) { + assert_eq!(para.next_grapheme_offset(pair[0]), pair[1]); + assert_eq!(para.prev_grapheme_offset(pair[1]), pair[0]); + } + assert_eq!(para.next_grapheme_offset(2), 3); + assert_eq!(para.prev_grapheme_offset(2), 1); + assert_eq!(para.next_grapheme_offset(10), 10); + assert_eq!(para.prev_grapheme_offset(0), 0); + } + + #[test] + fn grapheme_offsets_cross_an_emoji_split_across_spans() { + let para = test_paragraph(&["A👍", "🏽B"]); + assert_eq!(para.next_grapheme_offset(1), 3); + assert_eq!(para.prev_grapheme_offset(3), 1); } fn sample_text_content() -> TextContent { diff --git a/render-wasm/src/wasm/text/helpers.rs b/render-wasm/src/wasm/text/helpers.rs index 95bec9f9a6..f9ccfebdd0 100644 --- a/render-wasm/src/wasm/text/helpers.rs +++ b/render-wasm/src/wasm/text/helpers.rs @@ -49,7 +49,7 @@ pub fn move_cursor_backward( if cursor.offset > 0 { return TextPositionWithAffinity::new_downstream_affinity( cursor.paragraph, - cursor.offset - 1, + paragraphs[cursor.paragraph].prev_grapheme_offset(cursor.offset), ); } if cursor.paragraph > 0 { @@ -126,7 +126,7 @@ pub fn move_cursor_forward( if cursor.offset < char_count { return TextPositionWithAffinity::new_downstream_affinity( cursor.paragraph, - cursor.offset + 1, + para.next_grapheme_offset(cursor.offset), ); } if cursor.paragraph < paragraphs.len() - 1 { @@ -582,7 +582,7 @@ pub fn delete_char_before( if cursor.offset > 0 { let paragraphs = text_content.paragraphs_mut(); let para = &mut paragraphs[cursor.paragraph]; - let delete_pos = cursor.offset - 1; + let delete_pos = para.prev_grapheme_offset(cursor.offset); delete_range_in_paragraph(para, delete_pos, cursor.offset); Some(TextPositionWithAffinity::new_downstream_affinity( cursor.paragraph, @@ -766,7 +766,8 @@ pub fn delete_char_after(text_content: &mut TextContent, cursor: &TextPositionWi if cursor.offset < para_len { let para = &mut paragraphs[cursor.paragraph]; - delete_range_in_paragraph(para, cursor.offset, cursor.offset + 1); + let end = para.next_grapheme_offset(cursor.offset); + delete_range_in_paragraph(para, cursor.offset, end); } else if cursor.paragraph < paragraphs.len() - 1 { let next_para_idx = cursor.paragraph + 1; let next_children: Vec<_> = paragraphs[next_para_idx].children_mut().drain(..).collect(); @@ -946,6 +947,38 @@ mod tests { assert_eq!(text_of(&content), "ab"); } + fn caret(paragraph: usize, offset: usize) -> TextPositionWithAffinity { + TextPositionWithAffinity::new_downstream_affinity(paragraph, offset) + } + + #[test] + fn delete_char_before_deletes_a_whole_emoji() { + for emoji in ["😀", "👍🏽", "👨\u{200d}👩\u{200d}👧", "🇪🇸"] { + let text = format!("A{emoji}"); + let len = text.chars().count(); + let mut content = content(vec![paragraph(&[text.as_str()])]); + let new_caret = delete_char_before(&mut content, &caret(0, len)); + assert_eq!(text_of(&content), "A"); + assert_eq!(new_caret, Some(caret(0, 1))); + } + } + + #[test] + fn delete_char_after_deletes_a_whole_emoji() { + let mut content = content(vec![paragraph(&["A👍🏽B"])]); + delete_char_after(&mut content, &caret(0, 1)); + assert_eq!(text_of(&content), "AB"); + } + + #[test] + fn move_cursor_steps_over_a_whole_emoji() { + let paragraphs = vec![paragraph(&["A👍🏽B"])]; + let forward = move_cursor_forward(&caret(0, 1), ¶graphs, false); + assert_eq!(forward, caret(0, 3)); + let backward = move_cursor_backward(&caret(0, 3), ¶graphs, false); + assert_eq!(backward, caret(0, 1)); + } + #[test] fn delete_selection_range_spanning_several_spans() { let mut content = content(vec![paragraph(&["Añ", "adir"])]);