diff --git a/contributors/people.toml b/contributors/people.toml index 7168664b5..03db5f9d2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -175,3 +175,6 @@ display_name = "Mulai" [people.Bortlesboat] real_name = "Andrew Barnes" + +[people.pakiat] +display_name = "OtorO" diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 6dbd06935..1d0ee5a7d 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use wordcraft_doc::eq::{EncloseShape, EncloseStyle, Enclosure}; use wordcraft_doc::props::{Border, CharProps, Highlight, Rgb, TextColor, Underline, VertAlign}; use wordcraft_doc::resolve::ResolvedChar; -use wordcraft_doc::{Pos, RevisionKind}; +use wordcraft_doc::{InlineObject, PartKind, Pos, RevisionKind, StoryRef}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Session, p}; @@ -170,6 +170,14 @@ fn target(s: &Session) -> Option<(Pos, Pos)> { None } +/// A text box selected as an object (its frame, not text in it): all its text, which Word's Font +/// group formats then, instead of the box's anchor in the text around it. +fn selected_text_box(s: &Session) -> Option<(Pos, Pos)> { + let (_, InlineObject::Shape { story: Some(id), .. }) = super::objects::object_selection(s)? else { return None }; + let story = StoryRef::Part(*id); + s.doc.parts.get(id).filter(|p| p.kind == PartKind::TextBox).map(|_| (s.doc.start_of(story), s.doc.end_of(story))) +} + /// The non-empty rows of a column selection, if one is active. Of whole cells, every paragraph: /// an empty one's mark takes the formatting. fn column_rows(s: &Session) -> Option> { @@ -198,7 +206,7 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult { } return sel_result(s); } - match target(s) { + match selected_text_box(s).or_else(|| target(s)) { Some((a, b)) => { s.doc.format_range(&a, &b, f)?; } @@ -242,7 +250,7 @@ fn resolved(s: &Session) -> Vec { return out; } } - let Some((a, b)) = target(s) else { + let Some((a, b)) = selected_text_box(s).or_else(|| target(s)) else { let f = &s.sel.focus; let style = s.doc.para_at(f).and_then(|p| p.props.style.clone()); out.push(s.doc.styles.resolve_char(style.as_deref(), &s.typing_props())); @@ -262,8 +270,11 @@ fn resolved(s: &Session) -> Vec { } } if out.is_empty() { - let style = s.doc.para_at(&a).and_then(|p| p.props.style.clone()); - out.push(s.doc.styles.resolve_char(style.as_deref(), &s.typing_props())); + let p = s.doc.para_at(&a); + let style = p.and_then(|p| p.props.style.clone()); + // An empty selected text box has only its paragraph mark, which is what gets formatted. + let empty_box = selected_text_box(s).and(p).map(|p| &p.mark); + out.push(s.doc.styles.resolve_char(style.as_deref(), empty_box.unwrap_or(&s.typing_props()))); } out } @@ -393,9 +404,16 @@ fn highlight(s: &mut Session, v: &Value) -> CmdResult { apply(s, &|x| x.highlight = Some(h)) } +/// `format.changeCase`: `mode` (lower, upper, title, sentence, toggle), or Shift+F3's cycle +/// without one. Works on the selection, the word at the caret, or a selected text box's text. fn change_case(s: &mut Session, v: &Value) -> CmdResult { - let (a, b) = s.sel.ordered(); - let (a, b) = if a == b { target(s).unwrap_or((a, b)) } else { (a, b) }; + let (a, b) = match selected_text_box(s) { + Some(all) => all, + None => { + let (a, b) = s.sel.ordered(); + if a == b { target(s).unwrap_or((a, b)) } else { (a, b) } + } + }; if a == b { return sel_result(s); } @@ -630,8 +648,10 @@ fn convert_chars(src: &str, mode: &str, para_start: bool) -> Vec { out } +/// `format.clear` (Clear All Formatting): direct character formatting off and the paragraphs back +/// to Normal, for the selection or a selected text box's text; at a caret, for what's typed next. fn clear(s: &mut Session, _: &Value) -> CmdResult { - match target(s) { + match selected_text_box(s).or_else(|| target(s)) { Some((a, b)) => { s.doc.format_range(&a, &b, &|c| *c = c.cleared())?; // Clear Formatting also resets the paragraph to Normal. @@ -652,7 +672,7 @@ fn clear(s: &mut Session, _: &Value) -> CmdResult { /// Remove direct character formatting, keeping the paragraph and any character style. fn reset_char(s: &mut Session, _: &Value) -> CmdResult { - match target(s) { + match selected_text_box(s).or_else(|| target(s)) { Some((a, b)) => s.doc.format_range(&a, &b, &|c| *c = CharProps { style: c.style.clone(), ..c.cleared() })?, None => s.pending = Some(CharProps::default()), } @@ -684,7 +704,9 @@ pub fn state(s: &Session) -> Value { let v0 = f(r); if all.iter().all(|r| f(r) == v0) { v0 } else { Value::Null } }; - let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone()).unwrap_or_else(|| "Normal".into()); + // A selected text box shows its own (first) paragraph's style, like its text's formatting. + let at = selected_text_box(s).map_or_else(|| s.sel.focus.clone(), |(a, _)| a); + let style = s.doc.para_at(&at).and_then(|p| p.props.style.clone()).unwrap_or_else(|| "Normal".into()); let style_name = s.doc.styles.get(&style).map(|st| st.name.clone()).unwrap_or(style.clone()); json!({ "font": same(&|r| json!(r.font)), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1b5406c2b..a98bd72b1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3423,6 +3423,106 @@ fn text_box_insert_puts_the_caret_inside() { assert_eq!(s.sel.focus, Pos { story: StoryRef::Part(id), path: wordcraft_doc::Path::top(0), off: 4 }); } +#[test] +fn font_commands_on_a_selected_text_box_format_all_its_text() { + // Word: with a text box selected as an object (its frame, not text in it), the Font group + // formats all the box's text. Select All doesn't reach a floating box's text, so this is how + // its font is changed (and the only way when the box sits behind text, where a click goes to + // the text over it). + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Before "})); + let id = run(&mut s, "insert.textBox", json!({"text": "Box words"}))["story"].as_u64().unwrap() as u32; + let boxed = StoryRef::Part(id); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": "second"})); + run(&mut s, "select.range", json!({"anchor": Pos::body(0, "Before ".len()), "focus": Pos::body(0, "Before \u{FFFC}".len())})); + run(&mut s, "format.font", json!({"name": "TH Sarabun New"})); + run(&mut s, "format.size", json!({"size": 18})); + run(&mut s, "format.bold", json!({})); + let paths = s.doc.para_paths(boxed); + assert_eq!(paths.len(), 2); + for path in &paths { + for (_, c) in s.doc.para(boxed, path).unwrap().run_ranges() { + assert_eq!(c.font.as_deref(), Some("TH Sarabun New")); + assert_eq!(c.font_cs.as_deref(), Some("TH Sarabun New")); + assert_eq!(c.size, Some(18.0)); + assert_eq!(c.bold, Some(true)); + } + } + // The body text around the box is left as it was, and the box stays selected. + let body = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + assert!(body.run_ranges().all(|(_, c)| c.font.is_none() && c.bold.is_none())); + assert!(crate::cmd::objects::object_selection(&s).is_some()); + // Bold reads the box's text, so pressing it again takes it off. + run(&mut s, "format.bold", json!({})); + assert!(s.doc.para(boxed, &paths[0]).unwrap().run_ranges().all(|(_, c)| c.bold == Some(false))); + // One undo step per command. + run(&mut s, "edit.undo", json!({})); + assert!(s.doc.para(boxed, &paths[0]).unwrap().run_ranges().all(|(_, c)| c.bold == Some(true))); +} + +#[test] +fn case_and_clearing_commands_on_a_selected_text_box_work_on_its_text() { + // Word, with a text box selected as an object: Change Case, Clear All Formatting and Reset + // Character Formatting work on the box's text and leave the body text around it alone. + let boxed = |s: &mut Session| { + run(s, "text.insert", json!({"text": "Before "})); + run(s, "format.bold", json!({"value": true})); + run(s, "text.insert", json!({"text": "body"})); + run(s, "caret.set", json!({"pos": Pos::body(0, "Before ".len())})); + let id = run(s, "insert.textBox", json!({"text": "box text"}))["story"].as_u64().unwrap() as u32; + run(s, "select.all", json!({})); + run(s, "format.italic", json!({"value": true})); + run(s, "select.range", json!({"anchor": Pos::body(0, "Before ".len()), "focus": Pos::body(0, "Before \u{FFFC}".len())})); + StoryRef::Part(id) + }; + let body_bold = |s: &Session| s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().run_ranges().any(|(_, c)| c.bold == Some(true)); + + let mut cased = s(); + let id = boxed(&mut cased); + run(&mut cased, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(cased.doc.plain_text(id), "BOX TEXT"); + assert_eq!(cased.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).map(|p| p.text.as_str()), Some("Before \u{FFFC}body")); + assert!(crate::cmd::objects::object_selection(&cased).is_some(), "the box stays selected"); + + for cmd in ["format.clear", "format.resetChar"] { + let mut cleared = s(); + let id = boxed(&mut cleared); + run(&mut cleared, cmd, json!({})); + let p = cleared.doc.para(id, &wordcraft_doc::Path::top(0)).unwrap(); + assert!(p.run_ranges().all(|(_, c)| c.italic.is_none()), "{cmd} clears the box's text"); + assert!(body_bold(&cleared), "{cmd} leaves the body text's formatting"); + assert!(crate::cmd::objects::object_selection(&cleared).is_some(), "the box stays selected"); + } + + // An empty box: Bold reads (and sets) its paragraph mark, not the anchor's run around it. + let mut empty = s(); + run(&mut empty, "format.bold", json!({"value": true})); + run(&mut empty, "text.insert", json!({"text": "Bold body "})); + let id = run(&mut empty, "insert.textBox", json!({}))["story"].as_u64().unwrap() as u32; + let anchor = empty.doc.text_box_anchor(id).unwrap(); + run(&mut empty, "select.range", json!({"anchor": Pos { off: anchor.off - "\u{FFFC}".len(), ..anchor.clone() }, "focus": anchor})); + run(&mut empty, "format.bold", json!({})); + let mark = &empty.doc.para(StoryRef::Part(id), &wordcraft_doc::Path::top(0)).unwrap().mark; + assert_eq!(mark.bold, Some(true), "the empty box wasn't bold, so Bold turns it on"); +} + +#[test] +fn a_selected_text_box_shows_its_own_paragraph_style() { + // Word, with a text box selected as an object, shows the style of the box's text, not of the + // body paragraph the box is anchored in, as it does for its font. + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Body "})); + let id = run(&mut s, "insert.textBox", json!({"text": "Title"}))["story"].as_u64().unwrap() as u32; + run(&mut s, "para.style", json!({"style": "Heading 1"})); + let inside = run(&mut s, "format.state", json!({})); + let anchor = s.doc.text_box_anchor(id).unwrap(); + run(&mut s, "select.range", json!({"anchor": Pos { off: anchor.off - "\u{FFFC}".len(), ..anchor.clone() }, "focus": anchor})); + let selected = run(&mut s, "format.state", json!({})); + assert_ne!(inside["style"], json!("Normal"), "the box's paragraph has a style of its own: {inside}"); + assert_eq!((&selected["style"], &selected["styleName"]), (&inside["style"], &inside["styleName"])); +} + #[test] fn commands_after_the_largest_ids_take_free_ones() { // A file can carry any ids, 0 and u32::MAX included: comments, notes, boxes and lists the