🐛 Step over whole emojis in the text editor (#12202)

Caret moves, deletes and caret rects use grapheme boundaries.
Closes #12188
This commit is contained in:
Elena Torró 2026-10-08 13:42:25 +02:00 committed by GitHub
parent d6f62d8d7d
commit 29ea6e49b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 101 additions and 19 deletions

View File

@ -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"

View File

@ -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]

View File

@ -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,
);

View File

@ -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 {

View File

@ -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), &paragraphs, false);
assert_eq!(forward, caret(0, 3));
let backward = move_cursor_backward(&caret(0, 3), &paragraphs, false);
assert_eq!(backward, caret(0, 1));
}
#[test]
fn delete_selection_range_spanning_several_spans() {
let mut content = content(vec![paragraph(&["Añ", "adir"])]);