From 151f54ee46648f48829230d3341571435b46738f Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:48:16 +0200 Subject: [PATCH 1/8] Paragraph styles: a new style from the selected text, Next Style, Reset To Base New Paragraph Style with text selected starts from it, as InDesign's: Based On [No Paragraph Style] and every setting of the text that differs from it, with Apply Style to Selection (on), so OK gives the paragraph the new style. General has Next Style ([Same style] or a style) and Reset To Base (the style keeps nothing of its own; edits made in the dialog still apply). Basic Character Formats is laid out in two columns as InDesign's: the font, then Size | Leading, Kerning | Tracking, Case | Position. style.paragraph.edit honours nextStyle (string, or null for [Same style]); it was described but ignored. It takes resetToBase. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/style.rs | 20 ++++- crates/ui-egui/src/dialogs.rs | 140 +++++++++++++++++++++++++++++-- crates/ui-egui/src/i18n.rs | 13 +++ crates/ui-egui/src/i18n/ar.rs | 4 + crates/ui-egui/src/i18n/cs.rs | 4 + crates/ui-egui/src/i18n/it.rs | 4 + crates/ui-egui/src/i18n/ka.rs | 4 + crates/ui-egui/src/i18n/pl.rs | 4 + crates/ui-egui/src/i18n/pt_br.rs | 4 + crates/ui-egui/src/i18n/ru.rs | 4 + crates/ui-egui/src/i18n/uk.rs | 4 + 12 files changed, 196 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..41b3ab40d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · New Paragraph Style with text selected starts from it, as InDesign's (Based On [No Paragraph Style], every setting of the text that differs) and Apply Style to Selection gives the paragraph the new style; General has Next Style and Reset To Base; Basic Character Formats is in two columns (Size | Leading, Kerning | Tracking, Case | Position). `style.paragraph.edit` honours `nextStyle` (it was ignored) and takes `resetToBase`. Found in the Learn Skills Daily course, chapter 20. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d1bd06ad0..4526db5b0 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -311,7 +311,7 @@ pub fn specs() -> Vec { "Paragraph Style Options…", [], None, - "{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", + "{name, rename?, basedOn?, nextStyle?: style | null ([Same style]), resetToBase?: bool (Reset To Base: the style sets nothing of its own; `para`/`chars` then apply over its Based On), para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", has_doc, edit_para ), @@ -936,12 +936,25 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; let rename = str_param(p, "rename").map(str::to_string); let based = p.get("basedOn").cloned(); + let next = p.get("nextStyle").cloned(); + let reset = p.get("resetToBase").and_then(Value::as_bool).unwrap_or(false); s.edit(|d, _| { if let Some(Value::String(b)) = &based && d.styles.para_based_on_cycles(&name, b) { return Err(bad("style.paragraph.edit", "based-on would create a cycle")); } + if let Some(Value::String(n)) = &next + && d.styles.para(n).is_none() + { + return Err(bad("style.paragraph.edit", format!("no style `{n}`"))); + } + // Reset To Base: what the style set of its own goes before the edits apply. + if reset { + let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; + st.para = ParaAttrs::default(); + st.chars = CharAttrs::default(); + } // A rule object changes only the rule fields it names, over the style's resolved rule. let (current, _) = d.styles.resolve_para_style(&name); let mut para: ParaAttrs = attrs("style.paragraph.edit", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; @@ -956,6 +969,11 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { Some(Value::Null) => st.based_on = None, _ => {} } + match next { + Some(Value::String(n)) => st.next_style = Some(n), + Some(Value::Null) => st.next_style = None, + _ => {} + } if let Some(n) = rename.clone() { // Every use: other styles, stories, table cells, footnotes, object styles. rename_style(d, true, &name, &n); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..dc6a5d7e7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3680,16 +3680,30 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul } let based = d.s("basedOn"); let rename = d.s("rename"); + let next = d.fields.get("nextStyle").cloned(); let (r, style) = if d.b("new") { let mut params = json!({"name": if rename.trim().is_empty() { "Paragraph Style 1" } else { rename.trim() }, "para": para, "chars": chars}); if !based.is_empty() && based != designcraft_doc::NO_PARA_STYLE { params["basedOn"] = json!(based); } + if let Some(Value::String(n)) = &next { + params["nextStyle"] = json!(n); + } let r = app.run("style.paragraph.create", params)?; let style = r["name"].as_str().unwrap_or_default().to_string(); + // Apply Style to Selection. + if d.b("apply") && app.session.active().is_some_and(|st| st.selection.text.is_some()) { + app.run("style.paragraph.apply", json!({"name": style}))?; + } (r, style) } else { let mut params = json!({"name": name, "para": para, "chars": chars}); + if let Some(n) = next { + params["nextStyle"] = n; + } + if d.b("resetToBase") { + params["resetToBase"] = json!(true); + } if !based.is_empty() { params["basedOn"] = if based == designcraft_doc::NO_PARA_STYLE { Value::Null } else { json!(based) }; } @@ -3804,9 +3818,16 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo if new && !d.fields.contains_key("rename") { d.fields.insert("rename".into(), json!(next_style_name(&doc.styles, true))); } + let mut style = style; + let reset = !new && d.b("resetToBase"); + if reset { + style.para = Default::default(); + style.chars = Default::default(); + } let names: Vec = doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| new || *n != name).collect(); - // A new style shows what it would inherit from its Based On. - let (pp, cp) = doc.styles.resolve_para_style(if new { d.fields.get("basedOn").and_then(Value::as_str).unwrap_or("") } else { &name }); + // A new style shows what it would inherit from its Based On (as does one reset to it). + let based_name = d.fields.get("basedOn").and_then(Value::as_str).map(str::to_string).or(style.based_on.clone()).unwrap_or_default(); + let (pp, cp) = doc.styles.resolve_para_style(if new || reset { &based_name } else { &name }); let units = doc.settings.horizontal_units; let pv = serde_json::to_value(&pp).unwrap_or_default(); let cv = serde_json::to_value(&cp).unwrap_or_default(); @@ -4239,13 +4260,51 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo } }); ui.end_row(); + // Next Style: the style of the paragraph that follows when Enter ends this one. + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Next Style:")); + let next = match d.fields.get("nextStyle") { + Some(Value::String(n)) => Some(n.clone()), + Some(_) => None, + None => style.next_style.clone().filter(|n| *n != name), + }; + let same = crate::i18n::tr(&app.ui.language, "[Same style]").to_string(); + let shown = next.as_deref().map_or(same.clone(), |n| crate::i18n::style_name(&app.ui.language, n).to_string()); + egui::ComboBox::from_id_salt("psnext").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { + if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { + d.fields.insert("nextStyle".into(), Value::Null); + } + for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) { + if ui + .selectable_label(next.as_deref() == Some(n), crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))) + .clicked() + { + d.fields.insert("nextStyle".into(), json!(n)); + } + } + }); + ui.end_row(); }); + // Reset To Base: the style keeps nothing of its own (the edits made here stay). + if !new + && ui + .add_enabled( + !based_name.is_empty() && based_name != designcraft_doc::NO_PARA_STYLE, + egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset To Base"))), + ) + .clicked() + { + d.fields.insert("resetToBase".into(), json!(true)); + } ui.add_space(8.0); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Settings:")).font(semibold(12.0))); let summary = paragraph_style_summary(&app.ui.language, d, &style, &doc.styles); - egui::ScrollArea::vertical().id_salt("paragraph_style_settings").max_height(220.0).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt("paragraph_style_settings").max_height(180.0).show(ui, |ui| { ui.add(egui::Label::new(egui::RichText::new(summary).color(crate::theme::Tokens::get(ui.ctx()).text_dim)).wrap()); }); + if new { + ui.add_space(8.0); + check(ui, d, "apply", crate::i18n::tr(&app.ui.language, "Apply Style to Selection")); + } } }); }); @@ -5155,8 +5214,28 @@ pub fn open_style_options(app: &mut DesignApp, para: bool, name: &str) { pub fn open_new_style(app: &mut DesignApp, para: bool) { let Some(st) = app.session.active() else { return }; let name = next_style_name(&st.doc.styles, para); + let has_text = st.selection.text.is_some(); + let (bp, bc) = st.doc.styles.resolve_para_style(designcraft_doc::NO_PARA_STYLE); let id = if para { "paragraphStyleOptions" } else { "characterStyleOptions" }; - app.ui.dialog = Some(Dialog::new(id, json!({"new": true, "rename": name}))); + let mut fields = json!({"new": true, "rename": name}); + // New Paragraph Style with text selected starts from it, as InDesign's: Based On [No Paragraph + // Style] and every setting of the text that differs, applied to the selection on OK. + if para + && has_text + && let Some(sel) = app.session.execute("type.selectionAttrs", &json!({})).ok().filter(|v| !v.is_null()) + { + for (prefix, now, base) in + [("p.", &sel["para"], serde_json::to_value(bp).unwrap_or_default()), ("c.", &sel["chars"], serde_json::to_value(bc).unwrap_or_default())] + { + for (k, v) in now.as_object().into_iter().flatten() { + if !v.is_null() && base.get(k) != Some(v) && base.get(k).is_some() { + fields[format!("{prefix}{k}")] = v.clone(); + } + } + } + fields["apply"] = json!(true); + } + app.ui.dialog = Some(Dialog::new(id, fields)); } /// Character Style Options: General, then the character sections it shares with Paragraph Style @@ -5321,7 +5400,7 @@ fn char_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, su fn char_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, opts: &[(Value, &str)]) { let cur = cf.get(d, key); let shown = opts.iter().find(|o| o.0 == cur).map_or("", |o| crate::i18n::tr(lang, o.1)); - egui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { + egui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(140.0).show_ui(ui, |ui| { if cf.sparse && ui.selectable_label(cur.is_null(), " ").clicked() { cf.set(d, key, Value::Null); } @@ -5364,6 +5443,7 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo let lang = lang.as_str(); let fonts = crate::panels::fonts(app); let menu = crate::panels::font_menu(app); + // Two columns, as InDesign's: the font, then Size | Leading, Kerning | Tracking, Case | Position. egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Font Family:")); let fam = cf.get(d, "fontFamily").as_str().unwrap_or("").to_string(); @@ -5406,9 +5486,10 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }); } ui.end_row(); + }); + egui::Grid::new("psc2").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Size:")); char_number(ui, d, cf, "size", " pt", 1.0, (0.1, 1296.0)); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Leading:")); let lv = match cf.get(d, "leading") { v if v["kind"] == "points" => v["value"].as_f64(), @@ -5429,7 +5510,6 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo "kerning", &[(json!({"kind": "metrics"}), "Metrics"), (json!({"kind": "optical"}), "Optical"), (json!({"kind": "none"}), "0")], ); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Tracking:")); char_number(ui, d, cf, "tracking", "", 1.0, (-1000.0, 10000.0)); ui.end_row(); @@ -5447,7 +5527,6 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo (json!("openTypeAllSmallCaps"), "OpenType All Small Caps"), ], ); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Position:")); char_choice( lang, @@ -7493,6 +7572,48 @@ mod tests { assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["ruleBelow"], rule); } + /// The course's chapter 20: the "Recipe Categories" title formatted on the page (size, All + /// Caps, tracking, space after), then New Paragraph Style from it: the dialog starts from the + /// text, and with Apply Style to Selection the paragraph takes the new style. Next Style and + /// Reset To Base are set from General. + #[test] + fn a_new_paragraph_style_starts_from_the_selected_text() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Egg Recipes\rBoil them."})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 11})).unwrap(); + app.run("type.char", json!({"attrs": {"size": 20, "capitalization": "allCaps", "tracking": 50}})).unwrap(); + app.run("type.para", json!({"attrs": {"spaceAfter": 6}})).unwrap(); + open_new_style(&mut app, true); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!( + (d.fields["c.size"].clone(), d.fields["c.capitalization"].clone(), d.fields["p.spaceAfter"].clone()), + (json!(20.0), json!("allCaps"), json!(6.0)) + ); + assert!(d.b("apply") && !d.fields.contains_key("c.fontFamily"), "only what differs from [No Paragraph Style]"); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("rename".into(), json!("Recipe Categories")); + d.fields.insert("nextStyle".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + } + confirm(&mut app).unwrap(); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!(a["paragraphStyle"], "Recipe Categories", "applied to the selection"); + assert_eq!(a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0), 0); + let style = |app: &DesignApp| app.session.doc().unwrap().doc.styles.para("Recipe Categories").cloned().unwrap(); + assert_eq!(style(&app).next_style.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(style(&app).chars.size, Some(20.0)); + // Reset To Base: the style keeps nothing of its own, apart from the edits made then. + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Recipe Categories", "basedOn": designcraft_doc::BASIC_PARAGRAPH}))); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("resetToBase".into(), json!(true)); + d.fields.insert("c.tracking".into(), json!(10.0)); + } + confirm(&mut app).unwrap(); + let s = style(&app); + assert_eq!((s.chars.size, s.chars.tracking, s.para.space_after), (None, Some(10.0), None), "{s:?}"); + assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); + } + #[test] fn paragraph_style_options_edit_one_rule_field() { let (mut app, rule) = ruled_paragraph(); @@ -9660,7 +9781,8 @@ mod tests { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); let st = app.session.active().unwrap().doc.styles.para("Paragraph Style 1").cloned().unwrap(); assert_eq!(st.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - assert_eq!(st.chars, CharAttrs { size: Some(20.0), ..Default::default() }); + // The selected text's own Italic comes along, as InDesign's New Paragraph Style takes it. + assert_eq!(st.chars, CharAttrs { size: Some(20.0), font_style: Some("Italic".into()), ..Default::default() }); } #[test] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..caaede51c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -36,6 +36,19 @@ pub const LANGUAGES: &[(&str, &str)] = &[ /// English → [German, French, Spanish, Japanese, Simplified Chinese]. const TABLE: &[(&str, [&str; 5])] = &[ + ("Next Style:", ["Nächstes Format:", "Style suivant :", "Estilo siguiente:", "次のスタイル :", "下一样式:"]), + ("[Same style]", ["[Gleiches Format]", "[Même style]", "[Mismo estilo]", "[同じスタイル]", "[相同样式]"]), + ("Reset To Base", ["Auf Basis zurücksetzen", "Rétablir la base", "Restablecer a base", "基準に戻す", "重置为基准"]), + ( + "Apply Style to Selection", + [ + "Format auf Auswahl anwenden", + "Appliquer le style à la sélection", + "Aplicar estilo a la selección", + "選択範囲にスタイルを適用", + "将样式应用于选区", + ], + ), // Menus. ("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]), ("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..bc300eac8 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Next Style:", "النمط التالي:"), + ("[Same style]", "[النمط نفسه]"), + ("Reset To Base", "إعادة التعيين إلى الأساس"), + ("Apply Style to Selection", "تطبيق النمط على التحديد"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..aa4215745 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Next Style:", "Další styl:"), + ("[Same style]", "[Stejný styl]"), + ("Reset To Base", "Obnovit podle základu"), + ("Apply Style to Selection", "Použít styl na výběr"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..ab153084e 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Next Style:", "Stile successivo:"), + ("[Same style]", "[Stesso stile]"), + ("Reset To Base", "Ripristina base"), + ("Apply Style to Selection", "Applica stile alla selezione"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..128a872f6 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Next Style:", "შემდეგი სტილი:"), + ("[Same style]", "[იგივე სტილი]"), + ("Reset To Base", "საბაზოზე დაბრუნება"), + ("Apply Style to Selection", "სტილის გამოყენება მონიშნულზე"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..100168f3a 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Next Style:", "Następny styl:"), + ("[Same style]", "[Ten sam styl]"), + ("Reset To Base", "Przywróć bazowy"), + ("Apply Style to Selection", "Zastosuj styl do zaznaczenia"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..3f2b2afe5 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Next Style:", "Próximo estilo:"), + ("[Same style]", "[Mesmo estilo]"), + ("Reset To Base", "Redefinir para base"), + ("Apply Style to Selection", "Aplicar estilo à seleção"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..e567ec5a3 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Next Style:", "Следующий стиль:"), + ("[Same style]", "[Тот же стиль]"), + ("Reset To Base", "Сбросить до основы"), + ("Apply Style to Selection", "Применить стиль к выделенному"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..33ebb766d 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Next Style:", "Наступний стиль:"), + ("[Same style]", "[Той самий стиль]"), + ("Reset To Base", "Скинути до основи"), + ("Apply Style to Selection", "Застосувати стиль до виділеного"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), From f972cebb960351db4bb23fef77ae875e1caccf10 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:47:05 +0200 Subject: [PATCH 2/8] Paragraph style edits: Reset To Base with a new Based On resolves over it, is checked against it and previewed Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/style.rs | 14 ++++++---- crates/engine/src/tests.rs | 15 ++++++++++ crates/ui-egui/src/dialogs.rs | 50 ++++++++++++++++++++++++++++++++-- 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 41b3ab40d..9a8a08c3b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,7 +11,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. -## Status (2026-10-01) +## Status (2026-10-21) **Working today** - Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4526db5b0..903cd7d0f 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -955,6 +955,15 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { st.para = ParaAttrs::default(); st.chars = CharAttrs::default(); } + // A new Based On first, so the fields below resolve over the new parent. + if based.is_some() { + let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; + match &based { + Some(Value::String(b)) => st.based_on = Some(b.clone()), + Some(Value::Null) => st.based_on = None, + _ => {} + } + } // A rule object changes only the rule fields it names, over the style's resolved rule. let (current, _) = d.styles.resolve_para_style(&name); let mut para: ParaAttrs = attrs("style.paragraph.edit", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; @@ -964,11 +973,6 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; st.para.merge(¶); st.chars.merge(&chars); - match based { - Some(Value::String(b)) => st.based_on = Some(b), - Some(Value::Null) => st.based_on = None, - _ => {} - } match next { Some(Value::String(n)) => st.next_style = Some(n), Some(Value::Null) => st.next_style = None, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aacf469d..4bc7e360c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1650,3 +1650,18 @@ fn a_line_fits_when_its_baseline_does() { assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame"); assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line"); } + +/// Reset To Base with a new Based On: a partial rule edit resolves over the new parent, not the +/// old one. +#[test] +fn reset_to_base_with_a_new_parent_resolves_rule_edits_over_it() { + let mut s = session(); + let rule = |weight: f64, offset: f64| json!({"on": true, "weight": weight, "color": "[Black]", "offset": offset}); + s.execute("style.paragraph.create", &json!({"name": "Old", "para": {"ruleBelow": rule(3.0, 1.0)}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "New", "para": {"ruleBelow": rule(1.0, 9.0)}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Child", "basedOn": "Old"})).unwrap(); + s.execute("style.paragraph.edit", &json!({"name": "Child", "resetToBase": true, "basedOn": "New", "para": {"ruleBelow": {"weight": 2}}})) + .unwrap(); + let r = s.doc().unwrap().doc.styles.resolve_para_style("Child").0.rule_below; + assert_eq!((r.weight, r.offset), (2.0, 9.0), "the offset is the new parent's"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dc6a5d7e7..d5341722e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3646,9 +3646,25 @@ fn grep_error(p: &str) -> Option { /// The Justification spacing check of Paragraph Style Options, against what the section showed: /// the style's own values, or a new style's Based On. fn check_paragraph_style_justification(app: &DesignApp, d: &Dialog) -> Result<(), String> { - let shown = if d.b("new") { d.s("basedOn") } else { d.s("name") }; - let base = - app.session.active().map(|st| serde_json::to_value(st.doc.styles.resolve_para_style(&shown).0).unwrap_or_default()).unwrap_or_default(); + // After Reset To Base the style starts from its Based On (the one chosen here, if any). + let base_of = |st: &designcraft_engine::DocState| { + let chosen = d.s("basedOn"); + if chosen.is_empty() { st.doc.styles.para(&d.s("name")).and_then(|s| s.based_on.clone()).unwrap_or_default() } else { chosen } + }; + let base = app + .session + .active() + .map(|st| { + let shown = if d.b("new") { + d.s("basedOn") + } else if d.b("resetToBase") { + base_of(st) + } else { + d.s("name") + }; + serde_json::to_value(st.doc.styles.resolve_para_style(&shown).0).unwrap_or_default() + }) + .unwrap_or_default(); check_justification(d, "p.", &base) } @@ -3777,6 +3793,10 @@ fn style_options_preview(app: &mut DesignApp, d: &mut Dialog) { _ => based.clone(), }; } + // Reset To Base shows on the page too. + if paragraph && d.b("resetToBase") { + params["resetToBase"] = json!(true); + } if shown.as_ref() == Some(¶ms) { return; } @@ -7614,6 +7634,30 @@ mod tests { assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); } + /// With Reset To Base, the justification fields are checked against the base style (the + /// style's own minimum no longer applies), and Preview shows the reset on the page. + #[test] + fn reset_to_base_checks_and_previews_against_the_base() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Loose words"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "basedOn": designcraft_doc::BASIC_PARAGRAPH, "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}, "chars": {"size": 30}})) + .unwrap(); + app.run("style.paragraph.apply", json!({"name": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Loose"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.1)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below the style's own minimum"); + d.fields.insert("resetToBase".into(), json!(true)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "the base's minimum is 100%"); + d.fields.insert("preview".into(), json!(true)); + style_options_preview(&mut app, &mut d); + assert_eq!(d.fields[PREVIEWED]["resetToBase"], true); + let size = app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"].clone(); + assert_eq!(size, json!(12.0), "the page shows the style reset to its base"); + app.session.cancel_interaction(); + } + #[test] fn paragraph_style_options_edit_one_rule_field() { let (mut app, rule) = ruled_paragraph(); From 03754e381a074078cf713a59bce4c6815fe06c46 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:58:44 +0200 Subject: [PATCH 3/8] Paragraph styles: Based On and Next Style must name styles; the dialog resolves over the chosen Based On Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/style.rs | 23 ++++++++++++++--- crates/engine/src/tests.rs | 17 ++++++++++++ crates/ui-egui/src/dialogs.rs | 47 +++++++++++++++++++++++++++++----- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 903cd7d0f..699b01ea8 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -875,6 +875,13 @@ fn create_para(s: &mut Session, p: &Value) -> Result { { return Err(bad("style.paragraph.create", format!("no style `{b}`"))); } + // Next Style: a style of the document, or this one. + if let Some(n) = &next + && *n != name + && d.styles.para(n).is_none() + { + return Err(bad("style.paragraph.create", format!("no style `{n}`"))); + } d.styles_mut().paragraph.push(ParagraphStyle { name: name.clone(), based_on, next_style: next, para, chars, shortcut: String::new() }); Ok(json!({"name": name})) }) @@ -938,11 +945,19 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { let based = p.get("basedOn").cloned(); let next = p.get("nextStyle").cloned(); let reset = p.get("resetToBase").and_then(Value::as_bool).unwrap_or(false); + for (key, v) in [("basedOn", &based), ("nextStyle", &next)] { + if v.as_ref().is_some_and(|v| !v.is_string() && !v.is_null()) { + return Err(bad("style.paragraph.edit", format!("`{key}` must be a style name or null"))); + } + } s.edit(|d, _| { - if let Some(Value::String(b)) = &based - && d.styles.para_based_on_cycles(&name, b) - { - return Err(bad("style.paragraph.edit", "based-on would create a cycle")); + if let Some(Value::String(b)) = &based { + if d.styles.para(b).is_none() { + return Err(bad("style.paragraph.edit", format!("no style `{b}`"))); + } + if d.styles.para_based_on_cycles(&name, b) { + return Err(bad("style.paragraph.edit", "based-on would create a cycle")); + } } if let Some(Value::String(n)) = &next && d.styles.para(n).is_none() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4bc7e360c..33ae449e0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1665,3 +1665,20 @@ fn reset_to_base_with_a_new_parent_resolves_rule_edits_over_it() { let r = s.doc().unwrap().doc.styles.resolve_para_style("Child").0.rule_below; assert_eq!((r.weight, r.offset), (2.0, 9.0), "the offset is the new parent's"); } + +/// Paragraph style references are checked: a Based On or Next Style that names no style, or +/// that isn't a name or null, is refused and changes nothing. +#[test] +fn paragraph_style_references_must_name_styles() { + let mut s = session(); + s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap(); + assert!(s.execute("style.paragraph.create", &json!({"name": "Lost", "nextStyle": "Nowhere"})).is_err()); + assert!(s.doc().unwrap().doc.styles.para("Lost").is_none()); + s.execute("style.paragraph.create", &json!({"name": "Self", "nextStyle": "Self"})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + for p in [json!({"name": "Body", "basedOn": "Nowhere"}), json!({"name": "Body", "basedOn": 3}), json!({"name": "Body", "nextStyle": true})] { + assert!(s.execute("style.paragraph.edit", &p).is_err(), "{p}"); + } + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed"); + s.execute("style.paragraph.edit", &json!({"name": "Body", "basedOn": null, "nextStyle": "Self"})).unwrap(); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d5341722e..491b82866 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3645,6 +3645,21 @@ fn grep_error(p: &str) -> Option { /// The Justification spacing check of Paragraph Style Options, against what the section showed: /// the style's own values, or a new style's Based On. +/// What paragraph style `style` resolves to when based on `based` (the Based On chosen in its +/// dialog, which may not be its own yet): `based`'s chain with the style's own settings over it. +fn style_over( + styles: &designcraft_doc::Styles, + based: &str, + style: &designcraft_doc::ParagraphStyle, +) -> (designcraft_doc::ParaProps, designcraft_doc::CharProps) { + styles.resolve_para(&designcraft_doc::ParaFormat { + style: based.into(), + para: style.para.clone(), + chars: style.chars.clone(), + ..Default::default() + }) +} + fn check_paragraph_style_justification(app: &DesignApp, d: &Dialog) -> Result<(), String> { // After Reset To Base the style starts from its Based On (the one chosen here, if any). let base_of = |st: &designcraft_engine::DocState| { @@ -3655,14 +3670,18 @@ fn check_paragraph_style_justification(app: &DesignApp, d: &Dialog) -> Result<() .session .active() .map(|st| { - let shown = if d.b("new") { - d.s("basedOn") + let pp = if d.b("new") { + st.doc.styles.resolve_para_style(&d.s("basedOn")).0 } else if d.b("resetToBase") { - base_of(st) + st.doc.styles.resolve_para_style(&base_of(st)).0 } else { - d.s("name") + // The style's own settings over the Based On chosen in the dialog. + match st.doc.styles.para(&d.s("name")) { + Some(style) => style_over(&st.doc.styles, &base_of(st), style).0, + None => st.doc.styles.resolve_para_style(&d.s("name")).0, + } }; - serde_json::to_value(st.doc.styles.resolve_para_style(&shown).0).unwrap_or_default() + serde_json::to_value(pp).unwrap_or_default() }) .unwrap_or_default(); check_justification(d, "p.", &base) @@ -3847,7 +3866,8 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo let names: Vec = doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| new || *n != name).collect(); // A new style shows what it would inherit from its Based On (as does one reset to it). let based_name = d.fields.get("basedOn").and_then(Value::as_str).map(str::to_string).or(style.based_on.clone()).unwrap_or_default(); - let (pp, cp) = doc.styles.resolve_para_style(if new || reset { &based_name } else { &name }); + // An existing style shows its own settings over the Based On chosen here. + let (pp, cp) = if new || reset { doc.styles.resolve_para_style(&based_name) } else { style_over(&doc.styles, &based_name, &style) }; let units = doc.settings.horizontal_units; let pv = serde_json::to_value(&pp).unwrap_or_default(); let cv = serde_json::to_value(&cp).unwrap_or_default(); @@ -7634,6 +7654,21 @@ mod tests { assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); } + /// A Based On changed in the dialog (without Reset To Base) is what the fields are checked + /// against: the style's own settings over the new parent. + #[test] + fn a_changed_based_on_is_what_the_fields_are_checked_against() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Child", "basedOn": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Child"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.0)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below Loose's minimum"); + d.fields.insert("basedOn".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "[Basic Paragraph]'s minimum is lower"); + } + /// With Reset To Base, the justification fields are checked against the base style (the /// style's own minimum no longer applies), and Preview shows the reset on the page. #[test] From c5bca17a9bec4c36c27bd288ec487ee5b1aabd10 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 00:02:56 +0200 Subject: [PATCH 4/8] New Paragraph Style: apply to the selection once the dialog knows the style The dialog notes that the created style exists (so a second OK edits it) before Apply Style to Selection runs, so a failure there can't lead to a duplicate style. The Justification check's doc comment is back above its function. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 491b82866..4c5ed581a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3643,8 +3643,6 @@ fn grep_error(p: &str) -> Option { designcraft_compose::grep::Grep::new(p, Default::default()).err().map(|e| e.to_string()) } -/// The Justification spacing check of Paragraph Style Options, against what the section showed: -/// the style's own values, or a new style's Based On. /// What paragraph style `style` resolves to when based on `based` (the Based On chosen in its /// dialog, which may not be its own yet): `based`'s chain with the style's own settings over it. fn style_over( @@ -3660,6 +3658,8 @@ fn style_over( }) } +/// The Justification spacing check of Paragraph Style Options, against what the section showed: +/// the style's own values, or a new style's Based On. fn check_paragraph_style_justification(app: &DesignApp, d: &Dialog) -> Result<(), String> { // After Reset To Base the style starts from its Based On (the one chosen here, if any). let base_of = |st: &designcraft_engine::DocState| { @@ -3726,10 +3726,6 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul } let r = app.run("style.paragraph.create", params)?; let style = r["name"].as_str().unwrap_or_default().to_string(); - // Apply Style to Selection. - if d.b("apply") && app.session.active().is_some_and(|st| st.selection.text.is_some()) { - app.run("style.paragraph.apply", json!({"name": style}))?; - } (r, style) } else { let mut params = json!({"name": name, "para": para, "chars": chars}); @@ -3750,9 +3746,14 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul }; // The style now exists under `style`: if a later step fails, the dialog stays open on it and // a second OK edits it rather than creating another one. + let created = d.b("new"); d.fields.insert("new".into(), json!(false)); d.fields.insert("name".into(), json!(&style)); d.fields.insert("rename".into(), json!(&style)); + // Apply Style to Selection. + if created && d.b("apply") && app.session.active().is_some_and(|st| st.selection.text.is_some()) { + app.run("style.paragraph.apply", json!({"name": style}))?; + } if d.fields.contains_key("x.tag") { let tag = d.s("x.tag"); let tag = if tag == "[Automatic]" { String::new() } else { tag }; From d4cebe862e30fc0ceb1147763b62d0665bb037a8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:30 +0100 Subject: [PATCH 5/8] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 81 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 81 +++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 81 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0cf58d61d..e286dc94f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,87 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// The course's chapter 20: the "Recipe Categories" title formatted on the page (size, All - /// Caps, tracking, space after), then New Paragraph Style from it: the dialog starts from the - /// text, and with Apply Style to Selection the paragraph takes the new style. Next Style and - /// Reset To Base are set from General. - #[test] - fn a_new_paragraph_style_starts_from_the_selected_text() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Egg Recipes\rBoil them."})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 11})).unwrap(); - app.run("type.char", json!({"attrs": {"size": 20, "capitalization": "allCaps", "tracking": 50}})).unwrap(); - app.run("type.para", json!({"attrs": {"spaceAfter": 6}})).unwrap(); - open_new_style(&mut app, true); - let d = app.ui.dialog.clone().unwrap(); - assert_eq!( - (d.fields["c.size"].clone(), d.fields["c.capitalization"].clone(), d.fields["p.spaceAfter"].clone()), - (json!(20.0), json!("allCaps"), json!(6.0)) - ); - assert!(d.b("apply") && !d.fields.contains_key("c.fontFamily"), "only what differs from [No Paragraph Style]"); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("rename".into(), json!("Recipe Categories")); - d.fields.insert("nextStyle".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); - } - confirm(&mut app).unwrap(); - let a = app.run("type.selectionAttrs", json!({})).unwrap(); - assert_eq!(a["paragraphStyle"], "Recipe Categories", "applied to the selection"); - assert_eq!(a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0), 0); - let style = |app: &DesignApp| app.session.doc().unwrap().doc.styles.para("Recipe Categories").cloned().unwrap(); - assert_eq!(style(&app).next_style.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - assert_eq!(style(&app).chars.size, Some(20.0)); - // Reset To Base: the style keeps nothing of its own, apart from the edits made then. - app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Recipe Categories", "basedOn": designcraft_doc::BASIC_PARAGRAPH}))); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("resetToBase".into(), json!(true)); - d.fields.insert("c.tracking".into(), json!(10.0)); - } - confirm(&mut app).unwrap(); - let s = style(&app); - assert_eq!((s.chars.size, s.chars.tracking, s.para.space_after), (None, Some(10.0), None), "{s:?}"); - assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - } - - /// A Based On changed in the dialog (without Reset To Base) is what the fields are checked - /// against: the style's own settings over the new parent. - #[test] - fn a_changed_based_on_is_what_the_fields_are_checked_against() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.run("style.paragraph.create", json!({"name": "Loose", "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}})).unwrap(); - app.run("style.paragraph.create", json!({"name": "Child", "basedOn": "Loose"})).unwrap(); - let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Child"})); - d.fields.insert("p.wordSpaceDesired".into(), json!(1.0)); - assert!(check_paragraph_style_justification(&app, &d).is_err(), "below Loose's minimum"); - d.fields.insert("basedOn".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); - assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "[Basic Paragraph]'s minimum is lower"); - } - - /// With Reset To Base, the justification fields are checked against the base style (the - /// style's own minimum no longer applies), and Preview shows the reset on the page. - #[test] - fn reset_to_base_checks_and_previews_against_the_base() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Loose words"})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); - app.run("style.paragraph.create", json!({"name": "Loose", "basedOn": designcraft_doc::BASIC_PARAGRAPH, "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}, "chars": {"size": 30}})) - .unwrap(); - app.run("style.paragraph.apply", json!({"name": "Loose"})).unwrap(); - let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Loose"})); - d.fields.insert("p.wordSpaceDesired".into(), json!(1.1)); - assert!(check_paragraph_style_justification(&app, &d).is_err(), "below the style's own minimum"); - d.fields.insert("resetToBase".into(), json!(true)); - assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "the base's minimum is 100%"); - d.fields.insert("preview".into(), json!(true)); - style_options_preview(&mut app, &mut d); - assert_eq!(d.fields[PREVIEWED]["resetToBase"], true); - let size = app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"].clone(); - assert_eq!(size, json!(12.0), "the page shows the style reset to its base"); - app.session.cancel_interaction(); - } - #[test] fn color_theme_cancel_is_read_only_and_confirm_adds_the_preview() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 0d1430061..d221100cd 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3522,3 +3522,84 @@ fn character_style_options_set_the_digits() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); assert_eq!(char_style(&app, "Emphasis").unwrap().chars.digits, Some(designcraft_doc::Digits::Farsi)); } + +/// The course's chapter 20: the "Recipe Categories" title formatted on the page (size, All +/// Caps, tracking, space after), then New Paragraph Style from it: the dialog starts from the +/// text, and with Apply Style to Selection the paragraph takes the new style. Next Style and +/// Reset To Base are set from General. +#[test] +fn a_new_paragraph_style_starts_from_the_selected_text() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Egg Recipes\rBoil them."})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 11})).unwrap(); + app.run("type.char", json!({"attrs": {"size": 20, "capitalization": "allCaps", "tracking": 50}})).unwrap(); + app.run("type.para", json!({"attrs": {"spaceAfter": 6}})).unwrap(); + open_new_style(&mut app, true); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!( + (d.fields["c.size"].clone(), d.fields["c.capitalization"].clone(), d.fields["p.spaceAfter"].clone()), + (json!(20.0), json!("allCaps"), json!(6.0)) + ); + assert!(d.b("apply") && !d.fields.contains_key("c.fontFamily"), "only what differs from [No Paragraph Style]"); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("rename".into(), json!("Recipe Categories")); + d.fields.insert("nextStyle".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + } + confirm(&mut app).unwrap(); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!(a["paragraphStyle"], "Recipe Categories", "applied to the selection"); + assert_eq!(a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0), 0); + let style = |app: &DesignApp| app.session.doc().unwrap().doc.styles.para("Recipe Categories").cloned().unwrap(); + assert_eq!(style(&app).next_style.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(style(&app).chars.size, Some(20.0)); + // Reset To Base: the style keeps nothing of its own, apart from the edits made then. + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Recipe Categories", "basedOn": designcraft_doc::BASIC_PARAGRAPH}))); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("resetToBase".into(), json!(true)); + d.fields.insert("c.tracking".into(), json!(10.0)); + } + confirm(&mut app).unwrap(); + let s = style(&app); + assert_eq!((s.chars.size, s.chars.tracking, s.para.space_after), (None, Some(10.0), None), "{s:?}"); + assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); +} + +/// A Based On changed in the dialog (without Reset To Base) is what the fields are checked +/// against: the style's own settings over the new parent. +#[test] +fn a_changed_based_on_is_what_the_fields_are_checked_against() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Child", "basedOn": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Child"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.0)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below Loose's minimum"); + d.fields.insert("basedOn".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "[Basic Paragraph]'s minimum is lower"); +} + +/// With Reset To Base, the justification fields are checked against the base style (the +/// style's own minimum no longer applies), and Preview shows the reset on the page. +#[test] +fn reset_to_base_checks_and_previews_against_the_base() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Loose words"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "basedOn": designcraft_doc::BASIC_PARAGRAPH, "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}, "chars": {"size": 30}})) + .unwrap(); + app.run("style.paragraph.apply", json!({"name": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Loose"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.1)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below the style's own minimum"); + d.fields.insert("resetToBase".into(), json!(true)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "the base's minimum is 100%"); + d.fields.insert("preview".into(), json!(true)); + style_options_preview(&mut app, &mut d); + assert_eq!(d.fields[PREVIEWED]["resetToBase"], true); + let size = app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"].clone(); + assert_eq!(size, json!(12.0), "the page shows the style reset to its base"); + app.session.cancel_interaction(); +} From a05bf962e8d2ce0ec249127c12c57840f0c2264d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:05:46 +0100 Subject: [PATCH 6/8] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index d221100cd..e2877fb41 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3269,8 +3269,8 @@ fn new_paragraph_style_opens_its_options_and_creates_the_style() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); let st = app.session.active().unwrap().doc.styles.para("Paragraph Style 1").cloned().unwrap(); assert_eq!(st.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - // The selected text's own Italic comes along, as InDesign's New Paragraph Style takes it. - assert_eq!(st.chars, CharAttrs { size: Some(20.0), font_style: Some("Italic".into()), ..Default::default() }); + // The selected text's own Italic comes along, as InDesign's New Paragraph Style takes it. + assert_eq!(st.chars, CharAttrs { size: Some(20.0), font_style: Some("Italic".into()), ..Default::default() }); } #[test] From b622e045088fee1d05160a7255ef5b27b48556ea Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:00:22 +0100 Subject: [PATCH 7/8] Paragraph Style Options: Next Style named by its label, for screen readers; craft_ui dropdowns Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e286dc94f..13d7e54b3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4396,7 +4396,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Based On")); ui.end_row(); // Next Style: the style of the paragraph that follows when Enter ends this one. - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Next Style:")); + let next_name = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Next Style:")); let next = match d.fields.get("nextStyle") { Some(Value::String(n)) => Some(n.clone()), Some(_) => None, @@ -4404,7 +4404,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }; let same = crate::i18n::tr(&app.ui.language, "[Same style]").to_string(); let shown = next.as_deref().map_or(same.clone(), |n| crate::i18n::style_name(&app.ui.language, n).to_string()); - egui::ComboBox::from_id_salt("psnext").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("psnext").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { d.fields.insert("nextStyle".into(), Value::Null); } @@ -4416,7 +4416,9 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo d.fields.insert("nextStyle".into(), json!(n)); } } - }); + }) + .response + .labelled_by(next_name.id); ui.end_row(); }); // Reset To Base: the style keeps nothing of its own (the edits made here stay). @@ -5832,7 +5834,7 @@ fn char_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, su fn char_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, opts: &[(Value, &str)]) { let cur = cf.get(d, key); let shown = opts.iter().find(|o| o.0 == cur).map_or("", |o| crate::i18n::tr(lang, o.1)); - egui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(140.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(140.0).show_ui(ui, |ui| { if cf.sparse && ui.selectable_label(cur.is_null(), " ").clicked() { cf.set(d, key, Value::Null); } From 133536f0e7d614cd576806b12fbe94ff5765462f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 16:48:55 +0100 Subject: [PATCH 8/8] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 13d7e54b3..8e52ac46c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4404,21 +4404,27 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }; let same = crate::i18n::tr(&app.ui.language, "[Same style]").to_string(); let shown = next.as_deref().map_or(same.clone(), |n| crate::i18n::style_name(&app.ui.language, n).to_string()); - craft_ui::ComboBox::from_id_salt("psnext").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { - if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { - d.fields.insert("nextStyle".into(), Value::Null); - } - for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) { - if ui - .selectable_label(next.as_deref() == Some(n), crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))) - .clicked() - { - d.fields.insert("nextStyle".into(), json!(n)); + craft_ui::ComboBox::from_id_salt("psnext") + .selected_text(crate::rtl::widget(ui, shown)) + .width(220.0) + .show_ui(ui, |ui| { + if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { + d.fields.insert("nextStyle".into(), Value::Null); } - } - }) - .response - .labelled_by(next_name.id); + for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) { + if ui + .selectable_label( + next.as_deref() == Some(n), + crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n)), + ) + .clicked() + { + d.fields.insert("nextStyle".into(), json!(n)); + } + } + }) + .response + .labelled_by(next_name.id); ui.end_row(); }); // Reset To Base: the style keeps nothing of its own (the edits made here stay).