mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
co-authored by
Claude Haiku 5.5
parent
22db9261f9
commit
e5a3c9ca65
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user