vectorcraft/crates/text/src/edit.rs

254 lines
8.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Rich-text editing on style runs (range replace, range styling with run splitting) and
//! character/word/paragraph navigation on plain text. Shared by the engine commands
//! (`text.editRange`, `text.setRangeStyle`) and the Type tool.
use std::ops::Range;
use vectorcraft_doc::{CharStyle, TextRun};
/// Total byte length of `runs`.
pub fn runs_len(runs: &[TextRun]) -> usize {
runs.iter().map(|r| r.text.len()).sum()
}
/// Clamp `b` to `s.len()` and back to a char boundary.
pub fn floor_char(s: &str, b: usize) -> usize {
let mut b = b.min(s.len());
while b > 0 && !s.is_char_boundary(b) {
b -= 1;
}
b
}
/// Style of the character at `byte` (the character before it when `byte` is at a run end or the
/// text end), used for inserted text.
pub fn style_at(runs: &[TextRun], byte: usize) -> CharStyle {
let mut off = 0;
let mut last = None;
for r in runs {
let end = off + r.text.len();
if byte < end || (byte == end && byte == off) {
return r.style.clone();
}
if !r.text.is_empty() {
last = Some(&r.style);
if byte == end {
return r.style.clone();
}
}
off = end;
}
last.or(runs.first().map(|r| &r.style)).cloned().unwrap_or_default()
}
/// Style for text typed over `start..end` (Illustrator: the first replaced character's style, or
/// the character before the caret).
pub fn insertion_style(runs: &[TextRun], start: usize, end: usize) -> CharStyle {
if end > start {
// The character at `start` (not the one before it).
let mut off = 0;
for r in runs {
if start < off + r.text.len() {
return r.style.clone();
}
off += r.text.len();
}
}
style_at(runs, start)
}
/// Merge adjacent runs with equal styles and drop empty runs (keeping one run so the object
/// remembers its style).
pub fn normalize(runs: &mut Vec<TextRun>) {
let first_style = runs.first().map(|r| r.style.clone());
let mut out: Vec<TextRun> = Vec::with_capacity(runs.len());
for r in runs.drain(..) {
if r.text.is_empty() {
continue;
}
match out.last_mut() {
Some(l) if l.style == r.style => l.text.push_str(&r.text),
_ => out.push(r),
}
}
if out.is_empty() {
out.push(TextRun { text: String::new(), style: first_style.unwrap_or_default() });
}
*runs = out;
}
/// Split runs so that `byte` falls on a run boundary; returns the index of the run starting at
/// `byte` (== `runs.len()` at the end).
fn split_at(runs: &mut Vec<TextRun>, byte: usize) -> usize {
let mut off = 0;
for i in 0..runs.len() {
let len = runs[i].text.len();
if byte == off {
return i;
}
if byte < off + len {
let at = floor_char(&runs[i].text, byte - off);
let tail = runs[i].text.split_off(at);
let style = runs[i].style.clone();
runs.insert(i + 1, TextRun { text: tail, style });
return i + 1;
}
off += len;
}
runs.len()
}
/// Replace `start..end` of the plain text with `insert`, styled like the replaced text (see
/// [`insertion_style`]). Offsets are clamped to char boundaries. Returns the caret after the
/// insertion.
pub fn replace_range(runs: &mut Vec<TextRun>, start: usize, end: usize, insert: &str) -> usize {
let style = insertion_style(runs, start, end);
replace_range_styled(runs, start, end, &[TextRun { text: insert.to_string(), style }])
}
/// Replace `start..end` with styled runs (paste with formatting). Returns the caret after them.
pub fn replace_range_styled(runs: &mut Vec<TextRun>, start: usize, end: usize, insert: &[TextRun]) -> usize {
let plain: String = runs.iter().map(|r| r.text.as_str()).collect();
let (mut a, mut b) = (floor_char(&plain, start), floor_char(&plain, end));
if a > b {
std::mem::swap(&mut a, &mut b);
}
let i = split_at(runs, a);
let j = split_at(runs, b);
runs.splice(i..j, insert.iter().cloned());
normalize(runs);
a + insert.iter().map(|r| r.text.len()).sum::<usize>()
}
/// Apply `f` to the character style of `start..end`, splitting runs at the range ends.
pub fn style_range(runs: &mut Vec<TextRun>, start: usize, end: usize, mut f: impl FnMut(&mut CharStyle)) {
let plain: String = runs.iter().map(|r| r.text.as_str()).collect();
let (mut a, mut b) = (floor_char(&plain, start), floor_char(&plain, end));
if a > b {
std::mem::swap(&mut a, &mut b);
}
if a == b {
// Empty text: style the (single, empty) run so the next typed text uses it.
if plain.is_empty() {
for r in runs.iter_mut() {
f(&mut r.style);
}
}
return;
}
let i = split_at(runs, a);
let j = split_at(runs, b);
for r in &mut runs[i..j] {
f(&mut r.style);
}
normalize(runs);
}
/// Styled copy of `start..end` (for copy/cut with formatting).
pub fn slice_runs(runs: &[TextRun], start: usize, end: usize) -> Vec<TextRun> {
let mut out = vec![];
let mut off = 0;
for r in runs {
let (rs, re) = (off, off + r.text.len());
let (a, b) = (start.max(rs), end.min(re));
if a < b {
let t = &r.text[floor_char(&r.text, a - rs)..floor_char(&r.text, b - rs)];
out.push(TextRun { text: t.to_string(), style: r.style.clone() });
}
off = re;
}
out
}
// ---------- navigation on plain text ----------
pub fn prev_char(s: &str, i: usize) -> usize {
let i = floor_char(s, i);
s[..i].char_indices().next_back().map(|(k, _)| k).unwrap_or(0)
}
pub fn next_char(s: &str, i: usize) -> usize {
let i = floor_char(s, i);
s[i..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len())
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '\'' || c == '’'
}
/// Start of the previous word (Cmd/Alt+Left).
pub fn prev_word(s: &str, i: usize) -> usize {
let i = floor_char(s, i);
let chars: Vec<(usize, char)> = s[..i].char_indices().collect();
let mut k = chars.len();
while k > 0 && !is_word_char(chars[k - 1].1) {
k -= 1;
}
while k > 0 && is_word_char(chars[k - 1].1) {
k -= 1;
}
chars.get(k).map(|c| c.0).unwrap_or(i)
}
/// End of the next word (Cmd/Alt+Right).
pub fn next_word(s: &str, i: usize) -> usize {
let i = floor_char(s, i);
let mut it = s[i..].char_indices().peekable();
while it.peek().is_some_and(|(_, c)| !is_word_char(*c)) {
it.next();
}
while it.peek().is_some_and(|(_, c)| is_word_char(*c)) {
it.next();
}
it.peek().map(|(k, _)| i + k).unwrap_or(s.len())
}
/// The word (or run of spaces / single punctuation) at `i` (double-click selection).
pub fn word_at(s: &str, i: usize) -> Range<usize> {
let i = floor_char(s, i);
let (next, prev) = (s[i..].chars().next(), s[..i].chars().next_back());
// At a word's end, the word wins over the following space or punctuation.
let (c, at) = match (next, prev) {
(Some(n), Some(p)) if !is_word_char(n) && is_word_char(p) => (p, i - p.len_utf8()),
(Some(n), _) => (n, i),
(None, Some(p)) => (p, i - p.len_utf8()),
(None, None) => return i..i,
};
let class = |c: char| {
if is_word_char(c) {
0
} else if c.is_whitespace() && c != '\n' {
1
} else {
2
}
};
let k = class(c);
if k == 2 {
return at..at + c.len_utf8();
}
let mut a = at;
for (p, d) in s[..at].char_indices().rev() {
if class(d) != k {
break;
}
a = p;
}
let mut b = at;
for (p, d) in s[at..].char_indices() {
if class(d) != k {
break;
}
b = at + p + d.len_utf8();
}
a..b
}
/// The paragraph containing `i`, excluding its `\n` (triple-click selection).
pub fn paragraph_at(s: &str, i: usize) -> Range<usize> {
let i = floor_char(s, i);
let a = s[..i].rfind('\n').map(|p| p + 1).unwrap_or(0);
let b = s[i..].find('\n').map(|p| i + p).unwrap_or(s.len());
a..b
}