diff --git a/ROADMAP.md b/ROADMAP.md index d0bf01ce7..8df608dbc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -47,6 +47,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope IDML export after rejection and both selection orders. ## 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. @@ -457,6 +458,7 @@ Newest first. - **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change. - **2026-10-21** · Paragraph Border and Paragraph Shading link their four sides as InDesign's: the stroke weights, offsets and corners have the "Make all settings the same" link, on when the four are the same, so a side typed in sets all four; with the link off a side is set alone (a border with only a thick left side). Found in the Learn Skills Daily course, chapter 40. No readiness percentage change. - **2026-10-21** · Paragraph rules have InDesign's Type (solid, thick/thin stripes, dashed, dotted, straight hash, wavy, or a stroke style of the document), Gap Color and Gap Tint, and Keep In Frame (rule above); the Paragraph Rules page is laid out as InDesign's (Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, Left and Right Indent). IDML reads and writes them (`RuleAboveType`, `RuleBelowGapColor`, `KeepRuleAboveInFrame`…). Found in the Learn Skills Daily course, chapter 20. No readiness percentage change. +- **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-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4cc8a5894..348404740 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -134,7 +134,7 @@ pub fn specs() -> Vec { "Paragraph Style Options…", [], None, - "{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; 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, ignoreOpticalMargin, …; 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 ), diff --git a/crates/engine/src/cmd/style/paragraph.rs b/crates/engine/src/cmd/style/paragraph.rs index 81711ff10..72d957902 100644 --- a/crates/engine/src/cmd/style/paragraph.rs +++ b/crates/engine/src/cmd/style/paragraph.rs @@ -437,6 +437,13 @@ pub(super) 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})) }) @@ -499,11 +506,41 @@ pub(super) fn edit_para(s: &mut Session, p: &Value) -> Result { crate::cmd::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).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); + 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) + 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() { - return Err(bad("style.paragraph.edit", "based-on would create a cycle")); + 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 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, + _ => {} + } } if let Some(n) = &rename { if n.is_empty() || n.starts_with('[') { @@ -522,9 +559,9 @@ pub(super) 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, _ => {} } if let Some(n) = rename.clone() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6347b605f..398ea3650 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1661,6 +1661,38 @@ fn a_line_fits_when_its_baseline_does() { 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"); +} + +/// 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(); +} + /// Issue #1637: with several objects selected, a click on one of them (no drag) makes it the key /// object, and Align to Key Object leaves it in place. A second click on it drops the key. #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index faf329a07..88cb4d015 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3967,12 +3967,47 @@ fn grep_error(p: &str) -> Option { designcraft_compose::grep::Grep::new(p, Default::default()).err().map(|e| e.to_string()) } +/// 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() + }) +} + /// 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 pp = if d.b("new") { + st.doc.styles.resolve_para_style(&d.s("basedOn")).0 + } else if d.b("resetToBase") { + st.doc.styles.resolve_para_style(&base_of(st)).0 + } else { + // 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(pp).unwrap_or_default() + }) + .unwrap_or_default(); check_justification(d, "p.", &base) } @@ -4010,16 +4045,26 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul let (para, chars) = style_option_maps(d); let based = d.s("basedOn"); let rename = d.s("rename").trim().to_string(); + let next = d.fields.get("nextStyle").cloned(); let (r, style) = if d.b("new") { let mut params = json!({"name": if rename.is_empty() { "Paragraph Style 1" } else { rename.as_str() }, "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(); (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) }; } @@ -4037,9 +4082,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 }; @@ -4104,6 +4154,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; } @@ -4145,9 +4199,17 @@ 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(); + // 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(); @@ -4333,13 +4395,59 @@ 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. + 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, + 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()); + 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)); + } + } + }) + .response + .labelled_by(next_name.id); + 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")); + } } }); }); @@ -5600,8 +5708,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)); } /// The General section of the Character Style Options dialog: name, Based On, and a summary of @@ -5779,7 +5907,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)); - craft_ui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.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); } @@ -5822,6 +5950,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(); @@ -5871,9 +6000,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(), @@ -5894,7 +6024,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(); @@ -5912,7 +6041,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, diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 8884389b1..2665c0284 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3269,7 +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)); - 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] @@ -3682,3 +3683,84 @@ fn a_rules_default_gap_tint_shows_100() { .and_then(|n| n.value()); assert_eq!(field.as_deref(), Some("100%")); } + +/// 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(); +} diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 0bfc582d2..08a0d9438 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -32,6 +32,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "نسخ"), ("Paste", "لصق"), ("Keep In Frame", "الإبقاء داخل الإطار"), + ("Next Style:", "النمط التالي:"), + ("[Same style]", "[النمط نفسه]"), + ("Reset To Base", "إعادة التعيين إلى الأساس"), + ("Apply Style to Selection", "تطبيق النمط على التحديد"), ("Restart Numbering", "إعادة الترقيم"), ("Continue Numbering", "متابعة الترقيم"), ("Keep Options…", "خيارات الإبقاء…"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index d66258419..384d410ee 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -34,6 +34,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paste", "Vložit"), ("Keep In Frame", "Zachovat v rámečku"), ("Gap Tint:", "Odstín mezery:"), + ("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"), ("Restart Numbering", "Začít číslování znovu"), ("Continue Numbering", "Pokračovat v číslování"), ("Keep Options…", "Volby zachování…"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 1a5818894..97bb4ed7e 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -30,6 +30,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "Copia"), ("Paste", "Incolla"), ("Keep In Frame", "Mantieni nella cornice"), + ("Next Style:", "Stile successivo:"), + ("[Same style]", "[Stesso stile]"), + ("Reset To Base", "Ripristina base"), + ("Apply Style to Selection", "Applica stile alla selezione"), ("Restart Numbering", "Riavvia numerazione"), ("Continue Numbering", "Continua numerazione"), ("Keep Options…", "Opzioni di separazione…"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 87bacaf8a..6f2a880ae 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -33,6 +33,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "კოპირება"), ("Paste", "ჩასმა"), ("Keep In Frame", "ჩარჩოში შენარჩუნება"), + ("Next Style:", "შემდეგი სტილი:"), + ("[Same style]", "[იგივე სტილი]"), + ("Reset To Base", "საბაზოზე დაბრუნება"), + ("Apply Style to Selection", "სტილის გამოყენება მონიშნულზე"), ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"), ("Continue Numbering", "ნუმერაციის გაგრძელება"), ("Keep Options…", "შენარჩუნების პარამეტრები…"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 305a19980..8bfbfb863 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -32,6 +32,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paste", "Wklej"), ("Keep In Frame", "Zachowaj w ramce"), ("Gap Tint:", "Odcień odstępu:"), + ("Next Style:", "Następny styl:"), + ("[Same style]", "[Ten sam styl]"), + ("Reset To Base", "Przywróć bazowy"), + ("Apply Style to Selection", "Zastosuj styl do zaznaczenia"), ("Restart Numbering", "Zacznij numerowanie od nowa"), ("Continue Numbering", "Kontynuuj numerowanie"), ("Keep Options…", "Opcje utrzymywania…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index bd7bf0f58..cd1ee1ca6 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -33,6 +33,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "Copiar"), ("Paste", "Colar"), ("Keep In Frame", "Manter no quadro"), + ("Next Style:", "Próximo estilo:"), + ("[Same style]", "[Mesmo estilo]"), + ("Reset To Base", "Redefinir para base"), + ("Apply Style to Selection", "Aplicar estilo à seleção"), ("Restart Numbering", "Reiniciar numeração"), ("Continue Numbering", "Continuar numeração"), ("Keep Options…", "Opções de manutenção…"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 3399700b4..3e24376bf 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -36,6 +36,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paste", "Вставить"), ("Keep In Frame", "Сохранить во фрейме"), ("Gap Tint:", "Оттенок пробелов:"), + ("Next Style:", "Следующий стиль:"), + ("[Same style]", "[Тот же стиль]"), + ("Reset To Base", "Сбросить до основы"), + ("Apply Style to Selection", "Применить стиль к выделенному"), ("Restart Numbering", "Начать нумерацию заново"), ("Continue Numbering", "Продолжить нумерацию"), ("Keep Options…", "Параметры удержания…"), diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index 3e01872be..e606ff58f 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -1,6 +1,19 @@ /// English → [German, French, Spanish, Japanese, Simplified Chinese]. pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Keep In Frame", ["Im Rahmen behalten", "Conserver dans le bloc", "Mantener en el marco", "フレーム内に保持", "保持在框架内"]), + ("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", + "選択範囲にスタイルを適用", + "将样式应用于选区", + ], + ), ( "Character Formatting Controls", [ diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index edb7b1378..7105b3bef 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "Копіювати"), ("Paste", "Вставити"), ("Keep In Frame", "Зберегти у фреймі"), + ("Next Style:", "Наступний стиль:"), + ("[Same style]", "[Той самий стиль]"), + ("Reset To Base", "Скинути до основи"), + ("Apply Style to Selection", "Застосувати стиль до виділеного"), ("Restart Numbering", "Почати нумерацію знову"), ("Continue Numbering", "Продовжити нумерацію"), ("Keep Options…", "Параметри утримання…"),