From e5a3c9ca65c416f07b8d054c01aa93470473735a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:11:55 +0200 Subject: [PATCH] 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 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/format.rs | 36 +++++++++++------- crates/engine/src/tests.rs | 36 ++++++++++++++++++ crates/model/src/edit.rs | 67 +++++++++++++++------------------ 3 files changed, 90 insertions(+), 49 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 36d54de9..75952c99 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -698,21 +698,28 @@ fn case(s: &mut Session, p: &Value) -> Result { 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 = 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 = 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::>(), &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 { 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; } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a464fd8b..2f1667f2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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)); +} diff --git a/crates/model/src/edit.rs b/crates/model/src/edit.rs index 1c38bf74..33c2a8bf 100644 --- a/crates/model/src/edit.rs +++ b/crates/model/src/edit.rs @@ -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::() } 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 { + 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::() } 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)]