use crate::text::{Paragraph, Run, RunKind, RunProps, TextBody};
use crate::words;
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) }
}
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()
}
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())
}
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 {
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)
}
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;
}
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())
}
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();
}
}
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();
}
}
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
}
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
}
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()
}
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))
}
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),
}
}
}
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)
}
}
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),
)
}
}
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)
}
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
}
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)
}
}
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),
)
}
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"
}
}
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
}
_ => {
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");
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);
}
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))
);
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))
);
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)));
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))
);
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)));
assert_eq!(
del_word("مرحبا بالعالم hello", (0, 6), true),
("مرحبا hello".into(), (0, 6))
);
assert_eq!(
del_word("مرحبا بالعالم hello", (0, 8), false),
("مرحبا لعالم hello".into(), (0, 6))
);
assert_eq!(del_word("3.x 14", (0, 2), true), ("3.14".into(), (0, 2)));
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");
}
}