Files
deckcraft/crates/model/src/edit.rs
T
Brandon ThomasandClaude Opus 5.5 6078b68e97 M0: workspace, model, geometry, fonts, text, renderer, engine, MCP and CLI
Clean-room PowerPoint-class presentation engine in pure Rust:
- geom: units, transforms and 150+ re-derived preset shapes with adjust handles
- color: theme slots and colour transforms
- model: presentations, masters, 11 layouts, placeholder inheritance, themes,
  text, tables, charts, transitions, animations, text edit operations
- fonts: font DB with craft-fonts, system fonts and open substitutes
- text: shaping, line breaking, bullets/numbering, alignment, autofit, caret hit tests
- render: vello_cpu slides with fills, gradients, pictures, effects, tables, charts
- format: native .slidecraft (zip + JSON)
- engine: ~200 commands, undo/redo, clipboard, pointer tools, keys, sample deck
- mcp: MCP server (headless or bridged to the app); slidecraft-cli

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:27:20 -04:00

476 lines
16 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};
/// (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 is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '\''
}
/// Word boundaries around `pos` (double-click selection).
pub fn word_at(body: &TextBody, pos: Pos) -> (Pos, Pos) {
let (pi, ch) = clamp(body, pos);
let t: Vec<char> = body.paragraphs.get(pi).map(|p| p.text().chars().collect()).unwrap_or_default();
if t.is_empty() {
return ((pi, 0), (pi, 0));
}
let at = ch.min(t.len().saturating_sub(1));
let w = t.get(at).copied().is_some_and(is_word);
let mut s = at;
while s > 0 && t.get(s - 1).copied().is_some_and(|c| is_word(c) == w && !c.is_whitespace() || (w && is_word(c))) {
s -= 1;
}
let mut e = at;
while e < t.len() && t.get(e).copied().is_some_and(|c| is_word(c) == w && (w || !c.is_whitespace())) {
e += 1;
}
// Include trailing spaces after a word.
while w && e < t.len() && t.get(e) == Some(&' ') {
e += 1;
}
((pi, s), (pi, e.max(s)))
}
/// Next/previous word start (Option/Ctrl + arrows).
pub fn word_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
let (pi, ch) = clamp(body, pos);
let t: Vec<char> = body.paragraphs.get(pi).map(|p| p.text().chars().collect()).unwrap_or_default();
if forward {
if ch >= t.len() {
return if pi + 1 < body.paragraphs.len() { (pi + 1, 0) } else { (pi, ch) };
}
let mut i = ch;
while i < t.len() && t.get(i).copied().is_some_and(is_word) {
i += 1;
}
while i < t.len() && !t.get(i).copied().is_some_and(is_word) {
i += 1;
}
(pi, i)
} else {
if ch == 0 {
return if pi > 0 { (pi - 1, body.paragraphs.get(pi - 1).map(Paragraph::char_len).unwrap_or(0)) } else { (0, 0) };
}
let mut i = ch;
while i > 0 && !t.get(i - 1).copied().is_some_and(is_word) {
i -= 1;
}
while i > 0 && t.get(i - 1).copied().is_some_and(is_word) {
i -= 1;
}
(pi, i)
}
}
/// 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))
}
/// 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, 12)));
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);
}
}