Skip to main content

deckcraft_model/
words.rs

1//! Word boundaries in a paragraph, as PowerPoint's Ctrl+arrow, Ctrl+Backspace/Delete and
2//! double-click see them (observed black-box on PowerPoint for Windows, see
3//! `docs/ui-parity.md`, Text editing keys).
4//!
5//! A paragraph splits into *tokens*. Each token is a run of one class of grapheme clusters plus
6//! the spaces that follow it:
7//!
8//! - **word**: letters, digits and combining marks. An apostrophe (`'` or `โ€™`) joins a word when a
9//!   letter or digit follows it (`it's`, `'tis`, but `dogs'` ends before it); a hyphen joins when
10//!   a letter or digit comes before it (`a-b`, `1-2`, `a-`); `.`, `,` and `:` join between digits
11//!   (`3.14`, `1,000`, `3:45`, but `e.g.` is five tokens).
12//! - **punctuation**: everything else that is not a symbol; a run of it is one token (`...`,
13//!   `?!`, `),`, `ใ€‚ใ€‚`). `_`, `@`, `/`, `#`, quotes and brackets are punctuation.
14//! - **symbol**: emoji and pictographs (a run of them is one token, `๐Ÿ‘๐Ÿ‘`), `ยฉ`, `ยฎ`, `โ„ข`.
15//! - **ideograph**: each CJK ideograph, kana or Hangul syllable is a token of its own.
16//! - **tab** and **line break**: each is a token of its own, and spaces after one form their own
17//!   token instead of joining it.
18//! - **space**: U+0020, no-break space, ideographic space and the other fixed-width spaces. Spaces
19//!   join the token before them; spaces at the start of a paragraph (or after a tab or line break)
20//!   are a token of their own.
21//!
22//! Positions are character (Unicode scalar) offsets in the paragraph text, where a line break is
23//! one `\u{b}` character. A token never splits a grapheme cluster, so the caret never lands
24//! inside an emoji sequence or between a letter and its combining marks.
25
26use unicode_segmentation::UnicodeSegmentation;
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29enum Class {
30    Space,
31    Tab,
32    Break,
33    Word,
34    Ideo,
35    Punct,
36    Symbol,
37}
38
39/// One grapheme cluster: its first character offset and class.
40#[derive(Clone, Copy, Debug)]
41struct Cluster {
42    start: usize,
43    class: Class,
44}
45
46/// A space for word movement: absorbed into the token before it.
47pub fn is_space(c: char) -> bool {
48    matches!(
49        c,
50        ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}'
51            ..='\u{200a}' | '\u{202f}' | '\u{205f}' | '\u{3000}'
52    )
53}
54
55/// CJK ideographs, kana and Hangul: every one is a word of its own.
56fn is_ideo(c: char) -> bool {
57    matches!(c,
58        '\u{1100}'..='\u{11ff}'
59        | '\u{2e80}'..='\u{2fdf}'
60        | '\u{3005}'..='\u{3007}'
61        | '\u{3021}'..='\u{3029}'
62        | '\u{3031}'..='\u{3035}'
63        | '\u{3038}'..='\u{303c}'
64        | '\u{3040}'..='\u{309f}'
65        | '\u{30a0}'..='\u{30ff}'
66        | '\u{3100}'..='\u{318f}'
67        | '\u{31a0}'..='\u{31ff}'
68        | '\u{3400}'..='\u{4dbf}'
69        | '\u{4e00}'..='\u{9fff}'
70        | '\u{a960}'..='\u{a97f}'
71        | '\u{ac00}'..='\u{d7ff}'
72        | '\u{f900}'..='\u{faff}'
73        | '\u{ff66}'..='\u{ffdc}'
74        | '\u{20000}'..='\u{3134f}')
75}
76
77/// Emoji, pictographs and the common letter-like symbols.
78fn is_symbol(c: char) -> bool {
79    matches!(c,
80        '\u{a9}' | '\u{ae}' | '\u{2122}'
81        | '\u{2190}'..='\u{21ff}'
82        | '\u{2300}'..='\u{23ff}'
83        | '\u{25a0}'..='\u{27bf}'
84        | '\u{2b00}'..='\u{2bff}'
85        | '\u{1f000}'..='\u{1faff}')
86}
87
88fn base_class(c: char) -> Class {
89    match c {
90        '\u{b}' | '\n' | '\r' | '\u{2028}' | '\u{2029}' => Class::Break,
91        '\t' => Class::Tab,
92        c if is_space(c) => Class::Space,
93        c if is_ideo(c) => Class::Ideo,
94        c if c.is_alphanumeric() => Class::Word,
95        c if is_symbol(c) => Class::Symbol,
96        // A combining mark with no base (or any other mark) stays with letters.
97        '\u{300}'..='\u{36f}'
98        | '\u{1ab0}'..='\u{1aff}'
99        | '\u{1dc0}'..='\u{1dff}'
100        | '\u{20d0}'..='\u{20ff}'
101        | '\u{fe20}'..='\u{fe2f}' => Class::Word,
102        _ => Class::Punct,
103    }
104}
105
106fn clusters(text: &str) -> Vec<Cluster> {
107    let mut out: Vec<Cluster> = Vec::new();
108    let mut firsts: Vec<char> = Vec::new();
109    let mut pos = 0;
110    for g in text.graphemes(true) {
111        let n = g.chars().count();
112        let first = g.chars().next().unwrap_or(' ');
113        // "\r\n" is one grapheme; any other cluster takes the class of its first character.
114        out.push(Cluster {
115            start: pos,
116            class: base_class(first),
117        });
118        firsts.push(first);
119        pos += n;
120    }
121    // Context: apostrophes, hyphens and number separators that belong to a word.
122    let classes: Vec<Class> = out.iter().map(|c| c.class).collect();
123    for (i, cl) in out.iter_mut().enumerate() {
124        let Some(&c) = firsts.get(i) else { continue };
125        let prev = i.checked_sub(1).and_then(|j| firsts.get(j)).copied();
126        let next = firsts.get(i + 1).copied();
127        let joins = match c {
128            '\'' | '\u{2019}' => next.is_some_and(char::is_alphanumeric),
129            '-' => prev.is_some_and(char::is_alphanumeric),
130            '.' | ',' | ':' => {
131                prev.is_some_and(char::is_numeric) && next.is_some_and(char::is_numeric)
132            }
133            _ => false,
134        };
135        if joins && classes.get(i) == Some(&Class::Punct) {
136            cl.class = Class::Word;
137        }
138    }
139    out
140}
141
142/// Does a token start at cluster `cur` (after `prev`)?
143fn starts_token(prev: Class, cur: Class) -> bool {
144    if cur == Class::Space {
145        // Spaces join the token before them, except after a tab or a line break.
146        return matches!(prev, Class::Tab | Class::Break);
147    }
148    prev == Class::Space || prev != cur || matches!(cur, Class::Ideo | Class::Tab | Class::Break)
149}
150
151/// The word stops of a paragraph's text in ascending order: 0, the start of every token, and the
152/// end of the text. Ctrl+Right goes to the next one.
153pub fn stops(text: &str) -> Vec<usize> {
154    let cs = clusters(text);
155    let mut out = vec![0];
156    for w in cs.windows(2) {
157        if let [a, b] = w
158            && starts_token(a.class, b.class)
159        {
160            out.push(b.start);
161        }
162    }
163    let n = text.chars().count();
164    if out.last() != Some(&n) {
165        out.push(n);
166    }
167    out
168}
169
170/// The stops Ctrl+Left uses. PowerPoint's backward scan differs from the forward one in two
171/// observed details: a no-break or other non-ASCII space is a stop of its own (unless an ASCII
172/// space follows it), and a symbol followed by punctuation (`๐Ÿ‘?`) is one token.
173pub fn back_stops(text: &str) -> Vec<usize> {
174    let cs = clusters(text);
175    let chars: Vec<char> = text.chars().collect();
176    let mut out = vec![0];
177    for w in cs.windows(2) {
178        if let [a, b] = w {
179            let symbol_punct = matches!(
180                (a.class, b.class),
181                (Class::Symbol, Class::Punct) | (Class::Punct, Class::Symbol)
182            );
183            if starts_token(a.class, b.class) && !symbol_punct {
184                out.push(b.start);
185            }
186        }
187    }
188    for (i, c) in chars.iter().enumerate() {
189        if *c != ' ' && is_space(*c) && chars.get(i + 1) != Some(&' ') && i > 0 {
190            out.push(i);
191        }
192    }
193    out.sort_unstable();
194    out.dedup();
195    let n = chars.len();
196    if out.last() != Some(&n) {
197        out.push(n);
198    }
199    out
200}
201
202/// The next stop after `ch` (Ctrl+Right), or `None` at the end of the text.
203pub fn next_stop(text: &str, ch: usize) -> Option<usize> {
204    stops(text).into_iter().find(|s| *s > ch)
205}
206
207/// The previous stop before `ch` (Ctrl+Left), or `None` at the start.
208pub fn prev_stop(text: &str, ch: usize) -> Option<usize> {
209    back_stops(text).into_iter().rev().find(|s| *s < ch)
210}
211
212/// Is the character at `i` a space (for word purposes)?
213fn space_at(chars: &[char], i: usize) -> bool {
214    chars.get(i).is_some_and(|c| is_space(*c))
215}
216
217/// The end of the token's text (before its trailing spaces) for the token containing `ch`.
218fn content_end(text: &str, ch: usize) -> usize {
219    let chars: Vec<char> = text.chars().collect();
220    let next = next_stop(text, ch).unwrap_or(chars.len());
221    let mut e = ch;
222    while e < next && !space_at(&chars, e) {
223        e += 1;
224    }
225    // A token made only of spaces (leading spaces) ends at the next stop.
226    if e == ch && space_at(&chars, ch) {
227        next
228    } else {
229        e
230    }
231}
232
233/// The word a double-click at `ch` selects: the token under the caret without its trailing
234/// spaces; on a space just after a token, that token; in a run of spaces, the spaces.
235pub fn word_at(text: &str, ch: usize) -> (usize, usize) {
236    let chars: Vec<char> = text.chars().collect();
237    let n = chars.len();
238    if n == 0 {
239        return (0, 0);
240    }
241    let ch = ch.min(n);
242    let on_space = ch >= n || space_at(&chars, ch);
243    let after_token = ch > 0 && !space_at(&chars, ch - 1);
244    let at = if on_space && after_token {
245        ch - 1
246    } else {
247        ch.min(n - 1)
248    };
249    if space_at(&chars, at) {
250        let mut s = at;
251        while s > 0 && space_at(&chars, s - 1) {
252            s -= 1;
253        }
254        let mut e = at;
255        while e < n && space_at(&chars, e) {
256            e += 1;
257        }
258        return (s, e);
259    }
260    let s = stops(text)
261        .into_iter()
262        .rev()
263        .find(|s| *s <= at)
264        .unwrap_or(0);
265    (s, content_end(text, s).max(at + 1).min(n))
266}
267
268/// What Ctrl+Delete removes from `ch` (before smart spacing): inside a token, the rest of its
269/// text; on spaces right after a token, those spaces and the next token's text; anywhere else up
270/// to the next stop. `None` at the end of the text.
271pub fn delete_forward_end(text: &str, ch: usize) -> Option<usize> {
272    let chars: Vec<char> = text.chars().collect();
273    let n = chars.len();
274    if ch >= n {
275        return None;
276    }
277    let st = stops(text);
278    let at_stop = st.contains(&ch);
279    if !at_stop && !space_at(&chars, ch) {
280        return Some(content_end(text, ch));
281    }
282    let prev_is_text = ch > 0
283        && chars
284            .get(ch - 1)
285            .is_some_and(|c| !is_space(*c) && !matches!(base_class(*c), Class::Tab | Class::Break));
286    if space_at(&chars, ch) && prev_is_text {
287        let mut j = ch;
288        while j < n && space_at(&chars, j) {
289            j += 1;
290        }
291        if j >= n {
292            return Some(n);
293        }
294        return Some(content_end(text, j));
295    }
296    st.into_iter().find(|s| *s > ch)
297}
298
299/// Closing punctuation that a smart delete pulls its word's spaces up to (`ab cd, ef` โ†’ Ctrl+Delete
300/// on `cd` gives `ab, ef`).
301pub fn is_closing_punct(c: char) -> bool {
302    matches!(
303        c,
304        '.' | ','
305            | ';'
306            | ':'
307            | '!'
308            | '?'
309            | ')'
310            | ']'
311            | '}'
312            | '%'
313            | '\u{2026}'
314            | '\u{201d}'
315            | '\u{2019}'
316            | '\u{bb}'
317    )
318}
319
320/// Punctuation that a smart delete keeps a space after when a word follows (`cd...ef gh` โ†’
321/// Ctrl+Delete on `ef` gives `cd... gh`).
322pub fn is_sentence_punct(c: char) -> bool {
323    matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | '\u{2026}')
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    /// Stops drawn as `|` in the text, for readable assertions.
331    fn marked(text: &str, st: &[usize]) -> String {
332        let mut out = String::new();
333        for (i, c) in text.chars().enumerate() {
334            if st.contains(&i) {
335                out.push('|');
336            }
337            out.push(match c {
338                '\u{b}' => 'โ†ต',
339                '\t' => 'โ†’',
340                c => c,
341            });
342        }
343        if st.contains(&text.chars().count()) {
344            out.push('|');
345        }
346        out
347    }
348    fn fwd(text: &str) -> String {
349        marked(text, &stops(text))
350    }
351    fn back(text: &str) -> String {
352        marked(text, &back_stops(text))
353    }
354
355    // Every expected string below is a stop trace observed in PowerPoint for Windows.
356    #[test]
357    fn punctuation_and_spaces() {
358        let t = "Hello, world!  Foo...bar baz-qux it's 3.14 e.g. a_b x@y.com (end) \"q\" end.";
359        let want = "|Hello|, |world|!  |Foo|...|bar |baz-qux |it's |3.14 |e|.|g|. |a|_|b |x|@|y|.|com |(|end|) |\"|q|\" |end|.|";
360        assert_eq!(fwd(t), want);
361        assert_eq!(back(t), want);
362    }
363
364    #[test]
365    fn hyphens_apostrophes_numbers() {
366        let t = "a-b -b a--b a - b a- b dogs' x 'q' 1,000.5 3:45 v1.2.3 $5 50% a/b x&y #tag a+b c++ end";
367        assert_eq!(
368            fwd(t),
369            "|a-b |-|b |a-|-|b |a |- |b |a- |b |dogs|' |x |'q|' |1,000.5 |3:45 |v1.2.3 |$|5 |50|% |a|/|b |x|&|y |#|tag |a|+|b |c|++ |end|"
370        );
371        let t = "itโ€™s x a?!b a.)b a),b 1.a a1.b 1.5.x 1-2 a1-b A-1 โ€”x xโ€” a's 'tis o'clock โ€˜qโ€™ end";
372        assert_eq!(
373            fwd(t),
374            "|itโ€™s |x |a|?!|b |a|.)|b |a|),|b |1|.|a |a1|.|b |1.5|.|x |1-2 |a1-b |A-1 |โ€”|x |x|โ€” |a's |'tis |o'clock |โ€˜|q|โ€™ |end|"
375        );
376    }
377
378    #[test]
379    fn marks_emoji_symbols() {
380        let t = "cafรฉ e\u{301}te ๐Ÿ‘๐Ÿฝ x ๐Ÿ‘จ\u{200d}๐Ÿ‘ฉ\u{200d}๐Ÿ‘ง y ยฝ ยฎz ยซqยป โ€œwโ€ ยฟno? okโ€”em dashโ€“en x";
381        let want = "|cafรฉ |e\u{301}te |๐Ÿ‘๐Ÿฝ |x |๐Ÿ‘จ\u{200d}๐Ÿ‘ฉ\u{200d}๐Ÿ‘ง |y |ยฝ |ยฎ|z |ยซ|q|ยป |โ€œ|w|โ€ |ยฟ|no|? |ok|โ€”|em |dash|โ€“|en |x|";
382        assert_eq!(fwd(t), want);
383        assert_eq!(back(t), want);
384        // A run of emoji is one token; emoji then punctuation is two going right, one going left.
385        assert_eq!(fwd("๐Ÿ‘๐Ÿ‘ x ๐Ÿ‘? x๐Ÿ‘y"), "|๐Ÿ‘๐Ÿ‘ |x |๐Ÿ‘|? |x|๐Ÿ‘|y|");
386        assert_eq!(back("๐Ÿ‘๐Ÿ‘ x ๐Ÿ‘? x๐Ÿ‘y"), "|๐Ÿ‘๐Ÿ‘ |x |๐Ÿ‘? |x|๐Ÿ‘|y|");
387    }
388
389    #[test]
390    fn cjk_each_character() {
391        let t = "ๆ—ฅๆœฌ่ชžใฎใƒ†ใ‚ญใ‚นใƒˆใงใ™ใ€‚ใ‚ซใ‚ฟใ‚ซใƒŠๆผขๅญ— abc ไธญๆ–‡ๅญ—็ฌฆ๏ผŒๆ ‡็‚นใ€‚ํ•œ๊ตญ์–ด ๋ฌธ์žฅ aใ€‚ใ€‚b";
392        assert_eq!(
393            fwd(t),
394            "|ๆ—ฅ|ๆœฌ|่ชž|ใฎ|ใƒ†|ใ‚ญ|ใ‚น|ใƒˆ|ใง|ใ™|ใ€‚|ใ‚ซ|ใ‚ฟ|ใ‚ซ|ใƒŠ|ๆผข|ๅญ— |abc |ไธญ|ๆ–‡|ๅญ—|็ฌฆ|๏ผŒ|ๆ ‡|็‚น|ใ€‚|ํ•œ|๊ตญ|์–ด |๋ฌธ|์žฅ |a|ใ€‚ใ€‚|b|"
395        );
396        assert_eq!(back(t), fwd(t));
397    }
398
399    #[test]
400    fn rtl_words_in_logical_order() {
401        let t = "Hello ู…ุฑุญุจุง ุจุงู„ุนุงู„ู… world ืฉืœื•ื ืขื•ืœื end";
402        assert_eq!(fwd(t), "|Hello |ู…ุฑุญุจุง |ุจุงู„ุนุงู„ู… |world |ืฉืœื•ื |ืขื•ืœื |end|");
403    }
404
405    #[test]
406    fn tabs_breaks_and_other_spaces() {
407        assert_eq!(fwd("  lead x   mid  "), "|  |lead |x   |mid  |");
408        assert_eq!(
409            fwd("Five\u{b}six seven\tTab   x"),
410            "|Five|โ†ต|six |seven|โ†’|Tab   |x|"
411        );
412        assert_eq!(
413            fwd("a\t  b x  \u{b}  y q\u{b}\u{b}z"),
414            "|a|โ†’|  |b |x  |โ†ต|  |y |q|โ†ต|โ†ต|z|"
415        );
416        let t = "a\u{a0}\u{a0}b a\u{3000}b a\u{2003}b a \u{a0}b a\t\tb a \tb a\u{a0} b end";
417        assert_eq!(
418            fwd(t),
419            "|a\u{a0}\u{a0}|b |a\u{3000}|b |a\u{2003}|b |a \u{a0}|b |a|โ†’|โ†’|b |a |โ†’|b |a\u{a0} |b |end|"
420        );
421        assert_eq!(
422            back(t),
423            "|a|\u{a0}|\u{a0}|b |a|\u{3000}|b |a|\u{2003}|b |a |\u{a0}|b |a|โ†’|โ†’|b |a |โ†’|b |a\u{a0} |b |end|"
424        );
425    }
426
427    #[test]
428    fn next_and_prev() {
429        let t = "  lead x   mid  ";
430        assert_eq!(next_stop(t, 1), Some(2));
431        assert_eq!(next_stop(t, 9), Some(11));
432        assert_eq!(next_stop(t, 16), None);
433        assert_eq!(prev_stop(t, 1), Some(0));
434        assert_eq!(prev_stop(t, 9), Some(7));
435        assert_eq!(prev_stop(t, 15), Some(11));
436        assert_eq!(prev_stop(t, 0), None);
437        assert_eq!(next_stop("", 0), None);
438        assert_eq!(prev_stop("", 0), None);
439    }
440
441    #[test]
442    fn double_click_word() {
443        let t = "Hello world,  foo bar";
444        assert_eq!(word_at(t, 7), (6, 11));
445        assert_eq!(word_at(t, 15), (14, 17));
446        assert_eq!(word_at(t, 11), (11, 12));
447        assert_eq!(word_at(t, 12), (11, 12));
448        assert_eq!(word_at(t, 13), (12, 14));
449        assert_eq!(word_at(t, 21), (18, 21));
450        assert_eq!(word_at("ๆ—ฅๆœฌ่ชžใฎใƒ†ใ‚ญใ‚นใƒˆ abc", 5), (5, 6));
451        assert_eq!(word_at("", 3), (0, 0));
452        assert_eq!(word_at("x๐Ÿ‘จ\u{200d}๐Ÿ‘ฉ\u{200d}๐Ÿ‘งy", 2), (1, 6));
453    }
454
455    #[test]
456    fn delete_forward_extent() {
457        let t = "ab   cd...ef gh";
458        let ends: Vec<Option<usize>> = (0..=t.len()).map(|c| delete_forward_end(t, c)).collect();
459        // Before smart spacing: PowerPoint's results after it are tested in `edit`.
460        assert_eq!(
461            ends,
462            vec![
463                Some(5),
464                Some(2),
465                Some(7),
466                Some(5),
467                Some(5),
468                Some(7),
469                Some(7),
470                Some(10),
471                Some(10),
472                Some(10),
473                Some(13),
474                Some(12),
475                Some(15),
476                Some(15),
477                Some(15),
478                None
479            ]
480        );
481        assert_eq!(delete_forward_end("ab\t cd", 2), Some(3));
482        assert_eq!(delete_forward_end("ab  ", 2), Some(4));
483    }
484}