Merge #171: Paragraph and Character Styles: wire save and delete into the UI (#169)

Conflict: crates/engine/src/tests.rs: both sides append tests; kept next's then #171's
This commit is contained in:
aiongg
2026-10-10 09:19:37 +00:00
4 changed files with 340 additions and 1 deletions
+22
View File
@@ -118,6 +118,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
edit_char
),
cmd!("style.character.delete", "Delete Character Style", [], None, "{name, replaceWith?}", has_doc, delete_char),
cmd!(query "style.list", "List Styles", [], None, "{} → paragraph and character style names", has_doc, |s, _| {
let st = s.doc()?;
Ok(json!({
@@ -685,6 +686,27 @@ fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn delete_char(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("").to_string();
let repl = str_param(p, "replaceWith").unwrap_or(designcraft_doc::NO_CHAR_STYLE).to_string();
if name.starts_with('[') {
return Err(bad("style.character.delete", "built-in styles can't be deleted"));
}
s.edit(|d, _| {
d.styles_mut().character.retain(|x| x.name != name);
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
if let Some(story) = d.story_mut(sid) {
story.for_each_text_mut(&mut |st| {
for r in st.chars.iter_mut().filter(|r| r.format.style == name) {
r.format.style = repl.clone();
}
});
}
}
ok()
})
}
fn create_swatch(s: &mut Session, p: &Value) -> Result<Value> {
let c = p.get("color").ok_or_else(|| bad("swatch.create", "missing color"))?;
let color = parse_color(c).ok_or_else(|| bad("swatch.create", "bad color"))?;
+111
View File
@@ -703,3 +703,114 @@ fn preflight_mojikumi_uses_the_same_supported_rules_as_composition() {
assert_eq!(!warnings.is_empty(), unsupported, "{name}: {warnings:?}");
}
}
#[test]
fn paragraph_style_edit_persists_attrs_and_rename() {
let mut s = session();
let name =
s.execute("style.paragraph.create", &json!({"name": "Body Copy", "para": {"align": "left"}})).unwrap()["name"].as_str().unwrap().to_string();
s.execute("style.paragraph.edit", &json!({"name": name, "para": {"align": "center"}})).unwrap();
{
let st = s.doc().unwrap();
let style = st.doc.styles.para(&name).expect("style still exists");
assert_eq!(style.para.align, Some(designcraft_doc::Align::Center), "edited attribute persisted");
}
// Renaming updates the style definition itself, and every place it's referenced.
s.execute("style.paragraph.edit", &json!({"name": name, "rename": "Body Text"})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.styles.para("Body Text").is_some(), "renamed style exists under the new name");
assert!(st.doc.styles.para(&name).is_none(), "old name is gone");
}
#[test]
fn paragraph_style_edit_rejects_based_on_cycle() {
let mut s = session();
let a = s.execute("style.paragraph.create", &json!({"name": "A"})).unwrap()["name"].as_str().unwrap().to_string();
let b = s.execute("style.paragraph.create", &json!({"name": "B", "basedOn": a})).unwrap()["name"].as_str().unwrap().to_string();
let r = s.execute("style.paragraph.edit", &json!({"name": a, "basedOn": b}));
assert!(r.is_err(), "A based on B based on A must be rejected");
}
#[test]
fn paragraph_style_delete_reassigns_affected_text() {
let mut s = session();
let name = s.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string();
let sid = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap()["story"].as_u64().unwrap();
s.execute("text.insert", &json!({"text": "Hello, world"})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": name})).unwrap();
{
let st = s.doc().unwrap();
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists");
assert_eq!(story.paras[0].style, name, "style applied before delete");
}
s.execute("style.paragraph.delete", &json!({"name": name, "replaceWith": designcraft_doc::BASIC_PARAGRAPH})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.styles.para(&name).is_none(), "deleted style is gone");
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists");
assert_eq!(story.paras[0].style, designcraft_doc::BASIC_PARAGRAPH, "affected paragraph reassigned to the replacement style");
}
#[test]
fn paragraph_style_delete_rejects_builtin_styles() {
let mut s = session();
let r = s.execute("style.paragraph.delete", &json!({"name": designcraft_doc::BASIC_PARAGRAPH}));
assert!(r.is_err(), "built-in styles can't be deleted");
}
#[test]
fn character_style_edit_persists_attrs_and_rename() {
let mut s = session();
let name = s.execute("style.character.create", &json!({"name": "Key Term", "chars": {"fontStyle": "Bold"}})).unwrap()["name"]
.as_str()
.unwrap()
.to_string();
s.execute("style.character.edit", &json!({"name": name, "chars": {"tracking": 20.0}})).unwrap();
{
let st = s.doc().unwrap();
let style = st.doc.styles.char_style(&name).expect("style still exists");
assert_eq!(style.chars.tracking, Some(20.0), "edited attribute persisted");
}
// Renaming updates the style definition itself, and every place it's referenced.
s.execute("style.character.edit", &json!({"name": name, "rename": "Emphasis"})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.styles.char_style("Emphasis").is_some(), "renamed style exists under the new name");
assert!(st.doc.styles.char_style(&name).is_none(), "old name is gone");
}
#[test]
fn character_style_edit_rejects_based_on_cycle() {
let mut s = session();
let a = s.execute("style.character.create", &json!({"name": "A"})).unwrap()["name"].as_str().unwrap().to_string();
let b = s.execute("style.character.create", &json!({"name": "B", "basedOn": a})).unwrap()["name"].as_str().unwrap().to_string();
let r = s.execute("style.character.edit", &json!({"name": a, "basedOn": b}));
assert!(r.is_err(), "A based on B based on A must be rejected");
}
#[test]
fn character_style_delete_reassigns_affected_text() {
let mut s = session();
let name = s.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.insert", &json!({"text": "Hello, world"})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
s.execute("style.character.apply", &json!({"name": name})).unwrap();
{
let st = s.doc().unwrap();
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists");
assert_eq!(story.chars[0].format.style, name, "style applied before delete");
}
s.execute("style.character.delete", &json!({"name": name, "replaceWith": designcraft_doc::NO_CHAR_STYLE})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.styles.char_style(&name).is_none(), "deleted style is gone");
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists");
assert_eq!(story.chars[0].format.style, designcraft_doc::NO_CHAR_STYLE, "affected run reassigned to the replacement style");
}
#[test]
fn character_style_delete_rejects_builtin_styles() {
let mut s = session();
let r = s.execute("style.character.delete", &json!({"name": designcraft_doc::NO_CHAR_STYLE}));
assert!(r.is_err(), "built-in styles can't be deleted");
}
+193 -1
View File
@@ -68,6 +68,9 @@ impl Dialog {
if id == "paragraphStyleOptions" || id == "characterStyleOptions" {
fields.entry("section".to_string()).or_insert(json!("general"));
}
if id == "characterStyleOptions" {
fields.entry("section".to_string()).or_insert(json!("general"));
}
if id == "frameSize" {
for k in ["width", "height"] {
if let Some(v) = fields.get(k).and_then(Value::as_f64) {
@@ -1129,7 +1132,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"),
"findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"),
"paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph Style Options"),
"deleteParagraphStyle" => crate::i18n::tr(&app.ui.language, "Delete Paragraph Style"),
"characterStyleOptions" => crate::i18n::tr(&app.ui.language, "Character Style Options"),
"deleteCharacterStyle" => crate::i18n::tr(&app.ui.language, "Delete Character Style"),
"footnoteOptions" => crate::i18n::tr(&app.ui.language, "Footnote Options"),
"paragraphRules" => crate::i18n::tr(&app.ui.language, "Paragraph Rules"),
"insertXref" => crate::i18n::tr(&app.ui.language, "New Cross-Reference"),
@@ -1387,7 +1392,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
}
}
"paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d),
"deleteParagraphStyle" => delete_paragraph_style(app, ui, &mut d),
"characterStyleOptions" => character_style_options(app, ui, &mut d),
"deleteCharacterStyle" => delete_character_style(app, ui, &mut d),
"footnoteOptions" => footnote_options(app, ui, &mut d),
"paragraphRules" => {
let current = d.fields.get("current").cloned().unwrap_or_default();
@@ -1859,6 +1866,14 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
}
Ok(r)
}
"deleteParagraphStyle" => {
let name = d.s("name");
let replace = d.s("replaceWith");
app.run(
"style.paragraph.delete",
json!({"name": name, "replaceWith": if replace.is_empty() { json!(designcraft_doc::BASIC_PARAGRAPH) } else { json!(replace) }}),
)
}
"paragraphRules" => {
let attrs = rule_edits(&d, "");
if attrs.is_empty() {
@@ -1903,6 +1918,14 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
}
r
}
"deleteCharacterStyle" => {
let name = d.s("name");
let replace = d.s("replaceWith");
app.run(
"style.character.delete",
json!({"name": name, "replaceWith": if replace.is_empty() { json!(designcraft_doc::NO_CHAR_STYLE) } else { json!(replace) }}),
)
}
"colorPicker" => {
let hex = d.s("hex");
app.run("object.color", json!({"color": hex, "target": d.s("target"), "text": d.b("text")}))
@@ -2707,7 +2730,51 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
});
}
/// The section list on the left of the style options dialogs.
fn delete_paragraph_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
let name = d.s("name");
ui.set_min_width(380.0);
crate::rtl::label(
ui,
egui::RichText::new(
crate::i18n::tr(&app.ui.language, "Delete the paragraph style \"{name}\"?")
.replace("{name}", crate::i18n::style_name(&app.ui.language, &name)),
)
.size(12.5),
);
ui.add_space(8.0);
let Some(st) = app.session.active() else { return };
let in_use = st.doc.stories.values().any(|s| s.paras.iter().any(|f| f.style == name));
let names: Vec<String> = st.doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| *n != name).collect();
if in_use {
crate::rtl::label(
ui,
egui::RichText::new(crate::i18n::tr(&app.ui.language, "This style is in use. Text using it will be reassigned to:"))
.size(11.0)
.color(crate::theme::Tokens::get(ui.ctx()).text_dim),
);
} else {
crate::rtl::label(
ui,
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Replace with:")).size(11.0).color(crate::theme::Tokens::get(ui.ctx()).text_dim),
);
}
ui.add_space(4.0);
if !d.fields.contains_key("replaceWith") {
d.fields.insert("replaceWith".into(), json!(designcraft_doc::BASIC_PARAGRAPH));
}
let replace = d.s("replaceWith");
egui::ComboBox::from_id_salt("del_para_replace")
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &replace)))
.width(260.0)
.show_ui(ui, |ui| {
for n in &names {
if ui.selectable_label(*n == replace, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() {
d.fields.insert("replaceWith".into(), json!(n));
}
}
});
}
fn section_list(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, sections: &[(&str, &str)]) {
ui.vertical(|ui| {
ui.set_width(170.0);
@@ -3190,6 +3257,51 @@ fn line_options(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields,
});
}
fn delete_character_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
let name = d.s("name");
ui.set_min_width(380.0);
crate::rtl::label(
ui,
egui::RichText::new(
crate::i18n::tr(&app.ui.language, "Delete the character style \"{name}\"?")
.replace("{name}", crate::i18n::style_name(&app.ui.language, &name)),
)
.size(12.5),
);
ui.add_space(8.0);
let Some(st) = app.session.active() else { return };
let in_use = st.doc.stories.values().any(|s| s.chars.iter().any(|r| r.format.style == name));
let names: Vec<String> = st.doc.styles.character.iter().map(|c| c.name.clone()).filter(|n| *n != name).collect();
if in_use {
crate::rtl::label(
ui,
egui::RichText::new(crate::i18n::tr(&app.ui.language, "This style is in use. Text using it will be reassigned to:"))
.size(11.0)
.color(crate::theme::Tokens::get(ui.ctx()).text_dim),
);
} else {
crate::rtl::label(
ui,
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Replace with:")).size(11.0).color(crate::theme::Tokens::get(ui.ctx()).text_dim),
);
}
ui.add_space(4.0);
if !d.fields.contains_key("replaceWith") {
d.fields.insert("replaceWith".into(), json!(designcraft_doc::NO_CHAR_STYLE));
}
let replace = d.s("replaceWith");
egui::ComboBox::from_id_salt("del_char_replace")
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &replace)))
.width(260.0)
.show_ui(ui, |ui| {
for n in [designcraft_doc::NO_CHAR_STYLE].into_iter().chain(names.iter().map(String::as_str)) {
if ui.selectable_label(n == replace, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() {
d.fields.insert("replaceWith".into(), json!(n));
}
}
});
}
fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) {
let cur = d.s(key);
let shown = opts.iter().find(|o| o.0 == cur).map_or(cur.as_str(), |o| o.1).to_string();
@@ -4320,6 +4432,86 @@ mod tests {
}
}
fn test_app() -> crate::DesignApp {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({"pages": 1})).unwrap();
app
}
#[test]
fn paragraph_style_options_dialog_saves_edits() {
let mut app = test_app();
let name = app.session.execute("style.paragraph.create", &json!({"name": "Body Copy"})).unwrap()["name"].as_str().unwrap().to_string();
app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": name, "p.align": "center"})));
confirm(&mut app).expect("OK saves the style edit");
let st = app.session.active().unwrap();
assert_eq!(st.doc.styles.para(&name).unwrap().para.align, Some(designcraft_doc::Align::Center));
}
#[test]
fn delete_paragraph_style_dialog_removes_the_style_and_reassigns_text() {
let mut app = test_app();
let name = app.session.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string();
let sid = app.session.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap()["story"].as_u64().unwrap();
app.session.execute("text.insert", &json!({"text": "Hello"})).unwrap();
app.session.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
app.session.execute("style.paragraph.apply", &json!({"name": name})).unwrap();
app.ui.dialog = Some(Dialog::new("deleteParagraphStyle", json!({"name": name, "replaceWith": designcraft_doc::BASIC_PARAGRAPH})));
confirm(&mut app).expect("OK deletes the style");
let st = app.session.active().unwrap();
assert!(st.doc.styles.para(&name).is_none(), "style removed");
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).unwrap();
assert_eq!(story.paras[0].style, designcraft_doc::BASIC_PARAGRAPH, "text reassigned to the replacement style");
}
#[test]
fn delete_paragraph_style_dialog_defaults_replacement_to_basic_paragraph() {
let mut app = test_app();
let name = app.session.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string();
// No `replaceWith` set — the dialog's default field.
app.ui.dialog = Some(Dialog::new("deleteParagraphStyle", json!({"name": name})));
confirm(&mut app).expect("OK deletes the style with the default replacement");
let st = app.session.active().unwrap();
assert!(st.doc.styles.para(&name).is_none());
}
#[test]
fn character_style_options_dialog_saves_edits() {
let mut app = test_app();
let name = app.session.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string();
app.ui.dialog = Some(Dialog::new("characterStyleOptions", json!({"name": name, "c.tracking": 20.0})));
confirm(&mut app).expect("OK saves the style edit");
let st = app.session.active().unwrap();
assert_eq!(st.doc.styles.char_style(&name).unwrap().chars.tracking, Some(20.0));
}
#[test]
fn delete_character_style_dialog_removes_the_style_and_reassigns_text() {
let mut app = test_app();
let name = app.session.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string();
let sid = app.session.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap()["story"].as_u64().unwrap();
app.session.execute("text.insert", &json!({"text": "Hello"})).unwrap();
app.session.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
app.session.execute("style.character.apply", &json!({"name": name})).unwrap();
app.ui.dialog = Some(Dialog::new("deleteCharacterStyle", json!({"name": name, "replaceWith": designcraft_doc::NO_CHAR_STYLE})));
confirm(&mut app).expect("OK deletes the style");
let st = app.session.active().unwrap();
assert!(st.doc.styles.char_style(&name).is_none(), "style removed");
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).unwrap();
assert_eq!(story.chars[0].format.style, designcraft_doc::NO_CHAR_STYLE, "text reassigned to the replacement style");
}
#[test]
fn delete_character_style_dialog_defaults_replacement_to_none() {
let mut app = test_app();
let name = app.session.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string();
// No `replaceWith` set — the dialog's default field.
app.ui.dialog = Some(Dialog::new("deleteCharacterStyle", json!({"name": name})));
confirm(&mut app).expect("OK deletes the style with the default replacement");
let st = app.session.active().unwrap();
assert!(st.doc.styles.char_style(&name).is_none());
}
#[test]
fn pdf_export_defaults() {
let d = Dialog::new("pdfExport", json!({}));
+14
View File
@@ -148,5 +148,19 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren
ui.data_mut(|d| d.insert_temp(id, name));
});
}
if para && !n.starts_with('[') {
ui.separator();
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Paragraph Style…"))).clicked() {
app.ui.dialog = Some(crate::dialogs::Dialog::new("deleteParagraphStyle", json!({"name": n})));
ui.close();
}
}
if !para && !n.starts_with('[') {
ui.separator();
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Character Style…"))).clicked() {
app.ui.dialog = Some(crate::dialogs::Dialog::new("deleteCharacterStyle", json!({"name": n})));
ui.close();
}
}
});
}