From 5fb75aaefcf71d57e328c581b8602c08c97ed7ec Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Thu, 12 Nov 2026 14:27:08 -0800 Subject: [PATCH] Properties: Text Frame section shows inset spacing and vertical justification (#2661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Properties: Text Frame section shows inset spacing and vertical justification Properties › Text Frame now shows the frame's inset spacing under the columns: Top and Bottom, a link that makes all four the same, then Left and Right. A non-rectangular frame shows one Inset, the distance its text keeps from the outline. Four buttons set Vertical Justification to Top, Center, Bottom or Justify. Type on a path shows neither. The section also shows with the Type tool in a frame. There it changes the frame that shows the insertion point, or every frame that holds part of the selected text, as Object › Text Frame Options does. * Properties: one Inset for a non-rectangular frame keeps the other sides The single Inset sets only the top inset, the one kept from the outline. A rectangular frame that holds part of the same selected text keeps its own sides. * Properties: the one Inset field and the composer share the frame outline test FrameOutline::of_item builds an item's outline with its corner options applied. The composer uses it to keep the top inset from the outline, and the Properties panel uses it to decide whether a frame shows one Inset, so the two give the same answer for every frame. * Text Frame Options: frames taken while typing stay with their document OK is refused when another document became active after the dialog opened, so the saved frame ids do not change frames in that document. * Properties: the inset link starts linked again after Revert The link's state is kept per document state instead of per document, so a frame with equal insets in the reverted document starts linked. * Text Frame Options: frames taken while typing are refused after Revert Revert keeps the document's id but replaces its state, so a new frame can reuse a saved frame id. The dialog now ties the frames to the document state, and OK is refused after Revert as it is in another document. * Properties: the one Inset of a non-rectangular frame sets all four sides The composer bounds a shaped frame's text by its rectangle less all four insets as well as by the outline, so a single Inset that changed only the top left hidden sides in place: lowering a uniform 96 pt inset to 0 kept text overset. When every frame the field changes is shaped, it now sets all four sides. With a rectangular frame among them, only the top inset changes and each frame keeps its other sides. * Fix text frame dialog merge resolution * Keep text frame preview targets fixed * Fix target field key inference * Tests: Text Frame Options show four sides for text through an oval and a rectangle With the Type tool and text selected through an oval and a threaded rectangle, with the oval as the caret's stored frame, the dialog shows the four inset sides, a changed Top leaves the rectangle's other sides as they were, and the link stays off while the rectangle's sides are uneven. * Properties: the inset link starts off when another frame has uneven sides With selected text that runs through several frames, the link of Text Frame > Inset Spacing started linked when the frame the section shows had four equal insets, and a changed Top then set all four sides of every frame. The link now starts linked only when every frame the field changes has four equal insets. Its state is kept for that set of frames, so a link turned on for one frame does not carry over to selected text that also runs through a frame with uneven sides. --------- Co-authored-by: Dylan M. Taylor --- ROADMAP.md | 2 + crates/compose/src/lib.rs | 5 +- crates/compose/src/outline.rs | 7 + crates/engine/src/cmd/object.rs | 1 - crates/ui-egui/src/dialogs.rs | 6 + crates/ui-egui/src/dialogs/tests.rs | 55 +++- crates/ui-egui/src/icons.rs | 27 ++ crates/ui-egui/src/panels/properties.rs | 9 +- crates/ui-egui/src/panels/properties/tests.rs | 269 +++++++++++++++++- crates/ui-egui/src/panels/properties/text.rs | 132 ++++++++- docs/target-app-parity.md | 1 + 11 files changed, 496 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9e83d73ae..ee0f4e022 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -394,12 +394,14 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-11-12** · Properties › Text Frame shows the frame's inset spacing (Top, Bottom, Left and Right with a link that makes all four the same; one Inset for a non-rectangular frame) and four Vertical Justification buttons (Top, Center, Bottom, Justify), and the section also shows with the Type tool in a frame, where it changes the frame that shows the insertion point. No readiness percentage change. - **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** · Center, Bottom and Justify vertical justification in a shaped frame test each moved line against the outline down to its baseline. The descenders of earlier lines may cross the outline, as the last line's do, so an outline that narrows under an earlier line no longer holds the text higher by that line's descent. 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** · A placed JPEG drawn at a half, a quarter or an eighth of its pixel size or less is decoded at that size straight from its compressed blocks, so the canvas, page thumbnails and loaded-graphic thumbnails no longer decode and keep the full-size picture for it. CMYK JPEGs and the full size still decode in full. No readiness percentage change. - **2026-11-12** · A decoded placed image becomes a screen pixmap in its own buffer, premultiplied in place, and opaque pixels are left as they are: 120 ms becomes 7 ms for a 24-megapixel opaque photo, with the same pixels. 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. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index a367e7ade..333959626 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1205,10 +1205,7 @@ pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId // area (all four insets) still bounds the lines. let outline = match &tf.options.path { Some(_) => None, - None => { - let bp = if item.corners.is_none() { item.path.to_bezpath() } else { designcraft_geom::corners::apply(&item.path, &item.corners) }; - FrameOutline::new(&bp, tf.options.inset[0]) - } + None => FrameOutline::of_item(item, tf.options.inset[0]), }; // Vertical type: composed in the turned box; wraps and page rects turned with it. let vertical = doc.frame_vertical(item); diff --git a/crates/compose/src/outline.rs b/crates/compose/src/outline.rs index bfc709710..dd1f59c0e 100644 --- a/crates/compose/src/outline.rs +++ b/crates/compose/src/outline.rs @@ -59,6 +59,13 @@ impl FrameOutline { Some(FrameOutline { edges, inset }) } + /// The outline of `item`'s path with its corner options applied, or None when that is the + /// item's own rectangle. The composer and the Properties panel's one Inset field both use it. + pub fn of_item(item: &designcraft_doc::Item, inset: f64) -> Option { + let bp = if item.corners.is_none() { item.path.to_bezpath() } else { designcraft_geom::corners::apply(&item.path, &item.corners) }; + FrameOutline::new(&bp, inset) + } + /// The outline mapped by `xf` (into a vertical frame's turned box). pub fn transformed(&self, xf: Affine) -> FrameOutline { FrameOutline { edges: self.edges.iter().map(|&(a, b)| (xf * a, xf * b)).collect(), inset: self.inset } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index c269e4f1d..c41564047 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2732,7 +2732,6 @@ fn content_type(s: &mut Session, p: &Value) -> Result { /// 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. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a5babb120..409943f75 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1173,6 +1173,12 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { m.insert("inset".into(), json!(format_measure(o.inset[0], units))); } d.fields.insert("_shaped".into(), json!(shaped)); + if let Some(targets) = typing_frame_ids(app) { + d.fields.entry("_targets").or_insert(targets); + if let Some(epoch) = app.session.active().map(|doc| doc.identity_epoch) { + d.fields.entry("_targetsDoc").or_insert(json!(epoch)); + } + } if let Value::Object(m) = &shown { for (k, v) in m { d.fields.entry(k.clone()).or_insert_with(|| v.clone()); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 0bad3ce0e..ca057856d 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -1690,6 +1690,47 @@ fn text_frame_options_show_four_sides_for_a_mixed_selection() { assert!(d.b("_shaped")); } +/// With the Type tool, text selected through an oval and a threaded rectangle shows the four +/// sides even when the caret's frame is the oval, and a changed side leaves the rectangle's other +/// sides as they were. +#[test] +fn text_frame_options_show_four_sides_for_text_through_an_oval_and_a_rectangle() { + 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::>().join(" "); + let oval = app.run("frame.create", json!({"rect": [36, 36, 236, 136], "shape": "ellipse", "content": "text", "text": text})).unwrap(); + let sid = oval["story"].as_u64().unwrap(); + let oval = designcraft_doc::ItemId(oval["id"].as_u64().unwrap()); + let rect = app.run("frame.create", json!({"rect": [36, 200, 336, 700], "content": "text", "text": "", "caret": false})).unwrap(); + let rect = designcraft_doc::ItemId(rect["id"].as_u64().unwrap()); + app.run("text.thread", json!({"from": oval.0, "to": rect.0})).unwrap(); + app.run("object.textFrameOptions", json!({"ids": [oval.0, rect.0], "inset": [6, 2, 3, 4]})).unwrap(); + let opts = |app: &DesignApp, id| app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.clone(); + let select_through_both = |app: &mut DesignApp| { + app.run("text.select", json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap(); + // The caret's stored frame is the oval. + if let Some(t) = app.session.active_mut().unwrap().selection.text.as_mut() { + t.frame = Some(oval); + } + assert_eq!(designcraft_engine::cmd::text_frame_options_frames(&app.session), vec![oval, rect], "the selected text runs through both frames"); + }; + select_through_both(&mut app); + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(&app, &mut d); + assert!(!d.b("_shaped") && !d.fields.contains_key("inset"), "text through a rectangle shows the four sides"); + d.fields.insert("insetTop".into(), json!("1p0")); + app.ui.dialog = Some(d); + confirm(&mut app).unwrap(); + assert_eq!(opts(&app, rect).inset, [12.0, 2.0, 3.0, 4.0]); + assert_eq!(opts(&app, oval).inset, [12.0, 2.0, 3.0, 4.0]); + // An even oval does not link the sides while the rectangle's sides are uneven. + app.run("object.textFrameOptions", json!({"ids": [oval.0], "inset": [5, 5, 5, 5]})).unwrap(); + select_through_both(&mut app); + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(&app, &mut d); + assert!(!d.b("insetLinked"), "a frame with uneven sides among the targets leaves the link off"); +} + /// The dialog dims Top, Left, Bottom, Right and the link for a non-rectangular frame and adds an /// Inset field; for a rectangle it shows no Inset field and the sides stay editable. #[test] @@ -4350,12 +4391,20 @@ fn retype_beside(h: &mut egui_kittest::Harness<'static, crate::test_window::Wind h.run_steps(3); } +/// The dialog's "Make all settings the same" links, in reading order: the Properties panel's +/// own links (its Text Frame insets, with a caret in a frame) are to the right of its Quick +/// Actions heading's left edge and are left out. +fn dialog_links(h: &egui_kittest::Harness<'static, crate::test_window::Window>) -> Vec { + use egui_kittest::kittest::Queryable; + let panel_left = h.query_by_label("Quick Actions").map_or(f32::INFINITY, |n| n.rect().left()); + h.get_all_by_label("Make all settings the same").map(|n| n.rect()).filter(|r| r.center().x < panel_left).collect() +} + /// The course's chapter 40: Paragraph Border's four weights are linked as InDesign's, so a /// side typed in sets all four; with the link off ("Make all settings the same") a side is set /// alone, as the lesson gives the border only a thick left side. The corners link the same way. #[test] fn paragraph_border_sides_and_corners_link_as_indesigns() { - use egui_kittest::kittest::Queryable; let (app, _) = ruled_paragraph(); let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "border", "p.borderOn": true}))); @@ -4370,7 +4419,7 @@ fn paragraph_border_sides_and_corners_link_as_indesigns() { // The link of the weights (0), corners (1) or offsets (2), where it is now: the dialog // scrolls as fields take focus. let link = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, n: usize| { - let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); + let links = dialog_links(h); assert_eq!(links.len(), 3, "weights, corners and offsets each have a link"); links[n].center() }; @@ -4427,7 +4476,7 @@ fn paragraph_shading_page_is_laid_out_as_indesigns() { assert_eq!(field(&h, "shadingOverprint"), json!(true)); // The corners' link (0) and the offsets' (1). let link = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, n: usize| { - let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); + let links = dialog_links(h); assert_eq!(links.len(), 2, "corners and offsets each have a link"); links[n].center() }; diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c8eeee911..e15c90ec9 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -997,6 +997,19 @@ fn paint_text(pen: &mut Pen, name: &str) -> bool { pen.line(&[(12.0, 3.5), (12.0, 16.5)]); pen.line(&[(9.0, 10.0), (11.0, 10.0)]); } + "vjust-top" | "vjust-center" | "vjust-bottom" | "vjust-justify" => { + // A frame with three lines of text at its top, middle or bottom, or spread over it. + pen.rect(2.5, 2.5, 17.5, 17.5); + let ys = match name { + "vjust-top" => [5.5, 8.0, 10.5], + "vjust-center" => [7.5, 10.0, 12.5], + "vjust-bottom" => [9.5, 12.0, 14.5], + _ => [5.5, 10.0, 14.5], + }; + for y in ys { + pen.line(&[(5.0, y), (15.0, y)]); + } + } "small-caps" => { // Two original T shapes share a baseline; no phonetic small-cap glyph is required. pen.line(&[(3.0, 5.0), (10.0, 5.0)]); @@ -1383,6 +1396,20 @@ mod tests { } } + /// Each Vertical Justification button in Properties › Text Frame has its own glyph. + #[test] + fn vertical_justification_glyphs_are_distinct() { + let names = ["vjust-top", "vjust-center", "vjust-bottom", "vjust-justify"]; + let unknown = drawing("no-such-icon"); + let glyphs: Vec = names.iter().map(|n| drawing(n)).collect(); + for (k, g) in glyphs.iter().enumerate() { + assert_ne!(*g, unknown, "{} falls back to the placeholder", names[k]); + for (j, other) in glyphs.iter().enumerate().skip(k + 1) { + assert_ne!(g, other, "{} and {} draw alike", names[k], names[j]); + } + } + } + #[test] fn distribute_icons_are_distinct_from_align_and_each_other() { // `objalign-*` ship upstream and back the Properties Align row; only the diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 9b949f547..b6dabfe65 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -38,7 +38,8 @@ pub use object_panels::{align_panel, attributes_panel, effects_panel, pathfinder use object_panels::{anchor_point_sections, pathfinder_row}; pub(crate) use text::{LANGUAGES, kerning_control, language_menu}; use text::{ - bullets_section, character_section, paragraph_section, quick_actions_text, text_appearance_section, text_frame_section, text_style_section, + bullets_section, character_section, paragraph_section, quick_actions_text, text_appearance_section, text_frame_section, text_frame_target, + text_style_section, }; pub use typography::{DIGIT_OPTIONS, character_panel, digit_label, paragraph_panel, show_typography_panel}; #[cfg(test)] @@ -137,6 +138,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { section(ui, crate::i18n::tr(&app.ui.language, "Bullets and Numbering"), true); bullets_section(app, ui); divider(ui); + // The frame that shows the caret, as with the frame selected. + if text_frame_target(app).is_some() { + section(ui, crate::i18n::tr(&app.ui.language, "Text Frame"), true); + text_frame_section(app, ui); + divider(ui); + } section(ui, crate::i18n::tr(&app.ui.language, "Quick Actions"), true); quick_actions_text(app, ui); return; diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 59e0cbd1e..1792e3b3a 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -798,7 +798,9 @@ fn bullets_options_open_the_bullets_and_numbering_dialog() { let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "4 eggs"})).unwrap(); app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 6})).unwrap(); let mut h = panel(app); - h.get_by_label("Options").click(); + // Bullets and Numbering's Options, above Text Frame's. + let bullets = h.get_all_by_label("Options").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + bullets.click(); h.run_steps(3); let d = h.state().ui.dialog.clone().expect("a dialog"); assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "bullets")); @@ -1278,6 +1280,269 @@ fn transform_flips_and_rotates_from_properties() { assert!(h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some("90°")).is_some()); } +/// The vertical extent of Properties › Text Frame: from its heading (the last "Text Frame" label; +/// the selection band above may say "Text Frame" too) to the Quick Actions heading. +fn text_frame_band(h: &Harness<'_, DesignApp>) -> (f32, f32) { + let top = h.get_all_by_label("Text Frame").map(|n| n.rect().bottom()).fold(f32::MIN, f32::max); + (top, h.get_by_label("Quick Actions").rect().top()) +} + +/// The text fields of Properties › Text Frame, in reading order. +fn text_frame_fields(h: &Harness<'_, DesignApp>) -> Vec { + let (top, bottom) = text_frame_band(h); + let mut v: Vec = + h.get_all_by_role(egui::accesskit::Role::TextInput).map(|n| n.rect()).filter(|r| r.center().y > top && r.center().y < bottom).collect(); + v.sort_by(|a, b| (a.top(), a.left()).partial_cmp(&(b.top(), b.left())).unwrap()); + v +} + +/// Clicks the control labeled `label` inside Properties › Text Frame. +fn click_in_text_frame(h: &mut Harness<'_, DesignApp>, label: &str) { + let (top, bottom) = text_frame_band(h); + h.get_all_by_label(label).find(|n| n.rect().center().y > top && n.rect().center().y < bottom).unwrap().click(); + h.run_steps(3); +} + +fn type_in_field(h: &mut Harness<'_, DesignApp>, at: egui::Rect, text: &str) { + h.hover_at(at.center()); + h.drag_at(at.center()); + h.drop_at(at.center()); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text(text.into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); +} + +fn frame_options(app: &DesignApp, id: u64) -> designcraft_doc::TextFrameOptions { + app.session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().options.clone() +} + +/// Properties › Text Frame shows the frame's inset spacing under the columns, linked while the +/// four insets are the same, and the four vertical justification buttons. Each change is one +/// undo step; the button that is already on adds none. +#[test] +fn properties_text_frame_sets_inset_spacing_and_vertical_justification() { + use designcraft_doc::VerticalJustification; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 300], "content": "text", "text": "Hello"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = panel(app); + // Columns and gutter, then Top and Left, then Bottom and Right. + let f = text_frame_fields(&h); + assert_eq!(f.len(), 6, "columns, gutter and four insets: {f:?}"); + let steps = undo_steps(h.state()); + // Linked: the top inset sets all four. + type_in_field(&mut h, f[2], "12pt"); + assert_eq!(frame_options(h.state(), id).inset, [12.0; 4]); + assert_eq!(undo_steps(h.state()), steps + 1); + // Unlinked: the bottom one alone. + click_in_text_frame(&mut h, "Make all settings the same"); + let f = text_frame_fields(&h); + type_in_field(&mut h, f[4], "6pt"); + assert_eq!(frame_options(h.state(), id).inset, [12.0, 12.0, 6.0, 12.0], "[top, left, bottom, right]"); + // Vertical justification. + assert_eq!(frame_options(h.state(), id).vertical_justification, VerticalJustification::Top); + for (label, vj) in + [("Bottom", VerticalJustification::Bottom), ("Center", VerticalJustification::Center), ("Justify", VerticalJustification::Justify)] + { + click_in_text_frame(&mut h, label); + assert_eq!(frame_options(h.state(), id).vertical_justification, vj, "{label}"); + } + let steps = undo_steps(h.state()); + click_in_text_frame(&mut h, "Justify"); + assert_eq!(undo_steps(h.state()), steps, "the button already on adds no undo step"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(frame_options(h.state(), id).vertical_justification, VerticalJustification::Center); +} + +/// Insets unlinked before Revert start linked again in the reverted document, whose frame has the +/// same id and four equal insets. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn properties_text_frame_inset_link_starts_linked_after_revert() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 300], "content": "text", "text": "Hello"})).unwrap()["id"].as_u64().unwrap(); + let dir = tempfile::tempdir().unwrap(); + app.run("file.saveAs", json!({"path": dir.path().join("revert.designcraft")})).unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = panel(app); + click_in_text_frame(&mut h, "Make all settings the same"); + h.state_mut().run("frame.create", json!({"rect": [320, 72, 400, 150], "content": "text", "text": "unsaved"})).unwrap(); + h.state_mut().run("file.revert", json!({})).unwrap(); + h.state_mut().run("selection.set", json!({"ids": [id]})).unwrap(); + h.run_steps(3); + let f = text_frame_fields(&h); + type_in_field(&mut h, f[4], "6pt"); + assert_eq!(frame_options(h.state(), id).inset, [6.0; 4], "linked: the bottom inset sets all four"); +} + +/// With the Type tool in the second frame of a thread, Properties shows the Text Frame section, +/// and its insets and vertical justification change that frame and not the first. +#[test] +fn properties_text_frame_shows_while_typing_and_changes_the_caret_frame() { + use designcraft_doc::VerticalJustification; + 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::>().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(); + let mut h = panel(app); + let f = text_frame_fields(&h); + assert_eq!(f.len(), 6, "columns, gutter and four insets while typing: {f:?}"); + type_in_field(&mut h, f[2], "9pt"); + click_in_text_frame(&mut h, "Bottom"); + let (oa, ob) = (frame_options(h.state(), a), frame_options(h.state(), b)); + assert_eq!((oa.inset, ob.inset), ([0.0; 4], [9.0; 4])); + assert_eq!((oa.vertical_justification, ob.vertical_justification), (VerticalJustification::Top, VerticalJustification::Bottom)); + assert!(h.state().session.active().unwrap().selection.text.is_some(), "the caret stays in the text"); +} + +/// A non-rectangular text frame shows one Inset, which sets the distance its text keeps from +/// the outline. The composer also bounds its text by all four insets, so the field sets all four. +#[test] +fn properties_text_frame_shows_one_inset_for_an_oval() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 300], "shape": "ellipse", "content": "text", "text": "Hello"})).unwrap()["id"] + .as_u64() + .unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = panel(app); + let f = text_frame_fields(&h); + assert_eq!(f.len(), 3, "columns, gutter and one inset: {f:?}"); + h.state_mut().run("object.textFrameOptions", json!({"ids": [id], "inset": [1, 2, 3, 4]})).unwrap(); + h.run_steps(2); + type_in_field(&mut h, f[2], "8pt"); + assert_eq!(frame_options(h.state(), id).inset, [8.0; 4]); +} + +/// Lowering an oval's uniform inset with the one Inset field lowers every side, so no hidden +/// side keeps the old inset and the text area that goes with it. +#[test] +fn properties_text_frame_one_inset_lowers_a_uniform_inset_on_every_side() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 300], "shape": "ellipse", "content": "text", "text": "Hello"})).unwrap()["id"] + .as_u64() + .unwrap(); + app.run("object.textFrameOptions", json!({"ids": [id], "inset": 96})).unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = panel(app); + let f = text_frame_fields(&h); + assert_eq!(f.len(), 3, "columns, gutter and one inset: {f:?}"); + type_in_field(&mut h, f[2], "0pt"); + assert_eq!(frame_options(h.state(), id).inset, [0.0; 4]); +} + +/// With selected text that runs from an oval into a rectangular frame, the one Inset field +/// changes the top inset of both, and each frame keeps its other sides. +#[test] +fn properties_text_frame_one_inset_keeps_the_sides_of_a_rectangle_in_the_selection() { + 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::>().join(" "); + let a = app.run("frame.create", json!({"rect": [72, 72, 300, 300], "shape": "ellipse", "content": "text", "text": text})).unwrap(); + let b = app.run("frame.create", json!({"rect": [72, 400, 300, 760], "content": "text", "text": "", "caret": false})).unwrap(); + let (oval, rect, sid) = (a["id"].as_u64().unwrap(), b["id"].as_u64().unwrap(), a["story"].as_u64().unwrap()); + app.run("text.thread", json!({"from": oval, "to": rect})).unwrap(); + app.run("object.textFrameOptions", json!({"ids": [oval, rect], "inset": [1, 2, 3, 4]})).unwrap(); + app.run("text.select", json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap(); + assert_eq!( + designcraft_engine::cmd::text_frame_options_frames(&app.session), + [designcraft_doc::ItemId(oval), designcraft_doc::ItemId(rect)], + "the selected text runs through both frames" + ); + let mut h = panel(app); + let f = text_frame_fields(&h); + assert_eq!(f.len(), 3, "columns, gutter and one inset for the first frame, an oval: {f:?}"); + type_in_field(&mut h, f[2], "8pt"); + assert_eq!(frame_options(h.state(), oval).inset, [8.0, 2.0, 3.0, 4.0]); + assert_eq!(frame_options(h.state(), rect).inset, [8.0, 2.0, 3.0, 4.0]); +} + +/// With selected text that runs through two rectangular frames, the inset link starts off when +/// one of them has uneven sides, even while the frame the section shows has four equal insets, so +/// a changed Top leaves the other sides of each frame as they were. +#[test] +fn properties_text_frame_inset_link_starts_off_when_another_frame_has_uneven_sides() { + for even_first in [true, false] { + 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::>().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(); + let (even, uneven) = if even_first { (a, b) } else { (b, a) }; + app.run("object.textFrameOptions", json!({"ids": [even], "inset": 5})).unwrap(); + app.run("object.textFrameOptions", json!({"ids": [uneven], "inset": [1, 2, 3, 4]})).unwrap(); + app.run("text.select", json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap(); + assert_eq!( + designcraft_engine::cmd::text_frame_options_frames(&app.session), + [designcraft_doc::ItemId(a), designcraft_doc::ItemId(b)], + "the selected text runs through both frames" + ); + let mut h = panel(app); + let f = text_frame_fields(&h); + assert_eq!(f.len(), 6, "columns, gutter and four insets: {f:?}"); + type_in_field(&mut h, f[2], "8pt"); + assert_eq!(frame_options(h.state(), even).inset, [8.0, 5.0, 5.0, 5.0], "even frame first: {even_first}"); + assert_eq!(frame_options(h.state(), uneven).inset, [8.0, 2.0, 3.0, 4.0], "even frame first: {even_first}"); + } +} + +/// A link turned on for one frame does not carry over to selected text that runs from that frame +/// into a frame with uneven sides: the link starts off there, and a changed Top leaves the other +/// sides as they were. +#[test] +fn properties_text_frame_inset_link_of_one_frame_does_not_carry_to_a_text_selection() { + 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::>().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": [a], "inset": 5})).unwrap(); + app.run("object.textFrameOptions", json!({"ids": [b], "inset": [1, 2, 3, 4]})).unwrap(); + app.run("selection.set", json!({"ids": [a]})).unwrap(); + let mut h = panel(app); + // Off, then on again: the link of the first frame alone is now turned on by hand. + click_in_text_frame(&mut h, "Make all settings the same"); + click_in_text_frame(&mut h, "Make all settings the same"); + // The caret at the start of the story is in the first frame; the selection runs into the second. + h.state_mut().run("text.select", json!({"story": sid, "anchor": text.len(), "focus": 0})).unwrap(); + h.run_steps(3); + let f = text_frame_fields(&h); + assert_eq!(f.len(), 6, "columns, gutter and four insets: {f:?}"); + type_in_field(&mut h, f[2], "8pt"); + assert_eq!(frame_options(h.state(), a).inset, [8.0, 5.0, 5.0, 5.0]); + assert_eq!(frame_options(h.state(), b).inset, [8.0, 2.0, 3.0, 4.0]); +} + +/// Type on a path sets one line along the path: Properties › Text Frame shows no insets and no +/// vertical justification buttons for it. +#[test] +fn properties_text_frame_shows_no_insets_for_type_on_a_path() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap(); + app.run("type.onPath", json!({"id": id, "text": "Around"})).unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + assert!(frame_options(&app, id).path.is_some()); + let h = panel(app); + assert_eq!(text_frame_fields(&h).len(), 2, "columns and gutter only"); + let (top, bottom) = text_frame_band(&h); + assert!(h.query_all_by_label("Justify").all(|n| n.rect().center().y < top || n.rect().center().y > bottom)); +} + /// 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] @@ -1294,6 +1559,6 @@ fn text_frame_section_shows_the_frame_that_shows_the_caret() { // 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)); + let shown = super::text::text_frame_target(&app).map(|(id, o, _)| (id, o.columns)); assert_eq!(shown, Some((ItemId(b), 3))); } diff --git a/crates/ui-egui/src/panels/properties/text.rs b/crates/ui-egui/src/panels/properties/text.rs index f444d6ba2..94e3b4ef8 100644 --- a/crates/ui-egui/src/panels/properties/text.rs +++ b/crates/ui-egui/src/panels/properties/text.rs @@ -517,20 +517,31 @@ pub(super) fn quick_actions_text(app: &mut DesignApp, ui: &mut Ui) { } /// 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)> { +/// frame, the frame that shows the caret, or the first frame holding selected text. Its id, its +/// options, and whether its outline is not its own rectangle (an oval, a polygon or corner +/// options), where the text keeps one inset from the outline. Type on a path is not shaped. +pub(super) fn text_frame_target(app: &DesignApp) -> Option<(designcraft_doc::ItemId, designcraft_doc::TextFrameOptions, bool)> { 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())) + let item = st.doc.item(fid)?; + let opts = item.text_frame()?.options.clone(); + Some((fid, opts, is_shaped(item))) } -/// 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. +/// Whether a text frame's outline is not its own rectangle, where its text keeps one inset from +/// the outline. Type on a path is not shaped. +fn is_shaped(item: &designcraft_doc::Item) -> bool { + item.text_frame().is_some_and(|tf| tf.options.path.is_none()) && designcraft_compose::FrameOutline::of_item(item, 0.0).is_some() +} + +/// Text Frame section: columns, gutter, [Options]; Inset Spacing (Top and Bottom, the link that +/// makes all four the same, Left and Right; one Inset for a non-rectangular frame); the four +/// Vertical Justification buttons (neither for type on a path). 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((_, opts)) = text_frame_target(app) else { return }; + let Some((fid, opts, shaped)) = 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| { @@ -548,6 +559,113 @@ pub(super) fn text_frame_section(app: &mut DesignApp, ui: &mut Ui) { if place(ui, sub(b, 164.0, 0.5, ow, 20.0), |ui| outline_button(ui, crate::i18n::tr(&app.ui.language, "Options"), ow)).clicked() { app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({}))); } + // Type on a path sets one line along the path: insets and vertical justification do not apply. + if opts.path.is_some() { + return; + } + text_frame_insets(app, ui, fid, &opts, shaped); + ui.add_space(4.0); + let cur = opts.vertical_justification; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (vj, icon, tip) in [ + (designcraft_doc::VerticalJustification::Top, "vjust-top", "Top"), + (designcraft_doc::VerticalJustification::Center, "vjust-center", "Center"), + (designcraft_doc::VerticalJustification::Bottom, "vjust-bottom", "Bottom"), + (designcraft_doc::VerticalJustification::Justify, "vjust-justify", "Justify"), + ] { + if icon_toggle(ui, icon, cur == vj, crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run("object.textFrameOptions", json!({"verticalJustification": vj})); + } + } + }); +} + +/// Inset Spacing under the columns: Top and Bottom, the link that makes all four the same, then +/// Left and Right, as the wrap offsets are laid out. A non-rectangular frame shows one Inset, the +/// distance its text keeps from the outline. +fn text_frame_insets(app: &mut DesignApp, ui: &mut Ui, fid: designcraft_doc::ItemId, opts: &designcraft_doc::TextFrameOptions, shaped: bool) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + let u = units(app); + let inset = opts.inset; + ui.add_space(4.0); + if shaped { + let b = block(ui, FIELD_H); + let ir = sub(b, 0.0, 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, "margin-top", t.icon); + ui.interact(ir, ui.scope_id().with("prop_tf_inset"), Sense::hover()).on_hover_ui(|ui| { + crate::rtl::label(ui, tr("Inset")); + }); + if let Some(v) = place(ui, sub(b, 20.0, 0.0, 76.0, FIELD_H), |ui| { + NumField::measure("tfInset", Some(inset[0]), u).width(76.0).spinner().range(0.0, 15552.0).show(ui) + }) { + // The composer also bounds the text by the frame's rectangle less all four insets, so + // when every frame it changes is shaped the one Inset sets all four. With a + // rectangular frame among them (part of the same selected text), only the top inset + // changes and each frame keeps its other sides. + let all_shaped = app.session.active().is_some_and(|st| { + designcraft_engine::cmd::text_frame_options_frames(&app.session) + .iter() + .filter_map(|id| st.doc.item(*id)) + .filter(|it| it.text_frame().is_some_and(|tf| tf.options.path.is_none())) + .all(is_shaped) + }); + let inset = if all_shaped { json!(v) } else { json!([v, null, null, null]) }; + let _ = app.run("object.textFrameOptions", json!({"inset": inset})); + } + return; + } + let b = block(ui, 2.0 * FIELD_H + 5.0); + // The link's state belongs to the frames the field changes in this document state: other + // frames, or frames with the same ids in another document or after Revert, start linked when + // every one of them has four equal insets, so a frame with uneven sides keeps them when one + // side changes. + let doc_epoch = app.session.active().map_or(0, |st| st.identity_epoch); + let targets = designcraft_engine::cmd::text_frame_options_frames(&app.session); + let target_ids: Vec = targets.iter().map(|id| id.0).collect(); + let link_id = egui::Id::unique(("prop_tf_inset_linked", doc_epoch, fid.0, target_ids)); + let even = |i: &[f64; 4]| i.iter().all(|v| (v - i[0]).abs() < 1e-9); + let same = even(&inset) + && app.session.active().is_none_or(|st| targets.iter().filter_map(|id| st.doc.item(*id)?.text_frame()).all(|tf| even(&tf.options.inset))); + let linked = ui.data(|d| d.get_temp::(link_id)).unwrap_or(same); + let mut set = None; + // Insets are [top, left, bottom, right]. + for (k, key, icon, tip, x, y) in [ + (0, "tfInsetTop", "margin-top", "Top", 0.0, 0.0), + (2, "tfInsetBottom", "margin-bottom", "Bottom", 0.0, FIELD_H + 5.0), + (1, "tfInsetLeft", "margin-inside", "Left", 140.0, 0.0), + (3, "tfInsetRight", "margin-outside", "Right", 140.0, FIELD_H + 5.0), + ] { + let ir = sub(b, x, y + 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, icon, t.icon); + ui.interact(ir, ui.scope_id().with(("prop_tf_inset", k)), Sense::hover()).on_hover_ui(|ui| { + crate::rtl::label(ui, tr(tip)); + }); + let nv = place(ui, sub(b, x + 20.0, y, 76.0, FIELD_H), |ui| { + NumField::measure(key, Some(inset[k]), u).width(76.0).spinner().range(0.0, 15552.0).show(ui) + }); + if let Some(v) = nv { + // Linked, all four; else this side alone. + let mut o = [None; 4]; + if linked { + o = [Some(v); 4]; + } else { + o[k] = Some(v); + } + set = Some(o); + } + } + if place(ui, sub(b, 106.0, (FIELD_H + 5.0) / 2.0, 22.0, FIELD_H), |ui| { + widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, &tr("Make all settings the same"), vec2(22.0, 21.0)) + .clicked() + }) { + ui.data_mut(|d| d.insert_temp(link_id, !linked)); + } + if let Some(o) = set { + let _ = app.run("object.textFrameOptions", json!({"inset": o})); + } } /// Languages offered for text (InDesign-style names). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 608b587f8..52bd45ddc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -353,6 +353,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - 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. +- Properties › Text Frame (DesignCraft's own layout): columns and gutter; Inset Spacing with Top, Bottom, Left and Right and a link that makes all four the same (one Inset for a non-rectangular frame); four Vertical Justification buttons (Top, Center, Bottom, Justify); neither insets nor vertical justification for type on a path; Options opens Text Frame Options. With the Type tool in a frame the section shows too and changes the frame that shows the insertion point, or every frame that holds part of the selected text (`object.textFrameOptions {inset, verticalJustification}`). - 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: }`, 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).