Font: a selected text box's Font commands format all its text (#3898)

* Font: a selected text box's Font commands format all its text

With a text box selected as an object (its frame, not text in it), Word's
Font group formats all the box's text. WordCraft formatted only the box's
anchor character in the paragraph around it, so the box's text kept its
font. Select All doesn't reach a floating box's text either (as in Word),
which left no way to change a box's font short of editing inside it, and
none when the box sits behind text, where a click goes to the text over it.

format::apply and the toggles' reading of the current formatting now take
a selected text box's whole story, as the WordArt commands already do.

* Contributors: pakiat's display name

* Font: Change Case, Clear and Reset on a selected text box work on its text

With a text box selected as an object, Word's Change Case, Clear All
Formatting and Reset Character Formatting work on the box's text and leave
the body text around it alone (observed black-box). format.changeCase,
format.clear and format.resetChar used the box's anchor in the body
instead, so Clear could reset the surrounding paragraph while the box kept
its formatting.

They now take a selected text box's whole story, as the other Font
commands do since the previous commit. An empty selected box also reads
its paragraph mark for the toggles' current state (it read the anchor's
run around it), so Bold on an empty box in bold body text turns Bold on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Font: a selected text box shows its own paragraph style

With a text box selected as an object, format.state reported the box's
character formatting but the style of the body paragraph the box is
anchored in. Word shows the box's own style then (observed black-box: a
Heading 1 box in a Normal paragraph shows Heading 1). The style and its
name now come from the box's first paragraph.

Also doc comments for format.changeCase and format.clear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pakiat
2026-10-23 12:51:18 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6fcfea4476
commit 9fb78ba8f1
3 changed files with 135 additions and 10 deletions
+3
View File
@@ -175,3 +175,6 @@ display_name = "Mulai"
[people.Bortlesboat]
real_name = "Andrew Barnes"
[people.pakiat]
display_name = "OtorO"
+32 -10
View File
@@ -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<Vec<(Pos, Pos)>> {
@@ -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<ResolvedChar> {
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<ResolvedChar> {
}
}
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<String> {
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)),
+100
View File
@@ -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