679 lines
25 KiB
Rust
679 lines
25 KiB
Rust
//! Text editing on a [`TextBody`]: positions are (paragraph, character offset). Inserting text
|
|
//! with `\n` splits paragraphs; formatting a range splits runs at its ends.
|
|
|
|
use crate::text::{Paragraph, Run, RunKind, RunProps, TextBody};
|
|
use crate::words;
|
|
|
|
/// (paragraph, char offset in the paragraph).
|
|
pub type Pos = (usize, usize);
|
|
|
|
fn clamp(body: &TextBody, p: Pos) -> Pos {
|
|
if body.paragraphs.is_empty() {
|
|
return (0, 0);
|
|
}
|
|
let pi = p.0.min(body.paragraphs.len() - 1);
|
|
let n = body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0);
|
|
(pi, p.1.min(n))
|
|
}
|
|
|
|
fn order(a: Pos, b: Pos) -> (Pos, Pos) {
|
|
if a <= b { (a, b) } else { (b, a) }
|
|
}
|
|
|
|
/// Split the paragraph's runs so a run boundary falls at char `at`; returns the index of the
|
|
/// first run starting at or after `at`.
|
|
fn split_at(p: &mut Paragraph, at: usize) -> usize {
|
|
let mut pos = 0;
|
|
let mut i = 0;
|
|
while i < p.runs.len() {
|
|
let len = p.runs.get(i).map(Run::char_len).unwrap_or(0);
|
|
if pos == at {
|
|
return i;
|
|
}
|
|
if at < pos + len {
|
|
let Some(r) = p.runs.get(i) else { return i };
|
|
if r.kind != RunKind::Text {
|
|
return i + 1;
|
|
}
|
|
let k = at - pos;
|
|
let byte = r.text.char_indices().nth(k).map(|(b, _)| b).unwrap_or(r.text.len());
|
|
let (a, b) = r.text.split_at(byte);
|
|
let second = Run { text: b.to_string(), props: r.props.clone(), kind: RunKind::Text };
|
|
let first = a.to_string();
|
|
if let Some(r) = p.runs.get_mut(i) {
|
|
r.text = first;
|
|
}
|
|
p.runs.insert(i + 1, second);
|
|
return i + 1;
|
|
}
|
|
pos += len;
|
|
i += 1;
|
|
}
|
|
p.runs.len()
|
|
}
|
|
|
|
/// The explicit run properties in effect at `pos` (the run before the caret, or the end mark).
|
|
pub fn props_at(body: &TextBody, pos: Pos) -> RunProps {
|
|
let (pi, ch) = clamp(body, pos);
|
|
let Some(p) = body.paragraphs.get(pi) else { return RunProps::default() };
|
|
let mut acc = 0;
|
|
let mut last = None;
|
|
for r in &p.runs {
|
|
let len = r.char_len();
|
|
if ch <= acc + len && ch > acc {
|
|
return r.props.clone();
|
|
}
|
|
if acc + len >= ch && ch == 0 {
|
|
return r.props.clone();
|
|
}
|
|
acc += len;
|
|
last = Some(&r.props);
|
|
}
|
|
last.cloned().unwrap_or_else(|| p.end_props.clone())
|
|
}
|
|
|
|
/// Insert `text` at `pos` (with `props`, else the props at `pos`). `\n` starts a new paragraph
|
|
/// (same level and paragraph props); `\u{b}` inserts a line break. Returns the caret after it.
|
|
pub fn insert(body: &mut TextBody, pos: Pos, text: &str, props: Option<RunProps>) -> Pos {
|
|
if body.paragraphs.is_empty() {
|
|
body.paragraphs.push(Paragraph::default());
|
|
}
|
|
let (mut pi, mut ch) = clamp(body, pos);
|
|
let props = props.unwrap_or_else(|| props_at(body, (pi, ch)));
|
|
let text = text.replace("\r\n", "\n").replace('\r', "\n");
|
|
let mut first = true;
|
|
for part in text.split('\n') {
|
|
if !first {
|
|
// Split paragraph pi at ch.
|
|
let Some(p) = body.paragraphs.get_mut(pi) else { break };
|
|
let idx = split_at(p, ch);
|
|
let tail: Vec<Run> = p.runs.drain(idx..).collect();
|
|
let newp = Paragraph { level: p.level, props: p.props.clone(), runs: tail, end_props: p.end_props.clone() };
|
|
p.end_props = props.clone();
|
|
body.paragraphs.insert(pi + 1, newp);
|
|
pi += 1;
|
|
ch = 0;
|
|
}
|
|
first = false;
|
|
for (k, seg) in part.split('\u{b}').enumerate() {
|
|
let Some(p) = body.paragraphs.get_mut(pi) else { break };
|
|
if k > 0 {
|
|
let idx = split_at(p, ch);
|
|
p.runs.insert(idx, Run { text: String::new(), props: props.clone(), kind: RunKind::Break });
|
|
ch += 1;
|
|
}
|
|
if seg.is_empty() {
|
|
continue;
|
|
}
|
|
let idx = split_at(p, ch);
|
|
p.runs.insert(idx, Run::with(seg, props.clone()));
|
|
ch += seg.chars().count();
|
|
}
|
|
if let Some(p) = body.paragraphs.get_mut(pi) {
|
|
p.normalize();
|
|
if p.runs.is_empty() {
|
|
p.end_props = props.clone();
|
|
}
|
|
}
|
|
}
|
|
(pi, ch)
|
|
}
|
|
|
|
/// Delete the range between `a` and `b`; returns the caret at the start.
|
|
pub fn delete(body: &mut TextBody, a: Pos, b: Pos) -> Pos {
|
|
if body.paragraphs.is_empty() {
|
|
return (0, 0);
|
|
}
|
|
let (s, e) = order(clamp(body, a), clamp(body, b));
|
|
if s == e {
|
|
return s;
|
|
}
|
|
if s.0 == e.0 {
|
|
if let Some(p) = body.paragraphs.get_mut(s.0) {
|
|
let end_props = props_of_range(p, s.1);
|
|
let i0 = split_at(p, s.1);
|
|
let i1 = split_at(p, e.1);
|
|
if i1 > i0 {
|
|
p.runs.drain(i0..i1);
|
|
}
|
|
p.normalize();
|
|
if p.runs.is_empty() {
|
|
p.end_props = end_props;
|
|
}
|
|
}
|
|
return s;
|
|
}
|
|
// Keep the head of s.0 and the tail of e.0, joined; drop paragraphs between.
|
|
let tail: Vec<Run> = match body.paragraphs.get_mut(e.0) {
|
|
Some(p) => {
|
|
let i = split_at(p, e.1);
|
|
p.runs.drain(i..).collect()
|
|
}
|
|
None => vec![],
|
|
};
|
|
if let Some(p) = body.paragraphs.get_mut(s.0) {
|
|
let i = split_at(p, s.1);
|
|
p.runs.truncate(i);
|
|
p.runs.extend(tail);
|
|
p.normalize();
|
|
}
|
|
if e.0 > s.0 && e.0 < body.paragraphs.len() {
|
|
body.paragraphs.drain(s.0 + 1..=e.0);
|
|
}
|
|
s
|
|
}
|
|
|
|
fn props_of_range(p: &Paragraph, at: usize) -> RunProps {
|
|
let mut acc = 0;
|
|
for r in &p.runs {
|
|
let len = r.char_len();
|
|
if at < acc + len || (at == acc + len && at > 0) {
|
|
return r.props.clone();
|
|
}
|
|
acc += len;
|
|
}
|
|
p.runs.last().map(|r| r.props.clone()).unwrap_or_else(|| p.end_props.clone())
|
|
}
|
|
|
|
/// Apply `f` to the run properties of every run in [a, b). An empty range changes the empty
|
|
/// paragraph's end mark (so typing there picks the formatting up).
|
|
pub fn format(body: &mut TextBody, a: Pos, b: Pos, f: &dyn Fn(&mut RunProps)) {
|
|
if body.paragraphs.is_empty() {
|
|
return;
|
|
}
|
|
let (s, e) = order(clamp(body, a), clamp(body, b));
|
|
for pi in s.0..=e.0 {
|
|
let Some(p) = body.paragraphs.get_mut(pi) else { continue };
|
|
let from = if pi == s.0 { s.1 } else { 0 };
|
|
let to = if pi == e.0 { e.1 } else { p.char_len() };
|
|
if p.runs.is_empty() || (from == to && s == e) {
|
|
f(&mut p.end_props);
|
|
continue;
|
|
}
|
|
let i0 = split_at(p, from);
|
|
let i1 = split_at(p, to);
|
|
for r in p.runs.iter_mut().take(i1).skip(i0) {
|
|
f(&mut r.props);
|
|
}
|
|
if to == p.char_len() {
|
|
f(&mut p.end_props);
|
|
}
|
|
p.normalize();
|
|
}
|
|
}
|
|
|
|
/// Apply `f` to every run in the body (and end marks).
|
|
pub fn format_all(body: &mut TextBody, f: &dyn Fn(&mut RunProps)) {
|
|
for p in &mut body.paragraphs {
|
|
for r in &mut p.runs {
|
|
f(&mut r.props);
|
|
}
|
|
f(&mut p.end_props);
|
|
p.normalize();
|
|
}
|
|
}
|
|
|
|
/// The text between two positions (`\n` between paragraphs).
|
|
pub fn text_range(body: &TextBody, a: Pos, b: Pos) -> String {
|
|
let (s, e) = order(clamp(body, a), clamp(body, b));
|
|
let mut out = String::new();
|
|
for pi in s.0..=e.0 {
|
|
let Some(p) = body.paragraphs.get(pi) else { continue };
|
|
let t: Vec<char> = p.text().chars().collect();
|
|
let from = if pi == s.0 { s.1 } else { 0 };
|
|
let to = if pi == e.0 { e.1 } else { t.len() };
|
|
out.extend(t.get(from.min(t.len())..to.min(t.len())).unwrap_or(&[]));
|
|
if pi != e.0 {
|
|
out.push('\n');
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// A formatted copy of the range.
|
|
pub fn slice(body: &TextBody, a: Pos, b: Pos) -> TextBody {
|
|
let (s, e) = order(clamp(body, a), clamp(body, b));
|
|
let mut out = TextBody { body: body.body.clone(), list_style: body.list_style.clone(), paragraphs: vec![] };
|
|
for pi in s.0..=e.0 {
|
|
let Some(p) = body.paragraphs.get(pi) else { continue };
|
|
let mut p = p.clone();
|
|
let to = if pi == e.0 { e.1 } else { p.char_len() };
|
|
let i1 = split_at(&mut p, to);
|
|
p.runs.truncate(i1);
|
|
let from = if pi == s.0 { s.1 } else { 0 };
|
|
let i0 = split_at(&mut p, from);
|
|
p.runs.drain(..i0);
|
|
out.paragraphs.push(p);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Insert a formatted body at `pos` (paste): first paragraph merges into the current one.
|
|
pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
|
|
let mut caret = clamp(body, pos);
|
|
for (k, sp) in src.paragraphs.iter().enumerate() {
|
|
if k > 0 {
|
|
caret = insert(body, caret, "\n", None);
|
|
if let Some(p) = body.paragraphs.get_mut(caret.0) {
|
|
p.level = sp.level;
|
|
p.props = sp.props.clone();
|
|
}
|
|
}
|
|
for r in &sp.runs {
|
|
match r.kind {
|
|
RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())),
|
|
RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())),
|
|
_ => {
|
|
if let Some(p) = body.paragraphs.get_mut(caret.0) {
|
|
let idx = split_at(p, caret.1);
|
|
p.runs.insert(idx, r.clone());
|
|
caret.1 += r.char_len();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
caret
|
|
}
|
|
|
|
fn para_text(body: &TextBody, pi: usize) -> String {
|
|
body.paragraphs.get(pi).map(Paragraph::text).unwrap_or_default()
|
|
}
|
|
|
|
/// The word a double-click selects around `pos` (see [`words::word_at`]): the word without its
|
|
/// trailing spaces, as PowerPoint selects it.
|
|
pub fn word_at(body: &TextBody, pos: Pos) -> (Pos, Pos) {
|
|
let (pi, ch) = clamp(body, pos);
|
|
let (s, e) = words::word_at(¶_text(body, pi), ch);
|
|
((pi, s), (pi, e))
|
|
}
|
|
|
|
/// Next/previous word stop (Ctrl+Right/Left on Windows, Option+arrows on the Mac). At the end of
|
|
/// a paragraph Ctrl+Right goes to the start of the next one; at its start Ctrl+Left goes to the
|
|
/// end of the previous one.
|
|
pub fn word_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
|
|
let (pi, ch) = clamp(body, pos);
|
|
let t = para_text(body, pi);
|
|
if forward {
|
|
match words::next_stop(&t, ch) {
|
|
Some(s) => (pi, s),
|
|
None if pi + 1 < body.paragraphs.len() => (pi + 1, 0),
|
|
None => (pi, ch),
|
|
}
|
|
} else {
|
|
match words::prev_stop(&t, ch) {
|
|
Some(s) => (pi, s),
|
|
None if pi > 0 => (pi - 1, body.paragraphs.get(pi - 1).map(Paragraph::char_len).unwrap_or(0)),
|
|
None => (pi, 0),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ctrl+Up: the start of the paragraph, or of the previous one when already there.
|
|
pub fn para_up(body: &TextBody, pos: Pos) -> Pos {
|
|
let (pi, ch) = clamp(body, pos);
|
|
if ch > 0 { (pi, 0) } else { (pi.saturating_sub(1), 0) }
|
|
}
|
|
|
|
/// Ctrl+Down: the start of the next paragraph, or the end of the last one.
|
|
pub fn para_down(body: &TextBody, pos: Pos) -> Pos {
|
|
let (pi, _) = clamp(body, pos);
|
|
if pi + 1 < body.paragraphs.len() { (pi + 1, 0) } else { (pi, body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0)) }
|
|
}
|
|
|
|
/// Delete a word from `pos` as Ctrl+Backspace (`forward == false`) or Ctrl+Delete does in
|
|
/// PowerPoint; returns the caret.
|
|
///
|
|
/// Ctrl+Backspace deletes back to the previous word stop (at a paragraph's start it joins the
|
|
/// paragraph to the one before). Ctrl+Delete deletes the rest of the word, or the spaces after a
|
|
/// word and the next word, or up to the next stop (see [`words::delete_forward_end`]), then
|
|
/// tidies the spacing as PowerPoint's smart cut does: spaces left before closing punctuation or
|
|
/// at the end of the paragraph after a word go, and sentence punctuation left against a word
|
|
/// gets a space. At a paragraph's end it joins the next paragraph.
|
|
pub fn delete_word(body: &mut TextBody, pos: Pos, forward: bool) -> Pos {
|
|
if body.paragraphs.is_empty() {
|
|
return (0, 0);
|
|
}
|
|
let (pi, ch) = clamp(body, pos);
|
|
let t = para_text(body, pi);
|
|
if !forward {
|
|
let to = match words::prev_stop(&t, ch) {
|
|
Some(s) => (pi, s),
|
|
None => char_move(body, (pi, ch), false),
|
|
};
|
|
return delete(body, to, (pi, ch));
|
|
}
|
|
let Some(e) = words::delete_forward_end(&t, ch) else {
|
|
let to = char_move(body, (pi, ch), true);
|
|
return delete(body, (pi, ch), to);
|
|
};
|
|
let at = delete(body, (pi, ch), (pi, e));
|
|
smart_space(body, at)
|
|
}
|
|
|
|
/// PowerPoint's smart-cut spacing after a word was deleted at `at`; returns the caret.
|
|
fn smart_space(body: &mut TextBody, at: Pos) -> Pos {
|
|
let (pi, s) = at;
|
|
let chars: Vec<char> = para_text(body, pi).chars().collect();
|
|
let after = chars.get(s).copied();
|
|
let before = s.checked_sub(1).and_then(|i| chars.get(i)).copied();
|
|
if after.is_none_or(words::is_closing_punct) && before.is_some_and(|c| c == ' ') {
|
|
let mut w = s;
|
|
while w > 0 && chars.get(w - 1) == Some(&' ') {
|
|
w -= 1;
|
|
}
|
|
if w > 0 && chars.get(w - 1).is_some_and(|c| c.is_alphanumeric()) {
|
|
return delete(body, (pi, w), (pi, s));
|
|
}
|
|
return at;
|
|
}
|
|
if let (Some(b), Some(a)) = (before, after)
|
|
&& words::is_sentence_punct(b)
|
|
&& a.is_alphanumeric()
|
|
{
|
|
let digits = s >= 2 && chars.get(s - 2).is_some_and(|c| c.is_numeric()) && a.is_numeric();
|
|
if !digits {
|
|
return insert(body, at, " ", None);
|
|
}
|
|
}
|
|
at
|
|
}
|
|
|
|
/// One character left/right across paragraph boundaries.
|
|
pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
|
|
let (pi, ch) = clamp(body, pos);
|
|
let n = body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0);
|
|
if forward {
|
|
if ch < n {
|
|
(pi, ch + 1)
|
|
} else if pi + 1 < body.paragraphs.len() {
|
|
(pi + 1, 0)
|
|
} else {
|
|
(pi, ch)
|
|
}
|
|
} else if ch > 0 {
|
|
(pi, ch - 1)
|
|
} else if pi > 0 {
|
|
(pi - 1, body.paragraphs.get(pi - 1).map(Paragraph::char_len).unwrap_or(0))
|
|
} else {
|
|
(0, 0)
|
|
}
|
|
}
|
|
|
|
/// End of the body.
|
|
pub fn end(body: &TextBody) -> Pos {
|
|
let pi = body.paragraphs.len().saturating_sub(1);
|
|
(pi, body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0))
|
|
}
|
|
|
|
/// The case Shift+F3 changes `s` to next, as PowerPoint cycles it: lowercase → Capitalize Each
|
|
/// Word → UPPERCASE → lowercase; any other mix goes to UPPERCASE first.
|
|
pub fn next_case(s: &str) -> &'static str {
|
|
if s == s.to_lowercase() && s.chars().any(char::is_alphabetic) {
|
|
"title"
|
|
} else if s == s.to_uppercase() && s.chars().any(char::is_alphabetic) {
|
|
"lower"
|
|
} else {
|
|
"upper"
|
|
}
|
|
}
|
|
|
|
/// Change case: `upper`, `lower`, `sentence`, `title`, `toggle`.
|
|
pub fn change_case(s: &str, mode: &str) -> String {
|
|
match mode {
|
|
"upper" => s.to_uppercase(),
|
|
"lower" => s.to_lowercase(),
|
|
"toggle" => s.chars().map(|c| if c.is_uppercase() { c.to_lowercase().collect::<String>() } else { c.to_uppercase().collect() }).collect(),
|
|
"title" => {
|
|
let mut out = String::new();
|
|
let mut start = true;
|
|
for c in s.chars() {
|
|
if start && c.is_alphabetic() {
|
|
out.extend(c.to_uppercase());
|
|
start = false;
|
|
} else {
|
|
out.extend(c.to_lowercase());
|
|
}
|
|
if c.is_whitespace() {
|
|
start = true;
|
|
}
|
|
}
|
|
out
|
|
}
|
|
_ => {
|
|
// sentence
|
|
let mut out = String::new();
|
|
let mut start = true;
|
|
for c in s.chars() {
|
|
if start && c.is_alphabetic() {
|
|
out.extend(c.to_uppercase());
|
|
start = false;
|
|
} else {
|
|
out.extend(c.to_lowercase());
|
|
}
|
|
if matches!(c, '.' | '!' | '?') {
|
|
start = true;
|
|
}
|
|
}
|
|
out
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn body(t: &str) -> TextBody {
|
|
TextBody::from_text(t)
|
|
}
|
|
|
|
#[test]
|
|
fn insert_and_split() {
|
|
let mut b = body("Hello");
|
|
let c = insert(&mut b, (0, 5), " world\nNext", None);
|
|
assert_eq!(b.text(), "Hello world\nNext");
|
|
assert_eq!(c, (1, 4));
|
|
let c = insert(&mut b, (0, 0), ">", None);
|
|
assert_eq!(c, (0, 1));
|
|
assert_eq!(b.paragraphs[0].runs.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn delete_within_and_across() {
|
|
let mut b = body("abc\ndef\nghi");
|
|
assert_eq!(delete(&mut b, (0, 1), (2, 1)), (0, 1));
|
|
assert_eq!(b.text(), "ahi");
|
|
let mut b = body("abcdef");
|
|
delete(&mut b, (0, 4), (0, 2));
|
|
assert_eq!(b.text(), "abef");
|
|
// hostile positions
|
|
delete(&mut b, (99, 99), (0, 0));
|
|
assert_eq!(b.text(), "");
|
|
}
|
|
|
|
#[test]
|
|
fn format_splits_runs() {
|
|
let mut b = body("abcdef");
|
|
format(&mut b, (0, 2), (0, 4), &|p| p.bold = Some(true));
|
|
let p = &b.paragraphs[0];
|
|
assert_eq!(p.runs.len(), 3);
|
|
assert_eq!(p.runs[1].text, "cd");
|
|
assert_eq!(p.runs[1].props.bold, Some(true));
|
|
assert_eq!(props_at(&b, (0, 3)).bold, Some(true));
|
|
assert_eq!(text_range(&b, (0, 1), (0, 5)), "bcde");
|
|
let s = slice(&b, (0, 1), (0, 5));
|
|
assert_eq!(s.text(), "bcde");
|
|
let mut c = body("XY");
|
|
let caret = insert_body(&mut c, (0, 1), &s);
|
|
assert_eq!(c.text(), "XbcdeY");
|
|
assert_eq!(caret, (0, 5));
|
|
}
|
|
|
|
#[test]
|
|
fn words_and_moves() {
|
|
let b = body("Hello brave world");
|
|
assert_eq!(word_at(&b, (0, 7)), ((0, 6), (0, 11)));
|
|
assert_eq!(word_move(&b, (0, 0), true), (0, 6));
|
|
assert_eq!(word_move(&b, (0, 8), false), (0, 6));
|
|
assert_eq!(char_move(&b, (0, 17), true), (0, 17));
|
|
assert_eq!(change_case("hello world. again", "sentence"), "Hello world. Again");
|
|
assert_eq!(change_case("hello world", "title"), "Hello World");
|
|
}
|
|
|
|
#[test]
|
|
fn line_break_insert() {
|
|
let mut b = body("ab");
|
|
let c = insert(&mut b, (0, 1), "\u{b}", None);
|
|
assert_eq!(c, (0, 2));
|
|
assert_eq!(b.paragraphs[0].char_len(), 3);
|
|
assert_eq!(b.paragraphs.len(), 1);
|
|
}
|
|
|
|
/// Ctrl+Backspace / Ctrl+Delete from `at`: the text and caret after (PowerPoint's results).
|
|
fn del_word(text: &str, at: Pos, forward: bool) -> (String, Pos) {
|
|
let mut b = body(text);
|
|
let c = delete_word(&mut b, at, forward);
|
|
(b.text(), c)
|
|
}
|
|
|
|
#[test]
|
|
fn ctrl_backspace_like_powerpoint() {
|
|
let t = "ab cd...ef gh";
|
|
let want = [
|
|
("ab cd...ef gh", 0),
|
|
("b cd...ef gh", 0),
|
|
(" cd...ef gh", 0),
|
|
(" cd...ef gh", 0),
|
|
(" cd...ef gh", 0),
|
|
("cd...ef gh", 0),
|
|
("ab d...ef gh", 5),
|
|
("ab ...ef gh", 5),
|
|
("ab cd..ef gh", 7),
|
|
("ab cd.ef gh", 7),
|
|
("ab cdef gh", 7),
|
|
("ab cd...f gh", 10),
|
|
("ab cd... gh", 10),
|
|
("ab cd...gh", 10),
|
|
("ab cd...ef h", 13),
|
|
("ab cd...ef ", 13),
|
|
];
|
|
for (c, (txt, caret)) in want.iter().enumerate() {
|
|
assert_eq!(del_word(t, (0, c), false), (txt.to_string(), (0, *caret)), "caret {c}");
|
|
}
|
|
let w = "Hello world foo, bar\nnext para";
|
|
assert_eq!(del_word(w, (0, 12), false), ("Hello foo, bar\nnext para".into(), (0, 6)));
|
|
assert_eq!(del_word(w, (0, 17), false), ("Hello world foo bar\nnext para".into(), (0, 16)));
|
|
// At a paragraph's start it joins the paragraph before.
|
|
assert_eq!(del_word(w, (1, 0), false), ("Hello world foo, barnext para".into(), (0, 21)));
|
|
assert_eq!(del_word("ab\n cd", (1, 2), false), ("ab\ncd".into(), (1, 0)));
|
|
// A line break is a word of its own.
|
|
let mut b = body("ab");
|
|
insert(&mut b, (0, 2), "\u{b}cd", None);
|
|
assert_eq!(delete_word(&mut b, (0, 3), false), (0, 2));
|
|
assert_eq!(b.text(), "abcd");
|
|
let mut b = body("ab");
|
|
insert(&mut b, (0, 2), "\u{b}cd", None);
|
|
assert_eq!(delete_word(&mut b, (0, 2), true), (0, 2));
|
|
assert_eq!(b.text(), "abcd");
|
|
assert_eq!(del_word("x", (0, 0), false), ("x".into(), (0, 0)));
|
|
// Emoji sequences and combining marks are never split.
|
|
assert_eq!(del_word("a 👨\u{200d}👩\u{200d}👧", (0, 7), false), ("a ".into(), (0, 2)));
|
|
assert_eq!(del_word("ce\u{301}te x", (0, 5), false), (" x".into(), (0, 0)));
|
|
assert_eq!(del_word("ce\u{301}te x", (0, 6), false), ("x".into(), (0, 0)));
|
|
// Each CJK character is a word.
|
|
assert_eq!(del_word("日本語", (0, 3), false), ("日本".into(), (0, 2)));
|
|
}
|
|
|
|
#[test]
|
|
fn ctrl_delete_like_powerpoint() {
|
|
let t = "ab cd...ef gh";
|
|
let want = [
|
|
("cd...ef gh", 0),
|
|
("a cd...ef gh", 1),
|
|
("ab...ef gh", 2),
|
|
("ab cd...ef gh", 3),
|
|
("ab cd...ef gh", 4),
|
|
("ab...ef gh", 2),
|
|
("ab c...ef gh", 6),
|
|
("ab cdef gh", 7),
|
|
("ab cd. ef gh", 9),
|
|
("ab cd.. ef gh", 10),
|
|
("ab cd... gh", 11),
|
|
("ab cd...e gh", 11),
|
|
("ab cd...ef", 12),
|
|
("ab cd...ef", 12),
|
|
("ab cd...ef g", 14),
|
|
("ab cd...ef gh", 15),
|
|
];
|
|
for (c, (txt, caret)) in want.iter().enumerate() {
|
|
assert_eq!(del_word(t, (0, c), true), (txt.to_string(), (0, *caret)), "caret {c}");
|
|
}
|
|
let w = "Hello world foo, bar\nnext para";
|
|
let want = [
|
|
(0, "world foo, bar\nnext para", 0),
|
|
(2, "He world foo, bar\nnext para", 2),
|
|
(5, "Hello foo, bar\nnext para", 5),
|
|
(6, "Hello foo, bar\nnext para", 6),
|
|
(8, "Hello wo foo, bar\nnext para", 8),
|
|
(11, "Hello world, bar\nnext para", 11),
|
|
(12, "Hello world foo, bar\nnext para", 12),
|
|
(16, "Hello world foobar\nnext para", 16),
|
|
(17, "Hello world foo,\nnext para", 17),
|
|
(18, "Hello world foo, \nnext para", 18),
|
|
(21, "Hello world foo, barnext para", 21),
|
|
];
|
|
for (c, txt, caret) in want {
|
|
assert_eq!(del_word(w, (0, c), true), (txt.to_string(), (0, caret)), "caret {c}");
|
|
}
|
|
assert_eq!(del_word(w, (1, 0), true), ("Hello world foo, bar\npara".into(), (1, 0)));
|
|
assert_eq!(del_word(w, (1, 2), true), ("Hello world foo, bar\nne para".into(), (1, 2)));
|
|
assert_eq!(del_word("ab \ncd", (0, 2), true), ("ab\ncd".into(), (0, 2)));
|
|
assert_eq!(del_word("ab \ncd", (0, 3), true), ("ab\ncd".into(), (0, 2)));
|
|
assert_eq!(del_word("ab\t cd", (0, 2), true), ("ab cd".into(), (0, 2)));
|
|
assert_eq!(del_word("ab cd(x) ef", (0, 3), true), ("ab (x) ef".into(), (0, 3)));
|
|
assert_eq!(del_word("ab cd, ef", (0, 3), true), ("ab, ef".into(), (0, 2)));
|
|
assert_eq!(del_word("ab. (cd) ef", (0, 5), true), ("ab. () ef".into(), (0, 5)));
|
|
assert_eq!(del_word("ab \"cd\" ef", (0, 4), true), ("ab \"\" ef".into(), (0, 4)));
|
|
assert_eq!(del_word("ab, cd", (0, 4), true), ("ab, ".into(), (0, 4)));
|
|
// Right-to-left text deletes in logical order like any other.
|
|
assert_eq!(del_word("مرحبا بالعالم hello", (0, 6), true), ("مرحبا hello".into(), (0, 6)));
|
|
assert_eq!(del_word("مرحبا بالعالم hello", (0, 8), false), ("مرحبا لعالم hello".into(), (0, 6)));
|
|
// Numbers keep their separators: no space is pushed into "3.14".
|
|
assert_eq!(del_word("3.x 14", (0, 2), true), ("3.14".into(), (0, 2)));
|
|
// Hostile positions clamp.
|
|
assert_eq!(del_word("ab", (9, 9), true).0, "ab");
|
|
assert_eq!(del_word("", (0, 0), false).0, "");
|
|
let mut empty = TextBody::default();
|
|
assert_eq!(delete_word(&mut empty, (3, 3), true), (0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn paragraph_moves() {
|
|
let b = body("one two\nthree\n\nfour");
|
|
assert_eq!(para_down(&b, (0, 2)), (1, 0));
|
|
assert_eq!(para_down(&b, (1, 0)), (2, 0));
|
|
assert_eq!(para_down(&b, (3, 1)), (3, 4));
|
|
assert_eq!(para_up(&b, (3, 2)), (3, 0));
|
|
assert_eq!(para_up(&b, (3, 0)), (2, 0));
|
|
assert_eq!(para_up(&b, (0, 0)), (0, 0));
|
|
assert_eq!(word_move(&b, (0, 4), true), (0, 7));
|
|
assert_eq!(word_move(&b, (0, 7), true), (1, 0));
|
|
assert_eq!(word_move(&b, (1, 0), false), (0, 7));
|
|
assert_eq!(word_move(&b, (2, 0), true), (3, 0));
|
|
assert_eq!(word_move(&b, (3, 4), true), (3, 4));
|
|
assert_eq!(word_move(&b, (0, 0), false), (0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn case_cycle() {
|
|
assert_eq!(next_case("hello world"), "title");
|
|
assert_eq!(next_case("Hello World"), "upper");
|
|
assert_eq!(next_case("HELLO WORLD"), "lower");
|
|
assert_eq!(next_case("Hello world"), "upper");
|
|
assert_eq!(next_case("hELLO wORLD"), "upper");
|
|
assert_eq!(next_case("hello. world is HERE"), "upper");
|
|
assert_eq!(next_case("123"), "upper");
|
|
}
|
|
}
|