From 61ea089149f21058bf79b293304792df5d3155f1 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 14:42:26 +0200 Subject: [PATCH] feat(ui): Text Frame Options laid out as InDesign's, with its General options InDesign's Text Frame Options has a section list (General, Baseline Options, Column Rules, ...). General holds Columns (Fixed Number / Fixed Width with Number, Width, Gutter), Balance Columns, Inset Spacing as a 2x2 grid with a link button, Vertical Justification and Ignore Text Wrap; Baseline Options holds First Baseline Offset and Min. DesignCraft showed one flat list without the columns type, width, balance, link, ignore-wrap or baseline settings, although the model keeps them. - dialogs: a section list (General, Baseline Options, Column Rules); General as above (Width shows what Fixed Number columns get, editable for Fixed Width; a side typed while linked sets all four insets); Baseline Options; Column Rules as before. Enum captions translated. - OK sends the new fields only when edited, as before. - engine: `object.textFrameOptions` takes `columnWidth` (Fixed Width) and `firstBaselineMin`, which compose already honours. LYH Studio episode 14 sets two columns (9:45) and a 0.1875 in inset (10:57) here. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 8 +- crates/ui-egui/src/dialogs.rs | 195 +++++++++++++++++++++++++++---- crates/ui-egui/src/i18n.rs | 14 +++ crates/ui-egui/src/i18n/ar.rs | 14 +++ crates/ui-egui/src/i18n/it.rs | 14 +++ crates/ui-egui/src/i18n/ja.rs | 12 ++ crates/ui-egui/src/i18n/pt_br.rs | 14 +++ crates/ui-egui/src/i18n/uk.rs | 16 +++ 9 files changed, 265 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..14fce084d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Text Frame Options is laid out as InDesign's: General (Columns: Fixed Number or Fixed Width with Number, Width and Gutter; Balance Columns; Inset Spacing with the link button; Vertical Justification; Ignore Text Wrap), Baseline Options (First Baseline Offset and Min) and Column Rules. `object.textFrameOptions` takes `columnWidth` and `firstBaselineMin`. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index aa5a2cc01..52708d040 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -532,7 +532,7 @@ pub fn specs() -> Vec { "Text Frame Options…", ["Object"], Some("Cmd+B"), - "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", + "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth|flexibleWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|fixed, firstBaselineMin? (pt), autoSize?, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", has_selection, text_frame_options ), @@ -1654,6 +1654,12 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("ignoreWrap").and_then(Value::as_bool) { o.ignore_wrap = v; } + if let Some(v) = p.get("columnWidth").and_then(Value::as_f64) { + o.column_width = v.max(0.0); + } + if let Some(v) = p.get("firstBaselineMin").and_then(Value::as_f64) { + o.first_baseline_min = v.max(0.0); + } if let Some(v) = p.get("balanceColumns").and_then(Value::as_bool) { o.balance_columns = v; } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 695a66af1..9397a1119 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -410,6 +410,13 @@ fn pdf_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } /// The options of the selected text frame (the first selected item, or the frame of the caret). +/// The selected text frame's width (its own, before rotation). +fn selected_frame_width(app: &DesignApp) -> Option { + 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()) +} + fn selected_frame_options(app: &DesignApp) -> Option { let st = app.session.active()?; let fid = st.selection.items.first().copied().or_else(|| st.selection.text.and_then(|t| t.frame))?; @@ -426,8 +433,19 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { let o = selected_frame_options(app).unwrap_or_default(); let units = app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas); let fm = |v: f64| json!(format_measure(v, units)); + // Fixed Number: the width each column gets in the frame (shown, as in InDesign). + let inner = selected_frame_width(app).map(|w| w - o.inset[1] - o.inset[3]).unwrap_or(0.0); + let n = o.columns.max(1) as f64; + let width = match o.columns_kind { + designcraft_doc::ColumnsKind::FixedWidth if o.column_width > 0.0 => o.column_width, + _ => ((inner - o.gutter * (n - 1.0)) / n).max(0.0), + }; + let kind = |v: Value| v.as_str().unwrap_or_default().to_string(); let 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, + "firstBaseline": kind(serde_json::to_value(o.first_baseline).unwrap_or_default()), "firstBaselineMin": fm(o.first_baseline_min), "insetTop": fm(o.inset[0]), "insetLeft": fm(o.inset[1]), "insetBottom": fm(o.inset[2]), "insetRight": fm(o.inset[3]), "verticalJustification": serde_json::to_value(o.vertical_justification).unwrap_or(Value::Null), "columnRule": o.column_rule, "columnRuleWeight": format_measure(o.column_rule_weight, Unit::Points), "columnRuleColor": o.column_rule_color, @@ -439,6 +457,7 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { d.fields.entry(k.clone()).or_insert_with(|| v.clone()); } } + d.fields.entry("insetLinked".to_string()).or_insert_with(|| json!(o.inset.iter().all(|v| (v - o.inset[0]).abs() < 1e-9))); d.fields.insert("_shown".into(), shown); } @@ -451,38 +470,113 @@ fn percent(t: f32) -> String { fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let lang = app.ui.language.clone(); let tr = |s: &'static str| crate::i18n::tr(&lang, s); - egui::Grid::new("tfo").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, tr("Number of columns")); - text_field(ui, d, "columns", 80.0); + if d.s("section").is_empty() { + d.fields.insert("section".into(), json!("general")); + } + 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); + section_list(&lang, ui, d, &[("general", "General"), ("baseline", "Baseline Options"), ("rules", "Column Rules")]); + ui.separator(); + ui.vertical(|ui| match d.s("section").as_str() { + "baseline" => { + crate::rtl::label(ui, egui::RichText::new(tr("First Baseline")).font(semibold(12.0))); + egui::Grid::new("tfo_fb").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Offset:")); + translated_combo( + &lang, + ui, + d, + "firstBaseline", + &[("ascent", "Ascent"), ("capHeight", "Cap Height"), ("leading", "Leading"), ("xHeight", "x Height"), ("fixed", "Fixed")], + ); + crate::rtl::label(ui, tr("Min:")); + text_field(ui, d, "firstBaselineMin", 70.0); + ui.end_row(); + }); + } + "rules" => column_rules(app, ui, d), + _ => general(&lang, ui, d), + }); + }); +} + +/// Text Frame Options › General: columns, inset spacing, vertical justification, text wrap. +fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { + let tr = |s: &'static str| crate::i18n::tr(lang, s); + crate::rtl::label(ui, egui::RichText::new(tr("Columns")).font(semibold(12.0))); + egui::Grid::new("tfo_cols").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Columns:")); + translated_combo(lang, ui, d, "columnsKind", &[("fixedNumber", "Fixed Number"), ("fixedWidth", "Fixed Width")]); ui.end_row(); - crate::rtl::label(ui, tr("Gutter")); - text_field(ui, d, "gutter", 80.0); + crate::rtl::label(ui, tr("Number:")); + text_field(ui, d, "columns", 70.0); + // Fixed Number: the width follows from the frame; Fixed Width: it's set here. + crate::rtl::label(ui, tr("Width:")); + ui.add_enabled_ui(d.s("columnsKind") == "fixedWidth", |ui| text_field(ui, d, "columnWidth", 70.0)); + ui.end_row(); + crate::rtl::label(ui, tr("Gutter:")); + text_field(ui, d, "gutter", 70.0); ui.end_row(); }); + check(ui, d, "balanceColumns", tr("Balance Columns")); ui.add_space(6.0); - crate::rtl::label(ui, egui::RichText::new(tr("Inset spacing")).font(semibold(12.0))); - egui::Grid::new("tfo_inset").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (label, key) in [("Top", "insetTop"), ("Bottom", "insetBottom"), ("Left", "insetLeft"), ("Right", "insetRight")] { - crate::rtl::label(ui, tr(label)); - text_field(ui, d, key, 80.0); - ui.end_row(); - } - }); - ui.add_space(6.0); - egui::Grid::new("tfo_vj").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, tr("Vertical justification")); - let cur = d.s("verticalJustification"); - egui::ComboBox::from_id_salt("vj").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, &cur))).show_ui(ui, |ui| { - for v in ["top", "center", "bottom", "justify"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&lang, v))).clicked() { - d.fields.insert("verticalJustification".into(), json!(v)); + crate::rtl::label(ui, egui::RichText::new(tr("Inset Spacing")).font(semibold(12.0))); + // 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(); + // Two rows, Top | Left and Bottom | Right, the link between the columns (fixed-width + // captions; the second column starts where the first row's does). + let mut col2_x: Option = None; + for (r, row) in [[("Top:", "insetTop"), ("Left:", "insetLeft")], [("Bottom:", "insetBottom"), ("Right:", "insetRight")]].into_iter().enumerate() { + ui.horizontal(|ui| { + for (k, (label, key)) in row.into_iter().enumerate() { + if k == 1 { + match col2_x { + Some(x) => ui.add_space((x - ui.cursor().min.x).max(0.0)), + None => col2_x = Some(ui.cursor().min.x), + } + } + let x0 = ui.cursor().min.x; + crate::rtl::label(ui, tr(label)); + ui.add_space(((if k == 0 { 56.0 } else { 44.0 }) - (ui.cursor().min.x - x0)).max(0.0)); + text_field(ui, d, key, 70.0); + if k == 0 && 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() + { + d.fields.insert("insetLinked".into(), json!(!linked)); + } } } }); + } + if 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(); + for side in sides { + d.fields.insert(side.into(), v.clone()); + } + } + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(tr("Vertical Justification")).font(semibold(12.0))); + egui::Grid::new("tfo_vj").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Align:")); + translated_combo(lang, ui, d, "verticalJustification", &[("top", "Top"), ("center", "Center"), ("bottom", "Bottom"), ("justify", "Justify")]); ui.end_row(); }); ui.add_space(6.0); - crate::rtl::label(ui, egui::RichText::new(tr("Column Rules")).font(semibold(12.0))); + check(ui, d, "ignoreWrap", tr("Ignore Text Wrap")); +} + +/// Text Frame Options › Column Rules. +fn column_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let tr = |s: &'static str| crate::i18n::tr(&app.ui.language, s); check(ui, d, "columnRule", tr("Insert Column Rule")); let swatches: Vec = app .session @@ -554,6 +648,23 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result