Skip to main content

deckcraft_model/
edit.rs

1//! Text editing on a [`TextBody`]: positions are (paragraph, character offset). Inserting text
2//! with `\n` splits paragraphs; formatting a range splits runs at its ends.
3
4use crate::text::{Paragraph, Run, RunKind, RunProps, TextBody};
5use crate::words;
6
7/// (paragraph, char offset in the paragraph).
8pub type Pos = (usize, usize);
9
10fn clamp(body: &TextBody, p: Pos) -> Pos {
11    if body.paragraphs.is_empty() {
12        return (0, 0);
13    }
14    let pi = p.0.min(body.paragraphs.len() - 1);
15    let n = body
16        .paragraphs
17        .get(pi)
18        .map(Paragraph::char_len)
19        .unwrap_or(0);
20    (pi, p.1.min(n))
21}
22
23fn order(a: Pos, b: Pos) -> (Pos, Pos) {
24    if a <= b { (a, b) } else { (b, a) }
25}
26
27/// Split the paragraph's runs so a run boundary falls at char `at`; returns the index of the
28/// first run starting at or after `at`.
29fn split_at(p: &mut Paragraph, at: usize) -> usize {
30    let mut pos = 0;
31    let mut i = 0;
32    while i < p.runs.len() {
33        let len = p.runs.get(i).map(Run::char_len).unwrap_or(0);
34        if pos == at {
35            return i;
36        }
37        if at < pos + len {
38            let Some(r) = p.runs.get(i) else { return i };
39            if r.kind != RunKind::Text {
40                return i + 1;
41            }
42            let k = at - pos;
43            let byte = r
44                .text
45                .char_indices()
46                .nth(k)
47                .map(|(b, _)| b)
48                .unwrap_or(r.text.len());
49            let (a, b) = r.text.split_at(byte);
50            let second = Run {
51                text: b.to_string(),
52                props: r.props.clone(),
53                kind: RunKind::Text,
54            };
55            let first = a.to_string();
56            if let Some(r) = p.runs.get_mut(i) {
57                r.text = first;
58            }
59            p.runs.insert(i + 1, second);
60            return i + 1;
61        }
62        pos += len;
63        i += 1;
64    }
65    p.runs.len()
66}
67
68/// The explicit run properties in effect at `pos` (the run before the caret, or the end mark).
69pub fn props_at(body: &TextBody, pos: Pos) -> RunProps {
70    let (pi, ch) = clamp(body, pos);
71    let Some(p) = body.paragraphs.get(pi) else {
72        return RunProps::default();
73    };
74    let mut acc = 0;
75    let mut last = None;
76    for r in &p.runs {
77        let len = r.char_len();
78        if ch <= acc + len && ch > acc {
79            return r.props.clone();
80        }
81        if acc + len >= ch && ch == 0 {
82            return r.props.clone();
83        }
84        acc += len;
85        last = Some(&r.props);
86    }
87    last.cloned().unwrap_or_else(|| p.end_props.clone())
88}
89
90/// Insert `text` at `pos` (with `props`, else the props at `pos`). `\n` starts a new paragraph
91/// (same level and paragraph props); `\u{b}` inserts a line break. Returns the caret after it.
92pub fn insert(body: &mut TextBody, pos: Pos, text: &str, props: Option<RunProps>) -> Pos {
93    if body.paragraphs.is_empty() {
94        body.paragraphs.push(Paragraph::default());
95    }
96    let (mut pi, mut ch) = clamp(body, pos);
97    let props = props.unwrap_or_else(|| props_at(body, (pi, ch)));
98    let text = text.replace("\r\n", "\n").replace('\r', "\n");
99    let mut first = true;
100    for part in text.split('\n') {
101        if !first {
102            // Split paragraph pi at ch.
103            let Some(p) = body.paragraphs.get_mut(pi) else {
104                break;
105            };
106            let idx = split_at(p, ch);
107            let tail: Vec<Run> = p.runs.drain(idx..).collect();
108            let newp = Paragraph {
109                level: p.level,
110                props: p.props.clone(),
111                runs: tail,
112                end_props: p.end_props.clone(),
113            };
114            p.end_props = props.clone();
115            body.paragraphs.insert(pi + 1, newp);
116            pi += 1;
117            ch = 0;
118        }
119        first = false;
120        for (k, seg) in part.split('\u{b}').enumerate() {
121            let Some(p) = body.paragraphs.get_mut(pi) else {
122                break;
123            };
124            if k > 0 {
125                let idx = split_at(p, ch);
126                p.runs.insert(
127                    idx,
128                    Run {
129                        text: String::new(),
130                        props: props.clone(),
131                        kind: RunKind::Break,
132                    },
133                );
134                ch += 1;
135            }
136            if seg.is_empty() {
137                continue;
138            }
139            let idx = split_at(p, ch);
140            p.runs.insert(idx, Run::with(seg, props.clone()));
141            ch += seg.chars().count();
142        }
143        if let Some(p) = body.paragraphs.get_mut(pi) {
144            p.normalize();
145            if p.runs.is_empty() {
146                p.end_props = props.clone();
147            }
148        }
149    }
150    (pi, ch)
151}
152
153/// Delete the range between `a` and `b`; returns the caret at the start.
154pub fn delete(body: &mut TextBody, a: Pos, b: Pos) -> Pos {
155    if body.paragraphs.is_empty() {
156        return (0, 0);
157    }
158    let (s, e) = order(clamp(body, a), clamp(body, b));
159    if s == e {
160        return s;
161    }
162    if s.0 == e.0 {
163        if let Some(p) = body.paragraphs.get_mut(s.0) {
164            let end_props = props_of_range(p, s.1);
165            let i0 = split_at(p, s.1);
166            let i1 = split_at(p, e.1);
167            if i1 > i0 {
168                p.runs.drain(i0..i1);
169            }
170            p.normalize();
171            if p.runs.is_empty() {
172                p.end_props = end_props;
173            }
174        }
175        return s;
176    }
177    // Keep the head of s.0 and the tail of e.0, joined; drop paragraphs between.
178    let tail: Vec<Run> = match body.paragraphs.get_mut(e.0) {
179        Some(p) => {
180            let i = split_at(p, e.1);
181            p.runs.drain(i..).collect()
182        }
183        None => vec![],
184    };
185    if let Some(p) = body.paragraphs.get_mut(s.0) {
186        let i = split_at(p, s.1);
187        p.runs.truncate(i);
188        p.runs.extend(tail);
189        p.normalize();
190    }
191    if e.0 > s.0 && e.0 < body.paragraphs.len() {
192        body.paragraphs.drain(s.0 + 1..=e.0);
193    }
194    s
195}
196
197fn props_of_range(p: &Paragraph, at: usize) -> RunProps {
198    let mut acc = 0;
199    for r in &p.runs {
200        let len = r.char_len();
201        if at < acc + len || (at == acc + len && at > 0) {
202            return r.props.clone();
203        }
204        acc += len;
205    }
206    p.runs
207        .last()
208        .map(|r| r.props.clone())
209        .unwrap_or_else(|| p.end_props.clone())
210}
211
212/// Apply `f` to the run properties of every run in [a, b). An empty range changes the empty
213/// paragraph's end mark (so typing there picks the formatting up).
214pub fn format(body: &mut TextBody, a: Pos, b: Pos, f: &dyn Fn(&mut RunProps)) {
215    if body.paragraphs.is_empty() {
216        return;
217    }
218    let (s, e) = order(clamp(body, a), clamp(body, b));
219    for pi in s.0..=e.0 {
220        let Some(p) = body.paragraphs.get_mut(pi) else {
221            continue;
222        };
223        let from = if pi == s.0 { s.1 } else { 0 };
224        let to = if pi == e.0 { e.1 } else { p.char_len() };
225        if p.runs.is_empty() || (from == to && s == e) {
226            f(&mut p.end_props);
227            continue;
228        }
229        let i0 = split_at(p, from);
230        let i1 = split_at(p, to);
231        for r in p.runs.iter_mut().take(i1).skip(i0) {
232            f(&mut r.props);
233        }
234        if to == p.char_len() {
235            f(&mut p.end_props);
236        }
237        p.normalize();
238    }
239}
240
241/// Apply `f` to every run in the body (and end marks).
242pub fn format_all(body: &mut TextBody, f: &dyn Fn(&mut RunProps)) {
243    for p in &mut body.paragraphs {
244        for r in &mut p.runs {
245            f(&mut r.props);
246        }
247        f(&mut p.end_props);
248        p.normalize();
249    }
250}
251
252/// The text between two positions (`\n` between paragraphs).
253pub fn text_range(body: &TextBody, a: Pos, b: Pos) -> String {
254    let (s, e) = order(clamp(body, a), clamp(body, b));
255    let mut out = String::new();
256    for pi in s.0..=e.0 {
257        let Some(p) = body.paragraphs.get(pi) else {
258            continue;
259        };
260        let t: Vec<char> = p.text().chars().collect();
261        let from = if pi == s.0 { s.1 } else { 0 };
262        let to = if pi == e.0 { e.1 } else { t.len() };
263        out.extend(t.get(from.min(t.len())..to.min(t.len())).unwrap_or(&[]));
264        if pi != e.0 {
265            out.push('\n');
266        }
267    }
268    out
269}
270
271/// A formatted copy of the range.
272pub fn slice(body: &TextBody, a: Pos, b: Pos) -> TextBody {
273    let (s, e) = order(clamp(body, a), clamp(body, b));
274    let mut out = TextBody {
275        body: body.body.clone(),
276        list_style: body.list_style.clone(),
277        paragraphs: vec![],
278    };
279    for pi in s.0..=e.0 {
280        let Some(p) = body.paragraphs.get(pi) else {
281            continue;
282        };
283        let mut p = p.clone();
284        let to = if pi == e.0 { e.1 } else { p.char_len() };
285        let i1 = split_at(&mut p, to);
286        p.runs.truncate(i1);
287        let from = if pi == s.0 { s.1 } else { 0 };
288        let i0 = split_at(&mut p, from);
289        p.runs.drain(..i0);
290        out.paragraphs.push(p);
291    }
292    out
293}
294
295/// Insert a formatted body at `pos` (paste): first paragraph merges into the current one.
296pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
297    let mut caret = clamp(body, pos);
298    for (k, sp) in src.paragraphs.iter().enumerate() {
299        if k > 0 {
300            caret = insert(body, caret, "\n", None);
301            if let Some(p) = body.paragraphs.get_mut(caret.0) {
302                p.level = sp.level;
303                p.props = sp.props.clone();
304            }
305        }
306        for r in &sp.runs {
307            match r.kind {
308                RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())),
309                RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())),
310                _ => {
311                    if let Some(p) = body.paragraphs.get_mut(caret.0) {
312                        let idx = split_at(p, caret.1);
313                        p.runs.insert(idx, r.clone());
314                        caret.1 += r.char_len();
315                    }
316                }
317            }
318        }
319    }
320    caret
321}
322
323fn para_text(body: &TextBody, pi: usize) -> String {
324    body.paragraphs
325        .get(pi)
326        .map(Paragraph::text)
327        .unwrap_or_default()
328}
329
330/// The word a double-click selects around `pos` (see [`words::word_at`]): the word without its
331/// trailing spaces, as PowerPoint selects it.
332pub fn word_at(body: &TextBody, pos: Pos) -> (Pos, Pos) {
333    let (pi, ch) = clamp(body, pos);
334    let (s, e) = words::word_at(&para_text(body, pi), ch);
335    ((pi, s), (pi, e))
336}
337
338/// Next/previous word stop (Ctrl+Right/Left on Windows, Option+arrows on the Mac). At the end of
339/// a paragraph Ctrl+Right goes to the start of the next one; at its start Ctrl+Left goes to the
340/// end of the previous one.
341pub fn word_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
342    let (pi, ch) = clamp(body, pos);
343    let t = para_text(body, pi);
344    if forward {
345        match words::next_stop(&t, ch) {
346            Some(s) => (pi, s),
347            None if pi + 1 < body.paragraphs.len() => (pi + 1, 0),
348            None => (pi, ch),
349        }
350    } else {
351        match words::prev_stop(&t, ch) {
352            Some(s) => (pi, s),
353            None if pi > 0 => (
354                pi - 1,
355                body.paragraphs
356                    .get(pi - 1)
357                    .map(Paragraph::char_len)
358                    .unwrap_or(0),
359            ),
360            None => (pi, 0),
361        }
362    }
363}
364
365/// Ctrl+Up: the start of the paragraph, or of the previous one when already there.
366pub fn para_up(body: &TextBody, pos: Pos) -> Pos {
367    let (pi, ch) = clamp(body, pos);
368    if ch > 0 {
369        (pi, 0)
370    } else {
371        (pi.saturating_sub(1), 0)
372    }
373}
374
375/// Ctrl+Down: the start of the next paragraph, or the end of the last one.
376pub fn para_down(body: &TextBody, pos: Pos) -> Pos {
377    let (pi, _) = clamp(body, pos);
378    if pi + 1 < body.paragraphs.len() {
379        (pi + 1, 0)
380    } else {
381        (
382            pi,
383            body.paragraphs
384                .get(pi)
385                .map(Paragraph::char_len)
386                .unwrap_or(0),
387        )
388    }
389}
390
391/// Delete a word from `pos` as Ctrl+Backspace (`forward == false`) or Ctrl+Delete does in
392/// PowerPoint; returns the caret.
393///
394/// Ctrl+Backspace deletes back to the previous word stop (at a paragraph's start it joins the
395/// paragraph to the one before). Ctrl+Delete deletes the rest of the word, or the spaces after a
396/// word and the next word, or up to the next stop (see [`words::delete_forward_end`]), then
397/// tidies the spacing as PowerPoint's smart cut does: spaces left before closing punctuation or
398/// at the end of the paragraph after a word go, and sentence punctuation left against a word
399/// gets a space. At a paragraph's end it joins the next paragraph.
400pub fn delete_word(body: &mut TextBody, pos: Pos, forward: bool) -> Pos {
401    if body.paragraphs.is_empty() {
402        return (0, 0);
403    }
404    let (pi, ch) = clamp(body, pos);
405    let t = para_text(body, pi);
406    if !forward {
407        let to = match words::prev_stop(&t, ch) {
408            Some(s) => (pi, s),
409            None => char_move(body, (pi, ch), false),
410        };
411        return delete(body, to, (pi, ch));
412    }
413    let Some(e) = words::delete_forward_end(&t, ch) else {
414        let to = char_move(body, (pi, ch), true);
415        return delete(body, (pi, ch), to);
416    };
417    let at = delete(body, (pi, ch), (pi, e));
418    smart_space(body, at)
419}
420
421/// PowerPoint's smart-cut spacing after a word was deleted at `at`; returns the caret.
422fn smart_space(body: &mut TextBody, at: Pos) -> Pos {
423    let (pi, s) = at;
424    let chars: Vec<char> = para_text(body, pi).chars().collect();
425    let after = chars.get(s).copied();
426    let before = s.checked_sub(1).and_then(|i| chars.get(i)).copied();
427    if after.is_none_or(words::is_closing_punct) && before.is_some_and(|c| c == ' ') {
428        let mut w = s;
429        while w > 0 && chars.get(w - 1) == Some(&' ') {
430            w -= 1;
431        }
432        if w > 0 && chars.get(w - 1).is_some_and(|c| c.is_alphanumeric()) {
433            return delete(body, (pi, w), (pi, s));
434        }
435        return at;
436    }
437    if let (Some(b), Some(a)) = (before, after)
438        && words::is_sentence_punct(b)
439        && a.is_alphanumeric()
440    {
441        let digits = s >= 2 && chars.get(s - 2).is_some_and(|c| c.is_numeric()) && a.is_numeric();
442        if !digits {
443            return insert(body, at, " ", None);
444        }
445    }
446    at
447}
448
449/// One character left/right across paragraph boundaries.
450pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
451    let (pi, ch) = clamp(body, pos);
452    let n = body
453        .paragraphs
454        .get(pi)
455        .map(Paragraph::char_len)
456        .unwrap_or(0);
457    if forward {
458        if ch < n {
459            (pi, ch + 1)
460        } else if pi + 1 < body.paragraphs.len() {
461            (pi + 1, 0)
462        } else {
463            (pi, ch)
464        }
465    } else if ch > 0 {
466        (pi, ch - 1)
467    } else if pi > 0 {
468        (
469            pi - 1,
470            body.paragraphs
471                .get(pi - 1)
472                .map(Paragraph::char_len)
473                .unwrap_or(0),
474        )
475    } else {
476        (0, 0)
477    }
478}
479
480/// End of the body.
481pub fn end(body: &TextBody) -> Pos {
482    let pi = body.paragraphs.len().saturating_sub(1);
483    (
484        pi,
485        body.paragraphs
486            .get(pi)
487            .map(Paragraph::char_len)
488            .unwrap_or(0),
489    )
490}
491
492/// The case Shift+F3 changes `s` to next, as PowerPoint cycles it: lowercase → Capitalize Each
493/// Word → UPPERCASE → lowercase; any other mix goes to UPPERCASE first.
494pub fn next_case(s: &str) -> &'static str {
495    if s == s.to_lowercase() && s.chars().any(char::is_alphabetic) {
496        "title"
497    } else if s == s.to_uppercase() && s.chars().any(char::is_alphabetic) {
498        "lower"
499    } else {
500        "upper"
501    }
502}
503
504/// Change case: `upper`, `lower`, `sentence`, `title`, `toggle`.
505pub fn change_case(s: &str, mode: &str) -> String {
506    match mode {
507        "upper" => s.to_uppercase(),
508        "lower" => s.to_lowercase(),
509        "toggle" => s
510            .chars()
511            .map(|c| {
512                if c.is_uppercase() {
513                    c.to_lowercase().collect::<String>()
514                } else {
515                    c.to_uppercase().collect()
516                }
517            })
518            .collect(),
519        "title" => {
520            let mut out = String::new();
521            let mut start = true;
522            for c in s.chars() {
523                if start && c.is_alphabetic() {
524                    out.extend(c.to_uppercase());
525                    start = false;
526                } else {
527                    out.extend(c.to_lowercase());
528                }
529                if c.is_whitespace() {
530                    start = true;
531                }
532            }
533            out
534        }
535        _ => {
536            // sentence
537            let mut out = String::new();
538            let mut start = true;
539            for c in s.chars() {
540                if start && c.is_alphabetic() {
541                    out.extend(c.to_uppercase());
542                    start = false;
543                } else {
544                    out.extend(c.to_lowercase());
545                }
546                if matches!(c, '.' | '!' | '?') {
547                    start = true;
548                }
549            }
550            out
551        }
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    fn body(t: &str) -> TextBody {
560        TextBody::from_text(t)
561    }
562
563    #[test]
564    fn insert_and_split() {
565        let mut b = body("Hello");
566        let c = insert(&mut b, (0, 5), " world\nNext", None);
567        assert_eq!(b.text(), "Hello world\nNext");
568        assert_eq!(c, (1, 4));
569        let c = insert(&mut b, (0, 0), ">", None);
570        assert_eq!(c, (0, 1));
571        assert_eq!(b.paragraphs[0].runs.len(), 1);
572    }
573
574    #[test]
575    fn delete_within_and_across() {
576        let mut b = body("abc\ndef\nghi");
577        assert_eq!(delete(&mut b, (0, 1), (2, 1)), (0, 1));
578        assert_eq!(b.text(), "ahi");
579        let mut b = body("abcdef");
580        delete(&mut b, (0, 4), (0, 2));
581        assert_eq!(b.text(), "abef");
582        // hostile positions
583        delete(&mut b, (99, 99), (0, 0));
584        assert_eq!(b.text(), "");
585    }
586
587    #[test]
588    fn format_splits_runs() {
589        let mut b = body("abcdef");
590        format(&mut b, (0, 2), (0, 4), &|p| p.bold = Some(true));
591        let p = &b.paragraphs[0];
592        assert_eq!(p.runs.len(), 3);
593        assert_eq!(p.runs[1].text, "cd");
594        assert_eq!(p.runs[1].props.bold, Some(true));
595        assert_eq!(props_at(&b, (0, 3)).bold, Some(true));
596        assert_eq!(text_range(&b, (0, 1), (0, 5)), "bcde");
597        let s = slice(&b, (0, 1), (0, 5));
598        assert_eq!(s.text(), "bcde");
599        let mut c = body("XY");
600        let caret = insert_body(&mut c, (0, 1), &s);
601        assert_eq!(c.text(), "XbcdeY");
602        assert_eq!(caret, (0, 5));
603    }
604
605    #[test]
606    fn words_and_moves() {
607        let b = body("Hello brave world");
608        assert_eq!(word_at(&b, (0, 7)), ((0, 6), (0, 11)));
609        assert_eq!(word_move(&b, (0, 0), true), (0, 6));
610        assert_eq!(word_move(&b, (0, 8), false), (0, 6));
611        assert_eq!(char_move(&b, (0, 17), true), (0, 17));
612        assert_eq!(
613            change_case("hello world. again", "sentence"),
614            "Hello world. Again"
615        );
616        assert_eq!(change_case("hello world", "title"), "Hello World");
617    }
618
619    #[test]
620    fn line_break_insert() {
621        let mut b = body("ab");
622        let c = insert(&mut b, (0, 1), "\u{b}", None);
623        assert_eq!(c, (0, 2));
624        assert_eq!(b.paragraphs[0].char_len(), 3);
625        assert_eq!(b.paragraphs.len(), 1);
626    }
627
628    /// Ctrl+Backspace / Ctrl+Delete from `at`: the text and caret after (PowerPoint's results).
629    fn del_word(text: &str, at: Pos, forward: bool) -> (String, Pos) {
630        let mut b = body(text);
631        let c = delete_word(&mut b, at, forward);
632        (b.text(), c)
633    }
634
635    #[test]
636    fn ctrl_backspace_like_powerpoint() {
637        let t = "ab   cd...ef gh";
638        let want = [
639            ("ab   cd...ef gh", 0),
640            ("b   cd...ef gh", 0),
641            ("   cd...ef gh", 0),
642            ("  cd...ef gh", 0),
643            (" cd...ef gh", 0),
644            ("cd...ef gh", 0),
645            ("ab   d...ef gh", 5),
646            ("ab   ...ef gh", 5),
647            ("ab   cd..ef gh", 7),
648            ("ab   cd.ef gh", 7),
649            ("ab   cdef gh", 7),
650            ("ab   cd...f gh", 10),
651            ("ab   cd... gh", 10),
652            ("ab   cd...gh", 10),
653            ("ab   cd...ef h", 13),
654            ("ab   cd...ef ", 13),
655        ];
656        for (c, (txt, caret)) in want.iter().enumerate() {
657            assert_eq!(
658                del_word(t, (0, c), false),
659                (txt.to_string(), (0, *caret)),
660                "caret {c}"
661            );
662        }
663        let w = "Hello world  foo, bar\nnext para";
664        assert_eq!(
665            del_word(w, (0, 12), false),
666            ("Hello  foo, bar\nnext para".into(), (0, 6))
667        );
668        assert_eq!(
669            del_word(w, (0, 17), false),
670            ("Hello world  foo bar\nnext para".into(), (0, 16))
671        );
672        // At a paragraph's start it joins the paragraph before.
673        assert_eq!(
674            del_word(w, (1, 0), false),
675            ("Hello world  foo, barnext para".into(), (0, 21))
676        );
677        assert_eq!(
678            del_word("ab\n  cd", (1, 2), false),
679            ("ab\ncd".into(), (1, 0))
680        );
681        // A line break is a word of its own.
682        let mut b = body("ab");
683        insert(&mut b, (0, 2), "\u{b}cd", None);
684        assert_eq!(delete_word(&mut b, (0, 3), false), (0, 2));
685        assert_eq!(b.text(), "abcd");
686        let mut b = body("ab");
687        insert(&mut b, (0, 2), "\u{b}cd", None);
688        assert_eq!(delete_word(&mut b, (0, 2), true), (0, 2));
689        assert_eq!(b.text(), "abcd");
690        assert_eq!(del_word("x", (0, 0), false), ("x".into(), (0, 0)));
691        // Emoji sequences and combining marks are never split.
692        assert_eq!(
693            del_word("a 👨\u{200d}👩\u{200d}👧", (0, 7), false),
694            ("a ".into(), (0, 2))
695        );
696        assert_eq!(
697            del_word("ce\u{301}te x", (0, 5), false),
698            (" x".into(), (0, 0))
699        );
700        assert_eq!(
701            del_word("ce\u{301}te x", (0, 6), false),
702            ("x".into(), (0, 0))
703        );
704        // Each CJK character is a word.
705        assert_eq!(del_word("日本語", (0, 3), false), ("日本".into(), (0, 2)));
706    }
707
708    #[test]
709    fn ctrl_delete_like_powerpoint() {
710        let t = "ab   cd...ef gh";
711        let want = [
712            ("cd...ef gh", 0),
713            ("a   cd...ef gh", 1),
714            ("ab...ef gh", 2),
715            ("ab cd...ef gh", 3),
716            ("ab  cd...ef gh", 4),
717            ("ab...ef gh", 2),
718            ("ab   c...ef gh", 6),
719            ("ab   cdef gh", 7),
720            ("ab   cd. ef gh", 9),
721            ("ab   cd.. ef gh", 10),
722            ("ab   cd... gh", 11),
723            ("ab   cd...e gh", 11),
724            ("ab   cd...ef", 12),
725            ("ab   cd...ef", 12),
726            ("ab   cd...ef g", 14),
727            ("ab   cd...ef gh", 15),
728        ];
729        for (c, (txt, caret)) in want.iter().enumerate() {
730            assert_eq!(
731                del_word(t, (0, c), true),
732                (txt.to_string(), (0, *caret)),
733                "caret {c}"
734            );
735        }
736        let w = "Hello world  foo, bar\nnext para";
737        let want = [
738            (0, "world  foo, bar\nnext para", 0),
739            (2, "He world  foo, bar\nnext para", 2),
740            (5, "Hello  foo, bar\nnext para", 5),
741            (6, "Hello foo, bar\nnext para", 6),
742            (8, "Hello wo  foo, bar\nnext para", 8),
743            (11, "Hello world, bar\nnext para", 11),
744            (12, "Hello world foo, bar\nnext para", 12),
745            (16, "Hello world  foobar\nnext para", 16),
746            (17, "Hello world  foo,\nnext para", 17),
747            (18, "Hello world  foo, \nnext para", 18),
748            (21, "Hello world  foo, barnext para", 21),
749        ];
750        for (c, txt, caret) in want {
751            assert_eq!(
752                del_word(w, (0, c), true),
753                (txt.to_string(), (0, caret)),
754                "caret {c}"
755            );
756        }
757        assert_eq!(
758            del_word(w, (1, 0), true),
759            ("Hello world  foo, bar\npara".into(), (1, 0))
760        );
761        assert_eq!(
762            del_word(w, (1, 2), true),
763            ("Hello world  foo, bar\nne para".into(), (1, 2))
764        );
765        assert_eq!(
766            del_word("ab  \ncd", (0, 2), true),
767            ("ab\ncd".into(), (0, 2))
768        );
769        assert_eq!(
770            del_word("ab  \ncd", (0, 3), true),
771            ("ab\ncd".into(), (0, 2))
772        );
773        assert_eq!(del_word("ab\t cd", (0, 2), true), ("ab cd".into(), (0, 2)));
774        assert_eq!(
775            del_word("ab cd(x) ef", (0, 3), true),
776            ("ab (x) ef".into(), (0, 3))
777        );
778        assert_eq!(
779            del_word("ab cd, ef", (0, 3), true),
780            ("ab, ef".into(), (0, 2))
781        );
782        assert_eq!(
783            del_word("ab. (cd) ef", (0, 5), true),
784            ("ab. () ef".into(), (0, 5))
785        );
786        assert_eq!(
787            del_word("ab \"cd\" ef", (0, 4), true),
788            ("ab \"\" ef".into(), (0, 4))
789        );
790        assert_eq!(del_word("ab, cd", (0, 4), true), ("ab, ".into(), (0, 4)));
791        // Right-to-left text deletes in logical order like any other.
792        assert_eq!(
793            del_word("مرحبا بالعالم hello", (0, 6), true),
794            ("مرحبا hello".into(), (0, 6))
795        );
796        assert_eq!(
797            del_word("مرحبا بالعالم hello", (0, 8), false),
798            ("مرحبا لعالم hello".into(), (0, 6))
799        );
800        // Numbers keep their separators: no space is pushed into "3.14".
801        assert_eq!(del_word("3.x 14", (0, 2), true), ("3.14".into(), (0, 2)));
802        // Hostile positions clamp.
803        assert_eq!(del_word("ab", (9, 9), true).0, "ab");
804        assert_eq!(del_word("", (0, 0), false).0, "");
805        let mut empty = TextBody::default();
806        assert_eq!(delete_word(&mut empty, (3, 3), true), (0, 0));
807    }
808
809    #[test]
810    fn paragraph_moves() {
811        let b = body("one two\nthree\n\nfour");
812        assert_eq!(para_down(&b, (0, 2)), (1, 0));
813        assert_eq!(para_down(&b, (1, 0)), (2, 0));
814        assert_eq!(para_down(&b, (3, 1)), (3, 4));
815        assert_eq!(para_up(&b, (3, 2)), (3, 0));
816        assert_eq!(para_up(&b, (3, 0)), (2, 0));
817        assert_eq!(para_up(&b, (0, 0)), (0, 0));
818        assert_eq!(word_move(&b, (0, 4), true), (0, 7));
819        assert_eq!(word_move(&b, (0, 7), true), (1, 0));
820        assert_eq!(word_move(&b, (1, 0), false), (0, 7));
821        assert_eq!(word_move(&b, (2, 0), true), (3, 0));
822        assert_eq!(word_move(&b, (3, 4), true), (3, 4));
823        assert_eq!(word_move(&b, (0, 0), false), (0, 0));
824    }
825
826    #[test]
827    fn case_cycle() {
828        assert_eq!(next_case("hello world"), "title");
829        assert_eq!(next_case("Hello World"), "upper");
830        assert_eq!(next_case("HELLO WORLD"), "lower");
831        assert_eq!(next_case("Hello world"), "upper");
832        assert_eq!(next_case("hELLO wORLD"), "upper");
833        assert_eq!(next_case("hello. world is HERE"), "upper");
834        assert_eq!(next_case("123"), "upper");
835    }
836}