From 54652717c041cc03105a876dcfa5b94ff6e181ed Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 17:30:44 -0800 Subject: [PATCH 1/4] Text Frame Options: a non-rectangular frame shows one Inset field For an oval, a polygon or a frame with corner options, Inset Spacing dims Top, Left, Bottom, Right and the link and shows one Inset field. OK sends it as `object.textFrameOptions {inset: }`, which sets all four sides; compose keeps that inset from the frame's outline. Rectangular frames keep the four fields. --- crates/ui-egui/src/dialogs.rs | 57 ++++++++++++++--- crates/ui-egui/src/dialogs/tests.rs | 94 +++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/hu.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/sv.rs | 1 + crates/ui-egui/src/i18n/table.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + docs/target-app-parity.md | 1 + 16 files changed, 155 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a6400054d..158b9d07d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1005,6 +1005,23 @@ fn selected_frame_options(app: &DesignApp) -> Option bool { + let shaped = || { + let st = app.session.active()?; + let fid = st.selection.items.first().copied().or_else(|| st.selection.text.and_then(|t| t.frame))?; + let item = st.doc.item(fid)?; + if item.text_frame()?.options.path.is_some() { + return Some(false); + } + 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()) + }; + shaped().unwrap_or(false) +} + /// Object › Text Frame Options shows the selected frame's options. `_shown` keeps what it showed, /// so OK sends only the fields the user changed. Fields given when the dialog opened count as /// changed. @@ -1032,7 +1049,7 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { }; designcraft_doc::FrameBaselineGrid { start: g.start, increment: g.increment, relative_to } }); - let shown = json!({ + let mut shown = json!({ "columns": o.columns, "gutter": fm(o.gutter), "columnsKind": kind(serde_json::to_value(o.columns_kind).unwrap_or_default()), "columnWidth": fm(width), "balanceColumns": o.balance_columns, "ignoreWrap": o.ignore_wrap, @@ -1051,6 +1068,12 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { "customGrid": o.baseline_grid.is_some(), "gridStart": fm(grid.start), "gridIncrement": fm(grid.increment), "gridRelativeTo": grid.relative_to.name(), }); + // A non-rectangular frame shows one Inset (its top inset, the one kept from the outline). + let shaped = selected_frame_is_shaped(app); + if shaped && let Value::Object(m) = &mut shown { + m.insert("inset".into(), fm(o.inset[0])); + } + d.fields.insert("_shaped".into(), json!(shaped)); if let Value::Object(m) = &shown { for (k, v) in m { d.fields.entry(k.clone()).or_insert_with(|| v.clone()); @@ -1218,8 +1241,9 @@ fn text_frame_preview(app: &mut DesignApp, d: &mut Dialog) { } /// 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), Vertical -/// Justification (align, paragraph spacing limit) and Ignore Text Wrap. +/// 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 +/// Ignore Text Wrap. fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { let tr = |s: &'static str| crate::i18n::tr(lang, s); let (cap, field, cap2) = (64.0, 110.0, 70.0); @@ -1243,8 +1267,10 @@ fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { // Fixed Number: each column's width, from the frame, the insets and the gutter. let frame = d.fields.get("_frameWidth").and_then(Value::as_f64); let n = d.n("columns").unwrap_or(1.0).max(1.0); + // A non-rectangular frame keeps its sides until its one Inset is changed. + let (left, right) = if d.b("_shaped") && edited(d, "inset") { ("inset", "inset") } else { ("insetLeft", "insetRight") }; let width = frame.map(|w| { - let inner = w - d.m("insetLeft").unwrap_or(0.0) - d.m("insetRight").unwrap_or(0.0); + let inner = w - d.m(left).unwrap_or(0.0) - d.m(right).unwrap_or(0.0); ((inner - d.m("gutter").unwrap_or(0.0) * (n - 1.0)) / n).max(0.0) }); let _ = crate::widgets::NumField::measure("tfo_auto_width", width, d.unit).width(field).spinner().enabled(false).show(ui); @@ -1262,6 +1288,9 @@ fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { check(ui, d, "balanceColumns", tr("Balance Columns")); }); framed(ui, Some(tr("Inset Spacing")), |ui| { + // A non-rectangular frame: the four sides are dimmed and one Inset sets the distance kept + // from the outline. + let shaped = d.b("_shaped"); // While linked, a side typed in sets all four ("Make all settings the same"). let sides = ["insetTop", "insetLeft", "insetBottom", "insetRight"]; let before: Vec = sides.iter().map(|k| d.s(k)).collect(); @@ -1270,24 +1299,32 @@ fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { { ui.horizontal(|ui| { caption_at(ui, tr(l1), cap); - spin_measure(ui, d, k1, field, true); + spin_measure(ui, d, k1, field, !shaped); ui.add_space(8.0); if r == 0 { let linked = d.b("insetLinked"); let tip = tr("Make all settings the same"); - if crate::widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, egui::vec2(22.0, 21.0)) - .clicked() - { + let toggle = ui.add_enabled_ui(!shaped, |ui| { + crate::widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, egui::vec2(22.0, 21.0)) + }); + if toggle.inner.clicked() { d.fields.insert("insetLinked".into(), json!(!linked)); } } else { ui.add_space(22.0 + ui.spacing().item_spacing.x); } caption_at(ui, tr(l2), cap2 - 30.0); - spin_measure(ui, d, k2, field, true); + spin_measure(ui, d, k2, field, !shaped); }); } - if d.b("insetLinked") + if shaped { + ui.horizontal(|ui| { + caption_at(ui, tr("Inset:"), cap); + spin_measure(ui, d, "inset", field, true); + }); + } + if !shaped + && d.b("insetLinked") && let Some(k) = sides.iter().zip(&before).find(|(k, b)| d.s(k) != **b).map(|(k, _)| *k) { let v = d.fields.get(k).cloned().unwrap_or_default(); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 272165b08..a11bd2f3f 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -1436,6 +1436,100 @@ fn text_frame_options_set_a_custom_baseline_grid_relative_to() { assert_eq!(grid(&app), None); } +/// A non-rectangular frame (an oval, or a rectangle with rounded corners) shows one Inset, the +/// distance kept from its outline, and OK sends it as one value for all four sides. A rectangle +/// keeps the four side fields. +#[test] +fn text_frame_options_set_one_inset_for_a_non_rectangular_frame() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let oval = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "shape": "ellipse", "content": "text", "text": "Hello"})).unwrap(); + let oval = designcraft_doc::ItemId(oval["id"].as_u64().unwrap()); + let rect = app.run("frame.create", json!({"rect": [36, 300, 336, 500], "content": "text", "text": "Hello"})).unwrap(); + let rect = designcraft_doc::ItemId(rect["id"].as_u64().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 open = |app: &mut DesignApp, id: designcraft_doc::ItemId| { + app.run("selection.set", json!({"ids": [id.0]})).unwrap(); + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(app, &mut d); + d + }; + // The oval shows its top inset as the one Inset. + let d = open(&mut app, oval); + assert!(d.b("_shaped")); + assert_eq!(d.s("inset"), "0p6"); + // OK without edits changes nothing, and adds no undo step. + let undo = app.session.doc().unwrap().history.undo.len(); + app.ui.dialog = Some(d); + assert_eq!(confirm(&mut app).unwrap(), Value::Null); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo); + // Inset set to 1p0: all four sides take it. + let mut d = open(&mut app, oval); + d.fields.insert("inset".into(), json!("1p0")); + app.ui.dialog = Some(d); + confirm(&mut app).unwrap(); + assert_eq!(opts(&app, oval).inset, [12.0; 4]); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // A value that isn't a measure is refused and the frame keeps its inset. + let mut d = open(&mut app, oval); + d.fields.insert("inset".into(), json!("not a measure")); + app.ui.dialog = Some(d); + assert!(confirm(&mut app).unwrap_err().to_string().contains("the inset is not a measure")); + assert_eq!(opts(&app, oval).inset, [12.0; 4]); + // A rectangle shows the four sides; with rounded corners it is not rectangular either. + let d = open(&mut app, rect); + assert!(!d.b("_shaped") && !d.fields.contains_key("inset"), "a rectangle shows the four sides"); + app.run("object.cornerOptions", json!({"ids": [rect.0], "shape": "rounded", "size": 12})).unwrap(); + let d = open(&mut app, rect); + assert!(d.b("_shaped")); + assert_eq!(d.s("inset"), "0p6"); +} + +/// 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] +fn text_frame_options_dims_the_sides_for_a_non_rectangular_frame() { + use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, + }; + for shaped in [true, false] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let shape = if shaped { "ellipse" } else { "rectangle" }; + let r = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "shape": shape, "content": "text", "text": "Hello"})).unwrap(); + app.run("selection.set", json!({"ids": [r["id"]]})).unwrap(); + app.ui.dialog = Some(Dialog::new("textFrameOptions", json!({}))); + // The General tab's Columns and Align dropdowns have no accessible name yet. + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).with_accessibility_check(false).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("text_frame_inset_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + for side in ["insetTop", "insetLeft", "insetBottom", "insetRight"] { + let label = format!("Increase tfo_{side}"); + assert_eq!(h.get_by_label(&label).accesskit_node().is_disabled(), shaped, "{shape}: {side}"); + } + assert_eq!(h.query_by_label("Inset:").is_some(), shaped, "{shape}: the Inset caption"); + assert_eq!(h.query_by_label("Increase tfo_inset").is_some(), shaped, "{shape}: the Inset field"); + // The link toggles only for a rectangle. + let linked = |h: &Harness<'_, DesignApp>| h.state().ui.dialog.as_ref().unwrap().b("insetLinked"); + let before = linked(&h); + h.get_by_label("Make all settings the same").click(); + h.run_steps(3); + assert_eq!(linked(&h) != before, !shaped, "{shape}: the link"); + } +} + #[test] fn text_frame_options_change_only_what_was_edited() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index adf22f4fb..28dda77d0 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1858,6 +1858,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Keep Options", "خيارات الإبقاء"), ("Span Columns", "امتداد الأعمدة"), ("Top:", "أعلى:"), + ("Inset:", "الإزاحة الداخلية:"), ("Left:", "يسار:"), ("Bottom:", "أسفل:"), ("Right:", "يمين:"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 5310e8d12..32bf1803c 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -1913,6 +1913,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Third Space", "Třetina čtverčíku"), ("Title Case", "Velká Počáteční Písmena"), ("Top:", "Nahoře:"), + ("Inset:", "Odsazení:"), ("Trademark Symbol", "Symbol ochranné známky"), ("Transform Again Individually", "Transformovat znovu jednotlivě"), ("Transform Sequence Again", "Transformovat posloupnost znovu"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 2e9920126..15009e53a 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -1864,6 +1864,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Third Space", "Drittelgeviert"), ("Title Case", "Wortanfänge groß"), ("Top:", "Oben:"), + ("Inset:", "Versatz:"), ("Trademark Symbol", "Markensymbol"), ("Transform Again Individually", "Erneut einzeln transformieren"), ("Transform Sequence Again", "Abfolge erneut transformieren"), diff --git a/crates/ui-egui/src/i18n/hu.rs b/crates/ui-egui/src/i18n/hu.rs index ab30bce8d..8ca23c858 100644 --- a/crates/ui-egui/src/i18n/hu.rs +++ b/crates/ui-egui/src/i18n/hu.rs @@ -1727,6 +1727,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Top Margin", "Felső margó"), ("Top of Page", "Oldal teteje"), ("Top:", "Fent:"), + ("Inset:", "Belső margó:"), ("Track Changes", "Változások követése"), ("Track Changes in All Stories", "Változások követése minden szövegfolyamban"), ("Tracking", "Betűköz"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 131875d46..9f0cf29c0 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1918,6 +1918,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Keep Options", "Opzioni di unione"), ("Span Columns", "Estendi su colonne"), ("Top:", "Superiore:"), + ("Inset:", "Rientro:"), ("Left:", "Sinistra:"), ("Bottom:", "Inferiore:"), ("Right:", "Destra:"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index ae2dd653f..9c6a9772f 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -2057,6 +2057,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Third Space", "三分スペース"), ("Title Case", "各単語の先頭を大文字に"), ("Top:", "上:"), + ("Inset:", "フレーム内マージン:"), ("Trademark Symbol", "商標記号"), ("Transform Again Individually", "個別に再変形"), ("Transform Sequence Again", "一連の変形を再実行"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 1ba9a61a9..74b688a10 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1955,6 +1955,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Third Space", "მესამედი შორისი"), ("Title Case", "ყოველი სიტყვა მთავრულით"), ("Top:", "ზემოთ:"), + ("Inset:", "შეწევა:"), ("Trademark Symbol", "სავაჭრო ნიშნის სიმბოლო"), ("Transform Again Individually", "გარდაქმნის ცალ-ცალკე გამეორება"), ("Transform Sequence Again", "გარდაქმნების მიმდევრობის გამეორება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 7aac5d74b..b37f8de3e 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -1888,6 +1888,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Third Space", "Trzecia firetu"), ("Title Case", "Wielkie Litery Wyrazów"), ("Top:", "Góra:"), + ("Inset:", "Margines wewnętrzny:"), ("Trademark Symbol", "Symbol znaku towarowego"), ("Transform Again Individually", "Przekształć ponownie osobno"), ("Transform Sequence Again", "Powtórz sekwencję przekształceń"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 147947dbd..a5c47c386 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1468,6 +1468,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Keep Options", "Opções de manter"), ("Span Columns", "Abranger colunas"), ("Top:", "Superior:"), + ("Inset:", "Recuo:"), ("Left:", "Esquerda:"), ("Bottom:", "Inferior:"), ("Right:", "Direita:"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 178cc9bc3..3cfb7fff0 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -1898,6 +1898,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Third Space", "Треть шпации"), ("Title Case", "Каждое слово с прописной"), ("Top:", "Сверху:"), + ("Inset:", "Отступ:"), ("Trademark Symbol", "Знак товарного знака"), ("Transform Again Individually", "Повторить трансформирование по отдельности"), ("Transform Sequence Again", "Повторить последовательность трансформирования"), diff --git a/crates/ui-egui/src/i18n/sv.rs b/crates/ui-egui/src/i18n/sv.rs index 766ba32da..f3def92b5 100644 --- a/crates/ui-egui/src/i18n/sv.rs +++ b/crates/ui-egui/src/i18n/sv.rs @@ -65,6 +65,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Gutter:", "Spaltmellanrum:"), ("Fit Guides to:", "Anpassa stödlinjer till:"), ("Top:", "Överkant:"), + ("Inset:", "Infällning:"), ("Bottom:", "Nederkant:"), ("Inside:", "Inre:"), ("Outside:", "Yttre:"), diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index ebde2b629..0aea8e244 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -479,6 +479,7 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Gutter:", ["Steg:", "Gouttière :", "Medianil:", "間隔:", "栏间距:"]), ("Fit Guides to:", ["Hilfslinien einpassen in:", "Ajuster les repères à :", "Ajustar guías a:", "ガイドの適用先:", "参考线适合:"]), ("Top:", ["Oben:", "Haut :", "Superior:", "天:", "上:"]), + ("Inset:", ["Versatz:", "Marge intérieure :", "Margen interior:", "フレーム内マージン :", "内边距:"]), ("Bottom:", ["Unten:", "Bas :", "Inferior:", "地:", "下:"]), ("Inside:", ["Innen:", "Intérieur :", "Interior:", "ノド:", "内:"]), ("Outside:", ["Außen:", "Extérieur :", "Exterior:", "小口:", "外:"]), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 4b2853eae..16a983abe 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1885,6 +1885,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Keep Options", "Параметри утримання"), ("Span Columns", "Охоплення колонок"), ("Top:", "Зверху:"), + ("Inset:", "Відступ:"), ("Left:", "Ліворуч:"), ("Bottom:", "Знизу:"), ("Right:", "Праворуч:"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 193d3ae9a..4207507dc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -352,6 +352,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 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): 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). - Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. - Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame. Clipping paths saved in IDML (Detect Edges, Alpha Channel, Photoshop Path, User-Modified Path; Invert) hide the image outside the path on screen and in PDF and survive IDML export; `object.clippingPathOptions` reports, inverts or removes one (Object ▸ Clipping Path ▸ None). From c93e93414c1156589137b394172abb9da3882b2b Mon Sep 17 00:00:00 2001 From: "autocraft-bot[bot]" <354215646+autocraft-bot[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:02:22 +0000 Subject: [PATCH 2/4] Apply review fixes - crates/ui-egui/src/dialogs.rs - docs/target-app-parity.md --- crates/ui-egui/src/dialogs.rs | 15 ++++++++++----- docs/target-app-parity.md | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 158b9d07d..6335f8fca 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1005,13 +1005,14 @@ fn selected_frame_options(app: &DesignApp) -> Option bool { - let shaped = || { - let st = app.session.active()?; - let fid = st.selection.items.first().copied().or_else(|| st.selection.text.and_then(|t| t.frame))?; + let Some(st) = app.session.active() else { return false }; + let shaped = |fid: designcraft_doc::ItemId| -> Option { let item = st.doc.item(fid)?; if item.text_frame()?.options.path.is_some() { return Some(false); @@ -1019,7 +1020,11 @@ 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()) }; - shaped().unwrap_or(false) + 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)) } /// Object › Text Frame Options shows the selected frame's options. `_shown` keeps what it showed, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4207507dc..f950eb12c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -352,7 +352,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 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): 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). +- 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). - Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. - Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame. Clipping paths saved in IDML (Detect Edges, Alpha Channel, Photoshop Path, User-Modified Path; Invert) hide the image outside the path on screen and in PDF and survive IDML export; `object.clippingPathOptions` reports, inverts or removes one (Object ▸ Clipping Path ▸ None). From 293fe55a4d9c1841b8dea9cae4b3162b5d1280e3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 18:25:01 -0800 Subject: [PATCH 3/4] Text Frame Options: test the four sides for a mixed selection An oval selected first with a rectangle shows the four side fields, and a changed top inset leaves the rectangle's other sides as they were. --- crates/ui-egui/src/dialogs/tests.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index a11bd2f3f..8f1681611 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -1486,6 +1486,34 @@ fn text_frame_options_set_one_inset_for_a_non_rectangular_frame() { assert_eq!(d.s("inset"), "0p6"); } +/// A selection that mixes a non-rectangular frame with a rectangle shows the four sides, even with +/// the oval first, and a changed side leaves the rectangle's other sides as they were. +#[test] +fn text_frame_options_show_four_sides_for_a_mixed_selection() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let oval = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "shape": "ellipse", "content": "text", "text": "Hello"})).unwrap(); + let oval = designcraft_doc::ItemId(oval["id"].as_u64().unwrap()); + let rect = app.run("frame.create", json!({"rect": [36, 300, 336, 500], "content": "text", "text": "Hello"})).unwrap(); + let rect = designcraft_doc::ItemId(rect["id"].as_u64().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(); + app.run("selection.set", json!({"ids": [oval.0, rect.0]})).unwrap(); + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(&app, &mut d); + assert!(!d.b("_shaped") && !d.fields.contains_key("inset"), "a mixed selection 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]); + // Two non-rectangular frames still show the one Inset. + app.run("object.cornerOptions", json!({"ids": [rect.0], "shape": "rounded", "size": 12})).unwrap(); + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(&app, &mut d); + assert!(d.b("_shaped")); +} + /// 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] From 0db4ba3dd426834935aa8a9c300aa346b61c58ad Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 18:39:10 -0800 Subject: [PATCH 4/4] Text Frame Options: the dialog is one row taller for a non-rectangular frame The Inset row made the General tab taller than the dialog's fixed height, so the buttons moved when switching tabs. The fixed height now includes that row. --- crates/ui-egui/src/dialogs.rs | 8 +++++--- crates/ui-egui/src/dialogs/tests.rs | 8 ++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6335f8fca..702c6bcc2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1106,9 +1106,11 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) } ui.set_min_width(560.0); ui.horizontal_top(|ui| { - // A fixed height: the separator would otherwise stretch the dialog to the window. - ui.set_min_height(330.0); - ui.set_max_height(330.0); + // A fixed height: the separator would otherwise stretch the dialog to the window. A + // non-rectangular frame's Inset row needs one more row. + let height = if d.b("_shaped") { 360.0 } else { 330.0 }; + ui.set_min_height(height); + ui.set_max_height(height); section_list( &lang, ui, diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 8f1681611..38ffc28cb 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -1549,6 +1549,14 @@ fn text_frame_options_dims_the_sides_for_a_non_rectangular_frame() { } assert_eq!(h.query_by_label("Inset:").is_some(), shaped, "{shape}: the Inset caption"); assert_eq!(h.query_by_label("Increase tfo_inset").is_some(), shaped, "{shape}: the Inset field"); + // The General tab fits in the dialog's fixed height: the buttons stay put on another tab. + let (wrap, cancel) = (h.get_by_label("Ignore Text Wrap").rect().bottom(), h.get_by_label("Cancel").rect().top()); + assert!(wrap < cancel, "{shape}: Ignore Text Wrap ends at {wrap}, below the top of Cancel at {cancel}"); + h.get_by_label("Auto-Size").click(); + h.run_steps(3); + assert_eq!(h.get_by_label("Cancel").rect().top(), cancel, "{shape}: the buttons stay put on another tab"); + h.get_by_label("General").click(); + h.run_steps(3); // The link toggles only for a rectangle. let linked = |h: &Harness<'_, DesignApp>| h.state().ui.dialog.as_ref().unwrap().b("insetLinked"); let before = linked(&h);