Change case across formatting run boundaries

Found while testing crates/engine/src/cmd/format.rs:693-715 (also the whole-shape path at :734-741)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-18 12:13:47 +02:00
co-authored by Claude Haiku 5.5
parent 22db9261f9
commit e5a3c9ca65
3 changed files with 90 additions and 49 deletions
+23 -13
View File
@@ -698,21 +698,28 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
let from = if pi == a.0 { a.1 } else { 0 };
let to = if pi == b.0 { b.1 } else { n };
let mut pos = 0;
for r in &mut para.runs {
let mut pieces = Vec::new();
for (ri, r) in para.runs.iter().enumerate() {
// Breaks and fields are read for context (a break ends a word) but not rewritten.
let len = r.char_len();
if r.kind == deckcraft_model::text::RunKind::Text {
let rs = from.max(pos).saturating_sub(pos);
let re = to.min(pos + len).saturating_sub(pos);
if re > rs {
let chars: Vec<char> = r.text.chars().collect();
let mid: String = chars.get(rs..re).map(|c| c.iter().collect()).unwrap_or_default();
let pre: String = chars.get(..rs).map(|c| c.iter().collect()).unwrap_or_default();
let post: String = chars.get(re..).map(|c| c.iter().collect()).unwrap_or_default();
r.text = format!("{pre}{}{post}", ed::change_case(&mid, &m));
}
let rs = from.max(pos).saturating_sub(pos);
let re = to.min(pos + len).saturating_sub(pos);
if re > rs {
pieces.push((ri, rs, re));
}
pos += len;
}
let mids: Vec<String> = pieces
.iter()
.map(|&(ri, rs, re)| para.runs.get(ri).map(|r| r.content().chars().skip(rs).take(re - rs).collect()).unwrap_or_default())
.collect();
let changed = ed::change_case_parts(&mids.iter().map(String::as_str).collect::<Vec<_>>(), &m);
for (&(ri, rs, re), new) in pieces.iter().zip(changed) {
let Some(r) = para.runs.get_mut(ri).filter(|r| r.kind == deckcraft_model::text::RunKind::Text) else { continue };
let pre: String = r.text.chars().take(rs).collect();
let post: String = r.text.chars().skip(re).collect();
r.text = format!("{pre}{new}{post}");
}
}
if collapsed {
t.anchor = a;
@@ -734,9 +741,12 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
edit_shapes(s, &json!({}), "format.case", |sh| {
if let Some(t) = sh.text.as_mut() {
for para in &mut t.paragraphs {
for r in &mut para.runs {
// Breaks and fields are read for context (a break ends a word) but not rewritten.
let texts: Vec<&str> = para.runs.iter().map(|r| r.content()).collect();
let changed = ed::change_case_parts(&texts, &mode);
for (r, new) in para.runs.iter_mut().zip(changed) {
if r.kind == deckcraft_model::text::RunKind::Text {
r.text = ed::change_case(&r.text, &mode);
r.text = new;
}
}
}
+36
View File
@@ -3623,3 +3623,39 @@ fn table_column_width_of_a_huge_column_is_ignored() {
assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0], "no column changes");
}
}
#[test]
fn change_case_spans_formatting_runs() {
let setup = |text: &str, bold_to: usize| {
let mut s = session();
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 420.0, 160.0], "text": text})).unwrap();
let id = r["id"].as_u64().unwrap() as u32;
s.execute("text.edit", &json!({"id": id})).unwrap();
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, bold_to]})).unwrap();
s.execute("format.bold", &json!({"on": true})).unwrap();
(s, id)
};
let text_of = |s: &Session, id: u32| s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap().text.clone().unwrap();
for (mode, input, want) in [("title", "hello world", "Hello World"), ("sentence", "hello world. bye", "Hello world. Bye")] {
// Text range.
let (mut s, id) = setup(input, 3);
let n = input.chars().count();
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, n]})).unwrap();
s.execute("format.case", &json!({"mode": mode})).unwrap();
assert_eq!(text_of(&s, id).text(), want, "{mode} range");
// Whole shape.
let (mut s, id) = setup(input, 3);
s.execute("text.exit", &json!({})).unwrap();
s.execute("format.case", &json!({"mode": mode})).unwrap();
assert_eq!(text_of(&s, id).text(), want, "{mode} shape");
assert_eq!(text_of(&s, id).paragraphs[0].runs[0].props.bold, Some(true));
}
// A length-changing mapping keeps each piece in its own run.
let (mut s, id) = setup("straße", 4);
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 6]})).unwrap();
s.execute("format.case", &json!({"mode": "upper"})).unwrap();
let body = text_of(&s, id);
assert_eq!(body.text(), "STRASSE");
assert_eq!(body.paragraphs[0].runs[0].text, "STRA");
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true));
}
+31 -36
View File
@@ -459,44 +459,39 @@ pub fn next_case(s: &str) -> &'static str {
/// 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;
change_case_parts(&[s], mode).concat()
}
/// Change the case of consecutive pieces of text (the runs of a paragraph) as one string, so
/// `title` and `sentence` carry their word/sentence state across piece boundaries. Each output
/// piece is the changed form of the matching input piece, even where a character changes length.
pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
let mut start = true;
parts
.iter()
.map(|s| 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` restarts after whitespace, sentence case after sentence punctuation.
let title = mode == "title";
let mut out = String::new();
for c in s.chars() {
if start && c.is_alphabetic() {
out.extend(c.to_uppercase());
start = false;
} else {
out.extend(c.to_lowercase());
}
if (title && c.is_whitespace()) || (!title && matches!(c, '.' | '!' | '?')) {
start = true;
}
}
out
}
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
}
}
})
.collect()
}
#[cfg(test)]