Merge pull request #2652 from dotCooCoo/feat/text-frame-options-type-tool

Text: Text Frame Options open from the Type tool and change the frame holding the caret
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-12 16:09:26 -05:00
commit 520aeeefe1
10 files changed
+705 -58

No files matched your search

+1
View File
@@ -397,6 +397,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
- **2026-11-12** · An edit of a long threaded story composes the paragraphs it can change and copies the other lines from the composition before the edit; every typed character composed the whole story. In a 240-paragraph story a typed character composes 3 paragraphs (240 before). An edit that moves lines into other frames composes on until a paragraph starts in the same frame and place as before, or to the story's end, and stories with footnotes, tables, anchored objects, balanced columns or keep options that move lines are composed whole. No readiness percentage change.
- **2026-11-12** · Type shortcuts: Justify All Lines ⇧⌘F, Insert White Space › Em Space ⇧⌘M and En Space ⇧⌘N (shown in the menu, enabled at an insertion point), Paragraph Rules… ⌥⌘J (shown in the Paragraph panel menu). On the Mac a menu-bar insert (Em/En Space, Current Page Number) no longer types into the document while a text field has focus, a dialog or Quick Apply is open, or an input method is composing. An alignment the paragraphs already have records no undo step (#1138). No readiness percentage change.
- **2026-11-12** · The status bar's preflight count and the Preflight panel take their issues from a check on a background worker, which checks the newest state of the document and shows the issues last found meanwhile. Moving or resizing a frame and typing no longer wait for a check of the whole document on every drag step and keystroke. No readiness percentage change.
- **2026-11-12** · Object › Text Frame Options… (Cmd+B) opens with the Type tool in a frame, so inset spacing and vertical justification can be changed while typing; it changes the frame that shows the insertion point, or every frame that holds part of the selected text (#2396). No readiness percentage change.
- **2026-11-04** · Striped strokes retain out-and-back cubic segments whose second control point is away from the coincident endpoints and first control point. A geometry regression checks the offset extent and both stripe bands. No readiness percentage change.
- **2026-11-04** · Esperanto is a text language (Character › Language, `type.char {language: "Esperanto"}`, IDML `$ID/Esperanto`): tag `eo`, English-style quotes; not hyphenated yet (#1201). No readiness percentage change.
+1
View File
@@ -42,6 +42,7 @@ mod mathexpr;
mod media;
mod notes;
mod object;
pub use object::text_frame_options_frames;
mod overrides;
mod package;
pub mod params;
+372 -9
View File
@@ -610,8 +610,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Text Frame Options…",
["Object"],
Some("Cmd+B"),
"{columns? (1..40), gutter? (pt, 0..15552), inset?: number|[t,l,b,r] (pt, each 0..15552; a list has four sides, null keeps that side), verticalJustification?: top|center|bottom|justify, paragraphSpacingLimit? (pt: Justify adds at most this between paragraphs, the rest between lines), columnsKind?: fixedNumber|fixedWidth|flexibleWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
has_selection,
"{columns? (1..40), gutter? (pt, 0..15552), inset?: number|[t,l,b,r] (pt, each 0..15552; a list has four sides, null keeps that side), verticalJustification?: top|center|bottom|justify, paragraphSpacingLimit? (pt: Justify adds at most this between paragraphs, the rest between lines), columnsKind?: fixedNumber|fixedWidth|flexibleWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids? (without ids or selected objects: the frame showing the text caret, or every frame holding part of the selected text)}",
has_selection_or_text,
text_frame_options
),
cmd!(
@@ -2707,14 +2707,45 @@ fn content_type(s: &mut Session, p: &Value) -> Result<Value> {
/// The most columns a text frame takes.
const MAX_FRAME_COLUMNS: u64 = 40;
/// The frames `object.textFrameOptions` changes when the call names none: the selected objects,
/// or with the Type tool the frame that shows the caret or every frame holding part of the
/// selected text.
pub fn text_frame_options_frames(s: &Session) -> Vec<ItemId> {
let Ok(st) = s.doc() else { return Vec::new() };
if !st.selection.items.is_empty() {
return st.selection.items.clone();
}
st.selection.text.map(|t| text_selection_frames(s, &t)).unwrap_or_default()
}
/// Text Frame Options act on the selected objects, or with the Type tool on the frames that hold
/// the caret or the selected text.
fn has_selection_or_text(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| d.selection.text.is_some()) {
return Ok(());
}
has_selection(s)
}
fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "object.textFrameOptions";
let st = s.doc()?;
let mut ids = targets(s, p)?;
if ids.is_empty()
&& let Some(t) = st.selection.text
{
ids = st.doc.story(t.story).map(|s| s.frames.clone()).unwrap_or_default();
// A call that names its objects acts on those; an empty `ids` names nothing. `ids`, when
// given, wins over `id`, and a value that is not an object id is an error.
let ids = match (p.get("ids"), p.get("id")) {
(Some(v), _) => v
.as_array()
.and_then(|a| a.iter().map(|x| x.as_u64().map(ItemId)).collect::<Option<Vec<_>>>())
.ok_or_else(|| bad(ID, format!("ids must be a list of object ids, not {v}")))?,
(None, Some(v)) => vec![v.as_u64().map(ItemId).ok_or_else(|| bad(ID, format!("id must be an object id, not {v}")))?],
(None, None) => text_frame_options_frames(s),
};
// With a text caret the command runs before named objects are checked.
if let Some(gone) = ids.iter().find(|i| st.doc.item(**i).is_none()) {
return Err(bad(ID, format!("no object with id {}", gone.0)));
}
if ids.is_empty() {
return Err(bad(ID, super::NOTHING_SELECTED));
}
let ranged = |key: &str, lo: f64, hi: f64, what: &str| match p.get(key) {
None | Some(Value::Null) => Ok(None),
@@ -2811,8 +2842,10 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::String(c)) if st.doc.swatch(c).is_some() => Some(c.clone()),
Some(v) => return Err(bad(ID, format!("no swatch {v}"))),
};
let (before, revision) = (Arc::clone(&st.doc), st.revision);
let find = s.find_context.take();
let p = p.clone();
s.edit(|d, _| {
let edited = s.edit(|d, _| {
for id in &ids {
let Some(tf) = d.item_mut(*id).and_then(Item::text_frame_mut) else { continue };
let o = &mut tf.options;
@@ -2899,6 +2932,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
if fixed_width
&& ["columnsKind", "columnWidth", "columns", "gutter", "inset"].iter().any(|k| p.get(*k).is_some())
&& let Some(r) = d.item(*id).map(Item::inner_bounds)
&& ((r.width() - width).abs() >= 1e-9 || !width.is_finite())
{
let r = super::finite_rect("object.textFrameOptions", Rect::new(r.x0, r.y0, r.x0 + width, r.y1))?;
crate::autosize::resize(d, *id, r);
@@ -2910,7 +2944,84 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
super::text::set_story_direction(d, &stories, v);
}
Ok(json!({"changed": ids.len()}))
})
});
let changed = match edited {
Ok(v) => v,
Err(e) => {
s.find_context = find;
return Err(e);
}
};
// Options the frames already have: the document, its revision and the Find Next scope go back
// to what they were, which leaves no undo step. Only the frames and their stories are compared.
let st = s.doc_mut()?;
let same = ids.iter().all(|id| {
let (now, was) = (st.doc.item(*id), before.item(*id));
now == was && now.and_then(Item::text_frame).is_none_or(|t| st.doc.story(t.story) == before.story(t.story))
});
if same {
st.doc = before;
st.revision = revision;
s.find_context = find;
return Ok(json!({"changed": 0}));
}
Ok(changed)
}
/// The frames Text Frame Options acts on from the Type tool: the frame that shows the caret, or
/// every frame that holds part of the selected text. Text past the last frame counts as the last
/// frame's.
fn text_selection_frames(s: &Session, t: &TextSel) -> Vec<ItemId> {
let Ok(st) = s.doc() else { return Vec::new() };
let Some(story) = st.doc.story(t.story) else { return Vec::new() };
let last = story.frames.last().copied();
let cs = s.cache.get(&st.doc, t.story, None);
// In footnote text or a table cell, `anchor` and `focus` index that text: the frame the caret
// is drawn in (the placed footnote, or the cell's first non-repeated occurrence), else the
// frame that shows the footnote reference or the table's anchor character.
if let Some(c) = t.cell {
let (drawn, anchor) = match c.footnote_id() {
Some(id) => (designcraft_compose::find_note(&cs, id).map(|(fi, _)| fi), story.note_anchor(id)),
None => (designcraft_compose::find_cell(&cs, c.table, c.row, c.col).map(|(fi, ..)| fi), story.table_anchor(c.table)),
};
return drawn
.or_else(|| anchor.and_then(|a| designcraft_compose::caret(&cs, a)).map(|(fi, ..)| fi))
.and_then(|fi| cs.frames.get(fi))
.map(|f| f.frame)
.or_else(|| t.frame.filter(|f| story.frames.contains(f)))
.or(last)
.into_iter()
.collect();
}
let r = t.range();
// The frame the caret at the start is drawn in; the caret's stored frame only when no line
// holds it.
let caret_frame = || {
designcraft_compose::caret(&cs, r.start)
.and_then(|(fi, ..)| cs.frames.get(fi))
.map(|f| f.frame)
.or_else(|| t.frame.filter(|f| story.frames.contains(f) && cs.overset_at.is_none_or(|o| r.start < o)))
};
// A frame's range stops before the paragraph return that ends its last line; the return
// belongs to that frame.
let end_of = |f: &designcraft_compose::FrameText| f.range.end + usize::from(story.text.as_bytes().get(f.range.end) == Some(&b'\n'));
let mut frames: Vec<ItemId> = if t.is_caret() {
caret_frame().into_iter().collect()
} else {
cs.frames.iter().filter(|f| f.range.start < r.end && r.start < end_of(f)).map(|f| f.frame).collect()
};
// Selected text no frame holds goes with the line before it.
if frames.is_empty() {
frames.extend(caret_frame());
}
let overset = cs.overset_at.is_some_and(|o| r.end > o);
if let Some(l) = last
&& (frames.is_empty() || overset)
&& !frames.contains(&l)
{
frames.push(l);
}
frames
}
/// `object.textFrameOptions` Inset Spacing: one number for every side, or [top, left, bottom,
@@ -5015,6 +5126,7 @@ mod named_target_tests {
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap();
assert!(s.doc().unwrap().selection.items.is_empty(), "frame.create leaves a text caret");
s.execute("edit.deselectAll", &json!({})).unwrap();
let e = s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap_err().to_string();
assert!(e.contains("nothing selected"), "{e}");
s.execute("object.textFrameOptions", &json!({"ids": [r["id"]], "columns": 2})).unwrap();
@@ -5034,6 +5146,257 @@ mod named_target_tests {
}
}
#[cfg(test)]
mod type_tool_frame_options_tests {
use super::*;
use designcraft_doc::VerticalJustification;
/// One story threaded through two frames, with text in both: (first frame, second frame, story).
fn two_frame_thread(s: &mut Session) -> (ItemId, ItemId, StoryId) {
s.execute("file.new", &json!({})).unwrap();
let text = (0..40).map(|i| format!("Line {i} of the threaded story.")).collect::<Vec<_>>().join(" ");
let a = s.execute("frame.create", &json!({"rect": [36, 36, 300, 120], "content": "text", "text": text})).unwrap();
let b = s.execute("frame.create", &json!({"rect": [36, 200, 300, 700], "content": "text", "text": ""})).unwrap();
let (a, b) = (ItemId(a["id"].as_u64().unwrap()), ItemId(b["id"].as_u64().unwrap()));
s.execute("text.thread", &json!({"from": a.0, "to": b.0})).unwrap();
let sid = s.doc().unwrap().doc.item(a).and_then(Item::text_frame).unwrap().story;
(a, b, sid)
}
/// The story bytes `frame` shows.
fn shown(s: &Session, sid: StoryId, frame: ItemId) -> std::ops::Range<usize> {
let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default());
cs.frame(frame).unwrap().range.clone()
}
fn options(s: &Session, id: ItemId) -> TextFrameOptions {
s.doc().unwrap().doc.item(id).and_then(Item::text_frame).unwrap().options.clone()
}
fn undo_steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// With the Type tool, Text Frame Options change the frame that shows the caret, not every
/// frame of the story.
#[test]
fn a_caret_changes_the_frame_it_is_in() {
let mut s = Session::new();
let (a, b, sid) = two_frame_thread(&mut s);
let (ra, rb) = (shown(&s, sid, a), shown(&s, sid, b));
assert!(!ra.is_empty() && !rb.is_empty(), "both frames hold text: {ra:?} {rb:?}");
s.execute("text.select", &json!({"story": sid.0, "anchor": rb.start + 2})).unwrap();
assert!(s.doc().unwrap().selection.items.is_empty());
let steps = undo_steps(&s);
s.execute("object.textFrameOptions", &json!({"columns": 3, "verticalJustification": "center", "inset": 6})).unwrap();
assert_eq!(
(options(&s, b).columns, options(&s, b).vertical_justification, options(&s, b).inset),
(3, VerticalJustification::Center, [6.0; 4])
);
assert_eq!((options(&s, a).columns, options(&s, a).vertical_justification), (1, VerticalJustification::Top), "the first frame is left alone");
assert_eq!(undo_steps(&s), steps + 1, "one undo step");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(options(&s, b).columns, 1);
// A caret in the first frame changes the first frame.
s.execute("text.select", &json!({"story": sid.0, "anchor": ra.start + 2})).unwrap();
s.execute("object.textFrameOptions", &json!({"verticalJustification": "bottom"})).unwrap();
assert_eq!(
(options(&s, a).vertical_justification, options(&s, b).vertical_justification),
(VerticalJustification::Bottom, VerticalJustification::Top)
);
}
/// A caret moved to the start of the second frame keeps the first frame as its stored frame;
/// the options go to the frame the caret is drawn in.
#[test]
fn a_caret_at_a_frame_boundary_changes_the_frame_it_is_drawn_in() {
let mut s = Session::new();
let (a, b, sid) = two_frame_thread(&mut s);
let rb = shown(&s, sid, b);
let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default());
let drawn = designcraft_compose::caret(&cs, rb.start).and_then(|(fi, ..)| cs.frames.get(fi)).map(|f| f.frame);
assert_eq!(drawn, Some(b), "the caret at the boundary is drawn in the second frame");
s.doc_mut().unwrap().selection = Selection::text(TextSel { story: sid, anchor: rb.start, focus: rb.start, frame: Some(a), cell: None });
s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap();
assert_eq!((options(&s, a).columns, options(&s, b).columns), (1, 2));
}
/// Selected text that runs from one frame into the next changes both frames.
#[test]
fn selected_text_changes_every_frame_it_runs_through() {
let mut s = Session::new();
let (a, b, sid) = two_frame_thread(&mut s);
let (ra, rb) = (shown(&s, sid, a), shown(&s, sid, b));
s.execute("text.select", &json!({"story": sid.0, "anchor": ra.end - 3, "focus": rb.start + 3})).unwrap();
s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap();
assert_eq!((options(&s, a).columns, options(&s, b).columns), (2, 2));
// Text inside one frame changes that frame only.
s.execute("text.select", &json!({"story": sid.0, "anchor": rb.start + 1, "focus": rb.start + 6})).unwrap();
s.execute("object.textFrameOptions", &json!({"columns": 4})).unwrap();
assert_eq!((options(&s, a).columns, options(&s, b).columns), (2, 4));
}
/// A selected paragraph return at the end of the first frame changes the first frame.
#[test]
fn a_selected_return_at_the_end_of_a_frame_changes_that_frame() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let text = (0..40).map(|i| format!("Paragraph {i}")).collect::<Vec<_>>().join("\n");
let a = s.execute("frame.create", &json!({"rect": [36, 36, 300, 120], "content": "text", "text": text})).unwrap();
let b = s.execute("frame.create", &json!({"rect": [36, 200, 300, 700], "content": "text", "text": ""})).unwrap();
let (a, b) = (ItemId(a["id"].as_u64().unwrap()), ItemId(b["id"].as_u64().unwrap()));
s.execute("text.thread", &json!({"from": a.0, "to": b.0})).unwrap();
let sid = s.doc().unwrap().doc.item(a).and_then(Item::text_frame).unwrap().story;
let (ra, rb) = (shown(&s, sid, a), shown(&s, sid, b));
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text.as_bytes()[ra.end], b'\n', "the first frame ends before a return");
assert_eq!(rb.start, ra.end + 1, "the second frame starts after it");
s.execute("text.select", &json!({"story": sid.0, "anchor": ra.end, "focus": ra.end + 1})).unwrap();
s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap();
assert_eq!((options(&s, a).columns, options(&s, b).columns), (2, 1));
// From that return into the second frame, with the first frame back as it was: both frames.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((shown(&s, sid, a), shown(&s, sid, b)), (ra.clone(), rb.clone()));
s.execute("text.select", &json!({"story": sid.0, "anchor": ra.end, "focus": rb.start + 3})).unwrap();
s.execute("object.textFrameOptions", &json!({"columns": 3})).unwrap();
assert_eq!((options(&s, a).columns, options(&s, b).columns), (3, 3));
}
/// An empty `ids` names no frame, so it does not fall back to the caret's frame.
#[test]
fn empty_ids_with_a_caret_is_an_error() {
let mut s = Session::new();
let (a, b, sid) = two_frame_thread(&mut s);
s.execute("text.select", &json!({"story": sid.0, "anchor": 2})).unwrap();
let before = s.doc().unwrap().doc.clone();
let e = s.execute("object.textFrameOptions", &json!({"ids": [], "columns": 3})).unwrap_err().to_string();
assert!(e.contains("nothing selected"), "{e}");
// An id that is not in the document is refused too.
let e = s.execute("object.textFrameOptions", &json!({"ids": [99999, b.0], "columns": 3})).unwrap_err().to_string();
assert!(e.contains("no object with id 99999"), "{e}");
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed");
assert_eq!((options(&s, a).columns, options(&s, b).columns), (1, 1));
}
/// An `ids` that is not a list of object ids is refused, not read as the selected frame or the
/// caret's frame.
#[test]
fn malformed_ids_are_an_error() {
let mut s = Session::new();
let (a, b, sid) = two_frame_thread(&mut s);
s.execute("selection.set", &json!({"ids": [b.0]})).unwrap();
let before = s.doc().unwrap().doc.clone();
for p in [json!({"ids": "bad", "columns": 2}), json!({"ids": [b.0, "x"], "columns": 2}), json!({"ids": null, "columns": 2})] {
let e = s.execute("object.textFrameOptions", &p).unwrap_err().to_string();
assert!(e.contains("ids must be a list of object ids"), "{p}: {e}");
}
s.execute("text.select", &json!({"story": sid.0, "anchor": 2})).unwrap();
let e = s.execute("object.textFrameOptions", &json!({"ids": "bad", "columns": 2})).unwrap_err().to_string();
assert!(e.contains("ids must be a list of object ids"), "with a caret: {e}");
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed");
assert_eq!((options(&s, a).columns, options(&s, b).columns), (1, 1));
}
/// Options the frame already has change nothing and add no undo step.
#[test]
fn unchanged_options_add_no_undo_step() {
let mut s = Session::new();
let (_, b, sid) = two_frame_thread(&mut s);
let rb = shown(&s, sid, b);
s.execute("text.select", &json!({"story": sid.0, "anchor": rb.start + 2})).unwrap();
let steps = undo_steps(&s);
let (before, revision) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().revision);
let r = s.execute("object.textFrameOptions", &json!({"columns": 1, "verticalJustification": "top", "inset": 0})).unwrap();
assert_eq!(r["changed"], 0);
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed");
assert_eq!(s.doc().unwrap().revision, revision, "the revision stays");
assert_eq!(undo_steps(&s), steps);
// The same holds for selected frames, Fixed Width columns included.
s.execute("selection.set", &json!({"ids": [b.0]})).unwrap();
s.execute("object.textFrameOptions", &json!({"gutter": options(&s, b).gutter})).unwrap();
assert_eq!(undo_steps(&s), steps);
let fixed = json!({"columnsKind": "fixedWidth", "columnWidth": 100.3, "columns": 2, "gutter": 12.1});
s.execute("object.textFrameOptions", &fixed).unwrap();
assert_eq!(undo_steps(&s), steps + 1);
assert_eq!(s.execute("object.textFrameOptions", &fixed).unwrap()["changed"], 0);
assert_eq!(undo_steps(&s), steps + 1, "the same Fixed Width options again add no undo step");
}
/// A caret in footnote text changes the frame that shows the footnote, with or without a
/// stored frame on the caret.
#[test]
fn a_caret_in_a_footnote_changes_the_frame_that_shows_it() {
for (in_second, stored) in [(false, None), (true, None), (false, Some(true))] {
let mut s = Session::new();
let (a, b, sid) = two_frame_thread(&mut s);
let r = shown(&s, sid, if in_second { b } else { a });
s.execute("text.select", &json!({"story": sid.0, "anchor": r.start + 2})).unwrap();
s.execute("footnote.insert", &json!({"text": "Note."})).unwrap();
let mut t = s.doc().unwrap().selection.text.unwrap();
assert!(t.cell.and_then(|c| c.footnote_id()).is_some(), "the caret is in the footnote: {t:?}");
if let Some(second) = stored {
t.frame = Some(if second { b } else { a });
s.doc_mut().unwrap().selection = Selection::text(t);
}
s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap();
let want = if in_second { (1, 2) } else { (2, 1) };
assert_eq!((options(&s, a).columns, options(&s, b).columns), want, "footnote in the second frame: {in_second}, stored frame: {stored:?}");
}
}
/// A caret in a table cell changes the frame that shows the cell, with or without a stored
/// frame on the caret.
#[test]
fn a_caret_in_a_table_cell_changes_the_frame_that_shows_it() {
for (in_second, stored) in [(false, None), (true, None), (false, Some(true))] {
let mut s = Session::new();
let (a, b, sid) = two_frame_thread(&mut s);
let r = shown(&s, sid, if in_second { b } else { a });
s.execute("text.select", &json!({"story": sid.0, "anchor": r.start + 2})).unwrap();
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
let mut t = s.doc().unwrap().selection.text.unwrap();
assert!(t.cell.is_some_and(|c| c.footnote_id().is_none()), "the caret is in the cell: {t:?}");
if let Some(second) = stored {
t.frame = Some(if second { b } else { a });
s.doc_mut().unwrap().selection = Selection::text(t);
}
s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap();
let want = if in_second { (1, 2) } else { (2, 1) };
assert_eq!((options(&s, a).columns, options(&s, b).columns), want, "table in the second frame: {in_second}, stored frame: {stored:?}");
}
}
/// A caret in a repeated footer row is drawn in the footer's own occurrence at the end of the
/// table: that frame changes, not the earlier frame that shows a repeat of the footer.
#[test]
fn a_caret_in_a_repeated_footer_changes_the_frame_with_the_footer_itself() {
let mut s = Session::new();
let (a, b, sid) = two_frame_thread(&mut s);
let ra = shown(&s, sid, a);
s.execute("text.select", &json!({"story": sid.0, "anchor": ra.start + 2})).unwrap();
s.execute("table.insert", &json!({"rows": 15, "cols": 1, "footerRows": 1})).unwrap();
s.execute("table.options", &json!({"repeatFooter": true})).unwrap();
let t = s.doc().unwrap().selection.text.unwrap();
let table = t.cell.unwrap().table;
let footer = s.doc().unwrap().doc.story(sid).unwrap().tables.get(&table).unwrap().nrows() - 1;
let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default());
let placed = |f: ItemId| {
cs.frame(f)
.unwrap()
.tables
.iter()
.filter(|tf| tf.table == table)
.filter_map(|tf| tf.cell(footer, 0))
.map(|c| c.repeated)
.collect::<Vec<_>>()
};
assert_eq!((placed(a), placed(b)), (vec![true], vec![false]), "the footer repeats in the first frame and ends the table in the second");
s.doc_mut().unwrap().selection =
Selection::text(TextSel { cell: Some(designcraft_doc::CellAddr { table, row: footer, col: 0 }), anchor: 0, focus: 0, frame: None, ..t });
s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap();
assert_eq!((options(&s, a).columns, options(&s, b).columns), (1, 2));
}
}
#[cfg(test)]
mod column_rule_tests {
use super::*;
+205 -42
View File
@@ -556,6 +556,125 @@ mod color_theme_tests {
assert_eq!(opts(&app).columns, 1, "one undo step");
}
/// Opened with the caret on the last line of a frame, Text Frame Options keep acting on that
/// frame while a preview pushes the caret's line into the next frame of the thread.
#[test]
fn text_frame_options_keep_their_frame_while_the_preview_reflows_the_caret() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let text = (0..40).map(|i| format!("Line {i} of the threaded story.")).collect::<Vec<_>>().join(" ");
let a = app.run("frame.create", json!({"rect": [36, 36, 300, 120], "content": "text", "text": text})).unwrap();
let b = app.run("frame.create", json!({"rect": [36, 200, 300, 700], "content": "text", "text": "", "caret": false})).unwrap();
let (a, b, sid) =
(designcraft_doc::ItemId(a["id"].as_u64().unwrap()), designcraft_doc::ItemId(b["id"].as_u64().unwrap()), a["story"].as_u64().unwrap());
app.run("text.thread", json!({"from": a.0, "to": b.0})).unwrap();
let shown = |app: &DesignApp, f| {
let cs = designcraft_compose::compose_story(&app.session.doc().unwrap().doc, designcraft_doc::StoryId(sid), &Default::default());
cs.frame(f).unwrap().range.clone()
};
let caret = shown(&app, a).end - 1;
app.run("text.select", json!({"story": sid, "anchor": caret})).unwrap();
let inset = |app: &DesignApp, f| app.session.doc().unwrap().doc.item(f).unwrap().text_frame().unwrap().options.inset[0];
let mut d = Dialog::new("textFrameOptions", json!({}));
seed_text_frame_options(&app, &mut d);
d.fields.insert("inset".into(), json!("2p0"));
d.fields.insert("preview".into(), json!(true));
text_frame_preview(&mut app, &mut d);
assert!(shown(&app, b).contains(&caret), "the preview moved the caret's line into the next frame");
d.fields.insert("columns".into(), json!("2"));
text_frame_preview(&mut app, &mut d);
assert_eq!((inset(&app, a), inset(&app, b)), (24.0, 0.0), "the preview stays on the first frame");
app.ui.dialog = Some(d);
confirm(&mut app).unwrap();
assert_eq!((inset(&app, a), inset(&app, b)), (24.0, 0.0));
}
/// With the caret in the oval second frame of a thread whose first frame has uneven insets, the
/// dialog shows the one Inset of the oval and keeps the link on: both follow the frame it changes.
#[test]
fn text_frame_options_from_the_type_tool_show_the_inset_of_the_caret_frame() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let text = (0..40).map(|i| format!("Line {i} of the threaded story.")).collect::<Vec<_>>().join(" ");
let a = app.run("frame.create", json!({"rect": [36, 36, 300, 120], "content": "text", "text": text})).unwrap();
let b =
app.run("frame.create", json!({"rect": [36, 200, 300, 700], "shape": "ellipse", "content": "text", "text": "", "caret": false})).unwrap();
let (a, b, sid) =
(designcraft_doc::ItemId(a["id"].as_u64().unwrap()), designcraft_doc::ItemId(b["id"].as_u64().unwrap()), a["story"].as_u64().unwrap());
app.run("text.thread", json!({"from": a.0, "to": b.0})).unwrap();
app.run("object.textFrameOptions", json!({"ids": [a.0], "inset": [1, 2, 3, 4]})).unwrap();
let cs = designcraft_compose::compose_story(&app.session.doc().unwrap().doc, designcraft_doc::StoryId(sid), &Default::default());
let caret = cs.frame(b).unwrap().range.start + 2;
app.run("text.select", json!({"story": sid, "anchor": caret})).unwrap();
let mut d = Dialog::new("textFrameOptions", json!({}));
seed_text_frame_options(&app, &mut d);
assert_eq!(d.fields.get("_targets"), Some(&json!([b.0])));
assert!(d.b("_shaped"), "the oval shows one Inset");
assert!(d.b("insetLinked"), "the first frame's uneven sides do not turn the link off");
}
/// A preview shown in one document goes when OK is refused, or Cancel is chosen, after another
/// document became active.
#[test]
fn text_frame_options_preview_goes_from_its_document_after_another_becomes_active() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
let make = |app: &mut crate::DesignApp| {
app.run("file.new", json!({})).unwrap();
let r = app.run("frame.create", json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap();
app.run("text.select", json!({"story": r["story"], "anchor": 2})).unwrap();
designcraft_doc::ItemId(r["id"].as_u64().unwrap())
};
let a = make(&mut app);
let first = |app: &DesignApp| {
let st = &app.session.documents()[0];
(st.doc.item(a).unwrap().text_frame().unwrap().options.columns, st.interaction.is_some())
};
let preview = |app: &mut DesignApp| {
let mut d = Dialog::new("textFrameOptions", json!({}));
seed_text_frame_options(app, &mut d);
d.fields.insert("columns".into(), json!("3"));
d.fields.insert("preview".into(), json!(true));
text_frame_preview(app, &mut d);
assert_eq!(first(app), (3, true), "previewed in the first document");
d
};
let d = preview(&mut app);
make(&mut app);
app.ui.dialog = Some(d);
let err = confirm(&mut app).unwrap_err().to_string();
assert!(err.contains("no longer the active one"), "{err}");
assert_eq!(first(&app), (1, false), "OK refused: the first document is as it was");
app.ui.dialog = None;
app.session.set_active(0);
let d = preview(&mut app);
app.session.set_active(1);
app.ui.dialog = Some(d);
cancel(&mut app);
assert_eq!(first(&app), (1, false), "Cancel: the first document is as it was");
}
/// Undo puts back the document's id counter, so a frame made after Undo can take the id of a
/// frame the dialog stored; OK is refused instead of changing the new frame.
#[test]
fn text_frame_options_from_the_type_tool_are_refused_after_undo_reuses_the_frame_id() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let a = app.run("frame.create", json!({"rect": [36, 36, 300, 120], "content": "text", "text": "First"})).unwrap();
app.run("text.select", json!({"story": a["story"], "anchor": 1})).unwrap();
let mut d = Dialog::new("textFrameOptions", json!({}));
seed_text_frame_options(&app, &mut d);
d.fields.insert("columns".into(), json!("3"));
app.run("edit.undo", json!({})).unwrap();
let b = app.run("frame.create", json!({"rect": [36, 200, 300, 300], "content": "text", "text": "Second"})).unwrap();
assert_eq!(a["id"], b["id"], "the new frame takes the undone frame's id");
app.run("text.select", json!({"story": b["story"], "anchor": 1})).unwrap();
app.ui.dialog = Some(d);
let err = confirm(&mut app).unwrap_err().to_string();
assert!(err.contains("changed after the dialog opened"), "{err}");
let b = designcraft_doc::ItemId(b["id"].as_u64().unwrap());
assert_eq!(app.session.doc().unwrap().doc.item(b).unwrap().text_frame().unwrap().options.columns, 1, "the new frame is unchanged");
}
#[test]
fn toc_right_indent_separator_survives_the_dialog_and_update() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
@@ -991,40 +1110,36 @@ fn pdf_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
}
/// The first frame Text Frame Options changes: the first selected item, the frame that shows the
/// caret, or the first frame holding selected text.
fn options_frame(app: &DesignApp) -> Option<designcraft_doc::ItemId> {
designcraft_engine::cmd::text_frame_options_frames(&app.session).first().copied()
}
/// The selected text frame's width (its own, before rotation).
fn selected_frame_width(app: &DesignApp) -> Option<f64> {
let st = app.session.active()?;
let fid = st.selection.items.first().copied().or_else(|| st.selection.text.and_then(|t| t.frame))?;
Some(st.doc.item(fid)?.inner_bounds().width())
Some(st.doc.item(options_frame(app)?)?.inner_bounds().width())
}
/// The options of the selected text frame (the first selected item, or the frame of the caret).
/// The options of the frame Text Frame Options shows.
fn selected_frame_options(app: &DesignApp) -> Option<designcraft_doc::TextFrameOptions> {
let st = app.session.active()?;
let fid = st.selection.items.first().copied().or_else(|| st.selection.text.and_then(|t| t.frame))?;
st.doc.item(fid)?.text_frame().map(|t| t.options.clone())
st.doc.item(options_frame(app)?)?.text_frame().map(|t| t.options.clone())
}
/// True when every text frame the dialog changes has the same inset on all four sides: the
/// selected items, else the caret's frame, else every frame of the caret's story.
/// selected items, else the frame that shows the caret or every frame holding selected text.
fn selected_frames_have_even_insets(app: &DesignApp) -> bool {
let Some(st) = app.session.active() else {
return true;
};
let mut ids = st.selection.items.clone();
if ids.is_empty()
&& let Some(t) = st.selection.text
{
ids = match t.frame {
Some(f) => vec![f],
None => st.doc.story(t.story).map(|s| s.frames.clone()).unwrap_or_default(),
};
}
let ids = designcraft_engine::cmd::text_frame_options_frames(&app.session);
ids.iter().filter_map(|id| st.doc.item(*id)?.text_frame()).all(|t| t.options.inset.iter().all(|v| (v - t.options.inset[0]).abs() < 1e-9))
}
/// Whether every selected text frame (the selected items, or the frame of the caret) has an
/// outline that is not its own axis-aligned rectangle: an oval, a polygon, rounded or other corner
/// Whether every text frame the dialog changes (the selected items, else the frame that shows the
/// caret or every frame holding selected text) has an outline that is not its own axis-aligned rectangle: an oval, a polygon, rounded or other corner
/// options. Its text keeps one inset from the outline, as compose sets it; type on a path doesn't.
/// A selection that mixes them with rectangular frames shows the four sides, so each frame keeps
/// the sides left as shown.
@@ -1038,11 +1153,8 @@ fn selected_frame_is_shaped(app: &DesignApp) -> bool {
let bp = if item.corners.is_none() { item.path.to_bezpath() } else { designcraft_geom::corners::apply(&item.path, &item.corners) };
Some(designcraft_compose::FrameOutline::new(&bp, 0.0).is_some())
};
if st.selection.items.is_empty() {
let Some(fid) = st.selection.text.and_then(|t| t.frame) else { return false };
return shaped(fid).unwrap_or(false);
}
st.selection.items.iter().all(|&fid| shaped(fid).unwrap_or(false))
let ids = designcraft_engine::cmd::text_frame_options_frames(&app.session);
!ids.is_empty() && ids.iter().all(|&fid| shaped(fid).unwrap_or(false))
}
/// Object › Text Frame Options shows the selected frame's options. `_shown` keeps what it showed,
@@ -1072,6 +1184,16 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) {
if let Some(w) = selected_frame_width(app) {
d.fields.insert("_frameWidth".into(), json!(w));
}
// Typing in a frame: the frames taken when the dialog opens, so a preview that reflows the
// text into another frame does not change which frames the options apply to.
if let Some(ids) = typing_frame_ids(app) {
d.fields.insert("_targets".into(), ids);
// The document state those frames are in: another document, or the document after
// Revert, can reuse their ids.
d.fields.insert("_targetsDoc".into(), json!(app.session.active().map(|doc| doc.identity_epoch)));
// Undo puts back the document's id counter, so a frame made after it can take a stored id.
d.fields.insert("_targetsRevision".into(), json!(app.session.active().map(base_revision)));
}
d.fields.insert("_shown".into(), shown);
}
@@ -1128,6 +1250,22 @@ pub(crate) fn text_frame_fields(
})
}
/// The document's revision without a preview on top: the one the open gesture began at.
fn base_revision(st: &designcraft_engine::DocState) -> u64 {
st.interaction.as_ref().map_or(st.revision, |it| it.revision)
}
/// With the Type tool and no frame selected: the frame that shows the caret, or every frame that
/// holds part of the selected text.
fn typing_frame_ids(app: &DesignApp) -> Option<Value> {
let st = app.session.active()?;
if !st.selection.items.is_empty() || st.selection.text.is_none() {
return None;
}
let ids: Vec<u64> = designcraft_engine::cmd::text_frame_options_frames(&app.session).iter().map(|f| f.0).collect();
Some(json!(ids))
}
/// A 0..1 tint as a percentage ("40", "12.5").
fn percent(t: f32) -> String {
let s = format!("{:.2}", f64::from(t) * 100.0);
@@ -1241,8 +1379,7 @@ fn text_frame_preview(app: &mut DesignApp, d: &mut Dialog) {
let shown = d.fields.get(PREVIEWED).cloned();
if !d.b("preview") {
if shown.is_some() {
app.session.cancel_interaction();
d.fields.remove(PREVIEWED);
end_text_frame_preview(app, d);
d.fields.remove("status");
}
return;
@@ -1252,8 +1389,7 @@ fn text_frame_preview(app: &mut DesignApp, d: &mut Dialog) {
Err(e) => {
// Values OK would refuse don't stay on the page: it goes back to how it was.
if shown.is_some() {
app.session.cancel_interaction();
d.fields.remove(PREVIEWED);
end_text_frame_preview(app, d);
}
d.fields.insert("status".into(), json!(e));
return;
@@ -1265,16 +1401,16 @@ fn text_frame_preview(app: &mut DesignApp, d: &mut Dialog) {
// Nothing edited (yet, or any more): the page as it was.
if params.as_object().is_some_and(|m| m.keys().all(|k| k == "ids")) {
if shown.is_some() {
app.session.cancel_interaction();
d.fields.remove(PREVIEWED);
end_text_frame_preview(app, d);
}
return;
}
if shown.is_none()
&& let Err(e) = app.session.begin_interaction("Text Frame Options")
{
d.fields.insert("status".into(), json!(e.to_string()));
return;
if shown.is_none() {
if let Err(e) = app.session.begin_interaction("Text Frame Options") {
d.fields.insert("status".into(), json!(e.to_string()));
return;
}
d.fields.insert(PREVIEW_DOC.into(), json!(app.session.active().map(|doc| doc.uid)));
}
match app.session.preview("object.textFrameOptions", params.clone()) {
Ok(()) => d.fields.remove("status"),
@@ -1283,6 +1419,15 @@ fn text_frame_preview(app: &mut DesignApp, d: &mut Dialog) {
d.fields.insert(PREVIEWED.into(), params);
}
/// The preview goes from the document it was shown in, also when another document is active now.
fn end_text_frame_preview(app: &mut DesignApp, d: &mut Dialog) {
d.fields.remove(PREVIEWED);
match d.fields.remove(PREVIEW_DOC).as_ref().and_then(Value::as_u64) {
Some(uid) => app.session.cancel_interaction_of(uid),
None => app.session.cancel_interaction(),
}
}
/// Text Frame Options › General, as InDesign's: Columns (kind, number and width, gutter and
/// maximum, Balance Columns), Inset Spacing (top and bottom, the link, left and right; one Inset
/// for a non-rectangular frame), Vertical Justification (align, paragraph spacing limit) and
@@ -1529,8 +1674,8 @@ fn text_frame_baseline_grid(d: &Dialog, bad: &dyn Fn(&str) -> String) -> Result<
fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result<Value, String> {
seed_text_frame_options(app, d);
// The preview goes; the options are set once, as one undo step.
if d.fields.remove(PREVIEWED).is_some() {
app.session.cancel_interaction();
if d.fields.contains_key(PREVIEWED) {
end_text_frame_preview(app, d);
}
let p = text_frame_params(app, d)?;
if p.keys().all(|k| k == "ids") {
@@ -1632,16 +1777,28 @@ fn text_frame_params(app: &DesignApp, d: &Dialog) -> Result<Map<String, Value>,
json!(d.m("paragraphSpacingLimit").filter(|v| *v >= 0.0).ok_or_else(|| bad("the paragraph spacing limit is not a measure"))?),
);
}
// Typing in a frame: the options apply to that frame.
if let Some(st) = app.session.active()
&& st.selection.items.is_empty()
&& let Some(f) = st.selection.text.and_then(|t| t.frame)
{
p.insert("ids".into(), json!([f.0]));
if let Some(ids) = text_frame_targets(app, d).map_err(bad)? {
p.insert("ids".into(), ids);
}
Ok(p)
}
/// Typing in a frame: the frame that showed the caret, or every frame that held part of the
/// selected text, when the dialog opened. Those ids are refused once another document is active,
/// once Revert has replaced the document, or once the document has changed (Undo can give a new
/// frame a stored id).
fn text_frame_targets(app: &DesignApp, d: &Dialog) -> Result<Option<Value>, &'static str> {
let Some(ids) = d.fields.get("_targets").cloned() else { return Ok(typing_frame_ids(app)) };
let active = app.session.active().map(|doc| doc.identity_epoch);
if d.fields.get("_targetsDoc").and_then(Value::as_u64) != active {
return Err("the document the dialog opened on is no longer the active one");
}
if d.fields.get("_targetsRevision").and_then(Value::as_u64) != app.session.active().map(base_revision) {
return Err("the document changed after the dialog opened");
}
Ok(Some(ids))
}
/// File › Document Setup, filled from the document.
/// The active document's ruler unit (picas without a document).
fn doc_unit(app: &DesignApp) -> Unit {
@@ -4214,7 +4371,11 @@ pub fn cancel(app: &mut DesignApp) {
}
crate::new_document::end_preview(app);
if d.as_ref().is_some_and(|d| d.fields.contains_key(PREVIEWED)) {
app.session.cancel_interaction();
// Text Frame Options names the document its preview is in.
match d.as_ref().and_then(|d| d.fields.get(PREVIEW_DOC)).and_then(Value::as_u64) {
Some(uid) => app.session.cancel_interaction_of(uid),
None => app.session.cancel_interaction(),
}
}
app.cancel_pdf_import();
if let Some(d) = d
@@ -8944,6 +9105,8 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
/// The params a previewing dialog shows on the page (its interaction is open while it is set).
const PREVIEWED: &str = "_previewed";
/// The uid of the document a Text Frame Options preview is shown in.
const PREVIEW_DOC: &str = "_previewDoc";
/// Edit › Step and Repeat, as in InDesign: Repeat (Count, or Rows and Columns with Create as a
/// grid), Offset (Vertical, Horizontal, in the document's ruler units) and Preview, which shows
+56 -3
View File
@@ -1512,6 +1512,59 @@ fn text_frame_options_set_columns_baseline_and_wrap_as_indesign_does() {
assert_eq!((opts(&app).columns_kind, opts(&app).column_width), (designcraft_doc::ColumnsKind::FixedNumber, 120.0));
}
/// The frames Text Frame Options took while typing belong to the document it opened on: when
/// another document becomes active before OK, OK changes nothing there.
#[test]
fn text_frame_options_from_the_type_tool_stay_with_their_document() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
let make = |app: &mut crate::DesignApp| {
app.run("file.new", json!({})).unwrap();
let r = app.run("frame.create", json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap();
let (id, story) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
app.run("text.select", json!({"story": story, "anchor": 2})).unwrap();
id
};
let a = make(&mut app);
let mut d = Dialog::new("textFrameOptions", json!({}));
seed_text_frame_options(&app, &mut d);
assert_eq!(d.fields.get("_targets"), Some(&json!([a])));
d.fields.insert("columns".into(), json!(3));
// A second document, with a caret in a frame that has the same id, becomes active.
let b = make(&mut app);
assert_eq!(a, b);
app.ui.dialog = Some(d);
let err = confirm(&mut app).unwrap_err().to_string();
assert!(err.contains("no longer the active one"), "{err}");
let columns = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(b)).and_then(|i| i.text_frame().map(|t| t.options.columns));
assert_eq!(columns, Some(1), "the other document's frame is unchanged");
}
/// Revert replaces the document while Text Frame Options taken while typing stay open: OK is
/// refused, since the saved frame ids can name another frame in the reverted document.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn text_frame_options_from_the_type_tool_are_refused_after_revert() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let dir = tempfile::tempdir().unwrap();
app.run("file.saveAs", json!({"path": dir.path().join("revert.designcraft")})).unwrap();
let a = app.run("frame.create", json!({"rect": [36, 36, 300, 120], "content": "text", "text": "Unsaved"})).unwrap();
app.run("text.select", json!({"story": a["story"], "anchor": 1})).unwrap();
let mut d = Dialog::new("textFrameOptions", json!({}));
seed_text_frame_options(&app, &mut d);
d.fields.insert("columns".into(), json!(3));
app.run("file.revert", json!({})).unwrap();
let b = app.run("frame.create", json!({"rect": [36, 200, 300, 300], "content": "text", "text": "After"})).unwrap();
assert_eq!(a["id"], b["id"], "the reverted document gives a new frame the same id");
app.ui.dialog = Some(d);
let err = confirm(&mut app).unwrap_err().to_string();
assert!(err.contains("no longer the active one"), "{err}");
assert!(app.ui.dialog.is_some(), "the dialog stays open");
let b = designcraft_doc::ItemId(b["id"].as_u64().unwrap());
let columns = app.session.active().unwrap().doc.item(b).and_then(|i| i.text_frame().map(|t| t.options.columns));
assert_eq!(columns, Some(1), "the new frame is unchanged");
}
#[test]
fn baseline_grid_increment_error_explains_the_positive_measure_rule() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
@@ -1778,15 +1831,15 @@ fn text_frame_options_link_the_insets_only_when_every_selected_frame_has_even_si
assert!(!seeded(&app).b("insetLinked"));
app.run("text.select", json!({"story": other_even_story, "anchor": 0, "focus": 0})).unwrap();
assert!(seeded(&app).b("insetLinked"));
// A story threaded on into the uneven frame: a caret placed in the first frame changes only
// that frame, and a caret with no frame changes every frame of the story.
// A story threaded on into the uneven frame: a caret placed in the first frame, or a caret
// with no stored frame drawn in the first frame, changes only that frame.
let (first, story) = frame(&mut app, [300, 300, 500, 500], json!(6));
let (last, _) = frame(&mut app, [300, 520, 500, 700], json!([1, 2, 3, 4]));
app.run("text.thread", json!({"from": first.0, "to": last.0})).unwrap();
app.run("text.placeCaret", json!({"frame": first.0, "point": [310, 310]})).unwrap();
assert!(seeded(&app).b("insetLinked"));
app.run("text.select", json!({"story": story, "anchor": 0, "focus": 0})).unwrap();
assert!(!seeded(&app).b("insetLinked"));
assert!(seeded(&app).b("insetLinked"));
}
#[test]
+27
View File
@@ -346,6 +346,33 @@ fn pdf_export_menu_item_opens_the_dialog_or_exports() {
assert!(app.ui.dialog.is_none());
}
/// With the Type tool in the second frame of a thread, Cmd+B opens Text Frame Options, and OK
/// changes the frame that shows the caret.
#[test]
fn text_frame_options_open_from_the_type_tool() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let text = (0..40).map(|i| format!("Line {i} of the threaded story.")).collect::<Vec<_>>().join(" ");
let a = app.run("frame.create", json!({"rect": [36, 36, 300, 120], "content": "text", "text": text})).unwrap();
let b = app.run("frame.create", json!({"rect": [36, 200, 300, 700], "content": "text", "text": "", "caret": false})).unwrap();
let (a, b, sid) = (a["id"].as_u64().unwrap(), b["id"].as_u64().unwrap(), a["story"].as_u64().unwrap());
app.run("text.thread", json!({"from": a, "to": b})).unwrap();
// The end of the story is in the second frame.
app.run("text.select", json!({"story": sid, "anchor": text.len()})).unwrap();
assert!(super::state::enabled(&app, "object.textFrameOptions"), "the menu item is enabled while typing");
let ctx = egui::Context::default();
press_keys(&mut app, &ctx, &[(egui::Key::B, egui::Modifiers::COMMAND)]);
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("textFrameOptions"), "Cmd+B opens the dialog while typing");
if let Some(d) = app.ui.dialog.as_mut() {
d.fields.insert("columns".into(), json!(3));
}
crate::dialogs::confirm(&mut app).unwrap();
let columns = |app: &crate::DesignApp, id: u64| {
app.session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).and_then(|i| i.text_frame().map(|t| t.options.columns))
};
assert_eq!((columns(&app, a), columns(&app, b)), (Some(1), Some(3)));
}
#[test]
fn snap_preferences_are_a_command() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
@@ -1277,3 +1277,23 @@ fn transform_flips_and_rotates_from_properties() {
// The angle and scale fields are there.
assert!(h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some("90°")).is_some());
}
/// With the Type tool, the Text Frame section shows the frame Text Frame Options change: the frame
/// the caret is drawn in, not the caret's stored frame.
#[test]
fn text_frame_section_shows_the_frame_that_shows_the_caret() {
use designcraft_doc::{ItemId, Selection, StoryId, TextSel};
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let text = (0..40).map(|i| format!("Line {i} of the threaded story.")).collect::<Vec<_>>().join(" ");
let a = app.run("frame.create", json!({"rect": [36, 36, 300, 120], "content": "text", "text": text})).unwrap();
let b = app.run("frame.create", json!({"rect": [36, 200, 300, 700], "content": "text", "text": "", "caret": false})).unwrap();
let (a, b, sid) = (a["id"].as_u64().unwrap(), b["id"].as_u64().unwrap(), a["story"].as_u64().unwrap());
app.run("text.thread", json!({"from": a, "to": b})).unwrap();
app.run("object.textFrameOptions", json!({"ids": [b], "columns": 3})).unwrap();
// The end of the story is drawn in the second frame; the caret's stored frame is the first.
app.session.doc_mut().unwrap().selection =
Selection::text(TextSel { story: StoryId(sid), anchor: text.len(), focus: text.len(), frame: Some(ItemId(a)), cell: None });
let shown = super::text::text_frame_target(&app).map(|(id, o)| (id, o.columns));
assert_eq!(shown, Some((ItemId(b), 3)));
}
+12 -4
View File
@@ -516,13 +516,21 @@ pub(super) fn quick_actions_text(app: &mut DesignApp, ui: &mut Ui) {
}
}
/// Text Frame section: columns, gutter, [Options].
/// The text frame the Text Frame section shows, as Text Frame Options shows it: the first selected
/// frame, the frame that shows the caret, or the first frame holding selected text. Its id and its
/// options.
pub(super) fn text_frame_target(app: &DesignApp) -> Option<(designcraft_doc::ItemId, designcraft_doc::TextFrameOptions)> {
let st = app.session.active()?;
let fid = designcraft_engine::cmd::text_frame_options_frames(&app.session).first().copied()?;
Some((fid, st.doc.item(fid)?.text_frame()?.options.clone()))
}
/// Text Frame section: columns, gutter, [Options]. With the Type tool it changes the frame that
/// shows the caret, or every frame that holds part of the selected text.
pub(super) fn text_frame_section(app: &mut DesignApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let u = units(app);
let Some(st) = app.session.active() else { return };
let fid = st.selection.items.first().copied().or_else(|| st.selection.text.and_then(|t| t.frame));
let Some(opts) = fid.and_then(|f| st.doc.item(f)).and_then(|i| i.text_frame()).map(|t| t.options.clone()) else { return };
let Some((_, opts)) = text_frame_target(app) else { return };
let b = block(ui, FIELD_H);
icons::paint(ui.painter(), sub(b, 0.0, 1.0, 19.0, 19.0), "text-columns", t.icon);
if let Some(v) = place(ui, sub(b, 22.0, 0.0, 54.0, FIELD_H), |ui| {
+10
View File
@@ -231,6 +231,16 @@ execute {"command": "type.para", "params": {"attrs": {"hyphenate": fals
With a text selection, this formats the selected paragraphs; with selected text frames, it formats their whole stories.
`object.textFrameOptions` (columns, inset spacing, vertical justification and the rest of Text Frame Options) acts on
the selected frames or on the `ids` the call passes. With the Type tool and no frame selected, it acts on the frame that
shows the insertion point, or on every frame that holds part of the selected text, not on the other frames of the
thread. With the insertion point in a footnote or a table cell, it acts on the frame that shows the footnote or the
cell. An empty `ids`, or an `ids` that is not a list of object ids, is an error. Options a frame already has leave the document unchanged and add no undo step.
```text
execute {"command": "object.textFrameOptions", "params": {"verticalJustification": "center", "inset": 6}}
```
Use `execute` with `object.fit` to fit a selected graphic frame (or pass `ids`):
| `mode` | What it does |
+1
View File
@@ -352,6 +352,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame from the top line's ascent to the bottom line's descent, between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet.
- Auto-size (Text Frame Options › Auto-Size, `object.textFrameOptions {autoSize: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference, useMinimumHeight, minimumHeight, useMinimumWidth, minimumWidth, noLineBreaks}`): the frame fits its text exactly after every edit (typing, pasting, style changes, option changes) in the same undo step (opening a file keeps the frame sizes it gives); it grows and shrinks around the reference point, which stays put in spread space for rotated frames too, never below the minimum height/width and never past 10,000 pt. Width Only and No Line Breaks set each paragraph on one line; Height and Width without No Line Breaks keeps the width; Keep Proportions scales both sides until the text fits. IDML keeps all the options (`AutoSizing…`, `…ForAutoSizing` attributes). Not covered: frames of a thread other than the last, vertical frames, type on a path, and multi-column balancing in Keep Proportions.
- Balance Columns (Text Frame Options › Balance Columns, `object.textFrameOptions {balanceColumns}`, IDML `VerticalBalanceColumns`): in the frame the story ends in, the columns after the last explicit column break fill to about the same depth (the last may be shorter by a line per column); keep options hold, spanning and split paragraphs keep their own balancing, vertical justification then applies to each column as usual, and vertical frames balance their bands. A frame the text runs on from, or that is overset, fills normally.
- Text Frame Options from the Type tool (Object › Text Frame Options…, Cmd+B, `object.textFrameOptions` without `ids`): with the insertion point in a frame, the options change the frame it is drawn in; with text selected, every frame that holds part of it. Options a frame already has add no undo step.
- Text Frame Options dialog, laid out as InDesign's (General, Column Rules, Baseline Options, Auto-Size; framed Columns, Inset Spacing and Vertical Justification groups with spinner fields; Fixed Number shows each column's width as the fields change). Preview shows the edits on the page until OK (one undo step) or Cancel. An invalid value (such as a gutter that isn't a measure) takes the preview off the page, so the frame shows as it was, and the dialog's status line gives the reason; OK refuses the value too. Preview stays checked and shows the edits again once the value is valid. Vertical Justification › Paragraph Spacing Limit (`object.textFrameOptions {paragraphSpacingLimit}`, IDML `VerticalThreshold`): with Justify, the extra space goes between paragraphs up to the limit, the rest between lines.
- Text Frame Options › Inset Spacing for a non-rectangular frame (an oval, a polygon, a frame with corner options; when every selected text frame is one, a mixed selection keeps the four sides): Top, Left, Bottom, Right and the link are dimmed, and one Inset field sets the distance kept between the text and the frame's outline (`object.textFrameOptions {inset: <number>}`, all four sides).
- Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable).