From a59880394d618ff69cd9f3fefdd8b7cf56d1619d Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:23:48 -0400 Subject: [PATCH 1/2] Search: Delete Workspace is left out of Quick Apply and command search (#1791) * Search: Delete Workspace is left out of Quick Apply and command search It needs a saved workspace's name, which a search entry can't supply, so choosing it there failed. The application bar's workspace menu lists the saved workspaces to delete. * Search: leave out the interface commands that fail or reset a setting there Quick Apply and the application bar's search ran their entries without parameters. Commands that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file, or a swatch or color group failed when chosen there. Interface Color Theme and UI Scaling switched to the dark theme and 100%, Workspace switched to Essentials, and Reset Shortcuts cleared every custom shortcut without asking. Search leaves all of them out; each stays in the menu, panel or dialog that gives it the value. * Search: the flattener preset, Screen Mode, Zoom To and the Task Bar pin stay out of Quick Apply --- ROADMAP.md | 1 + crates/ui-egui/src/menus.rs | 140 +++++++++++++++++++++++++++++++++++- 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4337c28d1..541a56e5d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change. - **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change. - **2026-10-18** · Repeated placed-PDF layer variants are prepared once per asset and hidden-name set, with recovery warnings retained. All-visible named layers keep their original source bytes, including PDFs whose cross-reference tables the reader repairs, in plain, PDF/X-4, booklet and book-merge output. Layer changes that require writing still reject an unusable cross-reference table with a file-named damage error; the first placement error is retained (#906). No readiness percentage change. - **2026-10-18** · Placed PDF optional-content defaults and per-frame overrides survive plain and PDF/X-4 vector export, including repeated placements, booklet output and book merging (#906). Source usage-application arrays are accepted; groups without a catalog configuration stay visible. Missing or unreadable default configurations and unsupported base states use all-ON defaults with a file-named export warning, retaining explicit frame overrides. Readable groups omitted from the catalog list are added with a distinct file-named warning, preserving their default ON/OFF states. Concurrent recovery warnings are combined. Malformed ON data is recoverable when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides still fail export. Print-usage adjustments and IDML layer overrides remain follow-ups. No readiness percentage change. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 347e046de..073edc238 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3369,13 +3369,53 @@ pub fn quick_apply_items(session: &designcraft_engine::Session, query: &str, cjk out } +/// UI commands search leaves out: they only run with a parameter search can't supply and have no +/// dialog to ask for it, or without one they reset a setting. Each stays in the menu, panel or +/// dialog that gives it the value (the workspace menus, Keyboard Shortcuts, Preferences…). +const NOT_SEARCHED: &[&str] = &[ + // The workspace to switch to or delete. + "window.workspace", + "window.deleteWorkspace", + // A command and its shortcut, a menu item, a shortcut set; Reset Shortcuts clears every + // custom shortcut, and the Keyboard Shortcuts dialog offers it. + "window.setShortcut", + "window.resetShortcuts", + "window.importShortcuts", + "window.hideMenuItem", + // Without a value these reset the interface to 100% and the dark theme. + "window.uiScale", + "window.brightness", + // A panel, and where it goes. + "window.floatPanel", + "window.dockPanel", + "window.panel.layout", + "window.panel.move", + "window.panel.float", + "window.panel.dock", + "window.panel.activate", + "window.panel.close", + "window.panel.closeGroup", + // A window edge, an app page, a recent file, a swatch or color group. + "window.resize", + "help.app", + "app.openRecent", + "app.swatchOptionsDialog", + "app.colorGroupOptionsDialog", + // Without a value these clear the flattener preset, go to Normal screen mode or 100% zoom. + "app.flattener", + "view.screenMode", + "view.zoom", + // Where to pin the Contextual Task Bar. + "window.taskBarPin", +]; + /// The commands Quick Apply and the application bar's search find: (id, label, default shortcut). fn quick_commands() -> impl Iterator)> { designcraft_engine::command_specs() .iter() .filter(|c| !c.menu.is_empty() || c.shortcut.is_some()) .map(|c| (c.id, c.label, c.shortcut)) - .chain(UI_COMMANDS.iter().map(|c| (c.0, c.1, c.2))) + .chain(UI_COMMANDS.iter().filter(|c| !NOT_SEARCHED.contains(&c.0)).map(|c| (c.0, c.1, c.2))) } /// Whether any of `texts` contains the lowercased query (an empty query matches everything). @@ -5243,6 +5283,104 @@ mod tests { assert!(quick_apply_items(&s, "preferences", false).iter().any(|i| i.id == "app.preferences")); } + #[test] + fn searched_ui_commands_run_without_parameters_and_keep_preferences() { + // Without a value these reset a setting or need a place the test host always has. + let searched: Vec<&str> = quick_commands().map(|c| c.0).collect(); + for id in ["app.flattener", "view.screenMode", "view.zoom", "window.taskBarPin"] { + assert!(!searched.contains(&id), "{id} is searched"); + } + // A placed PDF: a graphic with layers. + let mut scratch = designcraft_engine::Session::new(); + scratch.execute("file.new", &json!({})).unwrap(); + let pdf = scratch.execute("file.exportPdf", &json!({})).unwrap()["base64"].clone(); + let contexts = ["nothing selected", "text insertion point", "text frame selected", "table cell", "placed PDF selected"]; + // A two-page document, a saved workspace and non-default preferences, a host with window + // controls, and file dialogs that are dismissed. + let setup = |context: usize| { + let services = crate::Services { + pick_open: Some(Box::new(|_| None)), + pick_save: Some(Box::new(|_| None)), + clipboard_text: Some(Box::new(|| None)), + ..Default::default() + }; + let mut app = DesignApp::new(designcraft_engine::Session::new(), services); + app.custom_titlebar = true; + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 700.0))); + app.run("file.new", json!({"pages": 2})).unwrap(); + app.session.prefs.recent_files.push("/nowhere/recent.dcraft".into()); + app.ui.shortcuts.insert("view.rulers".into(), "Ctrl+Alt+R".into()); + app.ui.hidden_menu_items.push("view.guides".into()); + app.ui.ui_scale = 1.25; + app.ui.brightness = crate::theme::Brightness::Light; + app.ui.task_bar_at = Some([200.0, 200.0]); + run_ui(&mut app, "window.newWorkspace", &json!({"name": "Mine"})).unwrap().unwrap(); + match context { + 1 => { + app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": "One", "caret": true})).unwrap(); + } + 2 => { + let f = app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": "One"})).unwrap()["id"].clone(); + app.run("selection.set", json!({"ids": [f]})).unwrap(); + } + 3 => { + app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "caret": true})).unwrap(); + app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap(); + } + 4 => { + let f = app.run("frame.create", json!({"rect": [72, 72, 400, 400]})).unwrap()["id"].clone(); + app.run("file.place", json!({"base64": pdf, "name": "a.pdf", "frame": f})).unwrap(); + app.run("selection.set", json!({"ids": [f]})).unwrap(); + } + _ => {} + } + app.ui.dialog = None; + app + }; + let prefs = |app: &DesignApp| { + let u = &app.ui; + json!({ + "shortcuts": u.shortcuts, "hiddenMenuItems": u.hidden_menu_items, "uiScale": u.ui_scale, + "brightness": u.brightness, "workspace": u.workspace, "customWorkspaces": u.custom_workspaces, + "docking": u.docking, "dockingHidden": u.docking_hidden, "floating": u.floating, + }) + }; + let quick = quick_apply_items(&setup(0).session, "workspace", false); + assert!(quick.iter().any(|i| i.id == "window.newWorkspace")); + // Deleting needs a workspace name: the application bar's workspace menu lists them. + assert!(!quick.iter().any(|i| i.id == "window.deleteWorkspace")); + // Each command runs as Quick Apply runs an entry. One that needs a selection may fail + // without it, but runs in one of the contexts; none changes a preference it wasn't asked to. + // Opening a panel changes where the panels are, as it should. + let opens_a_panel = ["app.tabsPanel", "app.characterStylesPanel", "app.paragraphStylesPanel"]; + let arrangement = ["docking", "dockingHidden", "floating"]; + let mut failed = Vec::new(); + for (id, ..) in quick_commands().filter(|(id, ..)| UI_COMMANDS.iter().any(|c| c.0 == *id)) { + let mut errors = Vec::new(); + for (i, context) in contexts.iter().enumerate() { + let mut app = setup(i); + let before = prefs(&app); + match app.run(id, json!({})) { + Err(e) => errors.push(format!("{context}: {e}")), + Ok(_) => { + let after = prefs(&app); + let changed: Vec<&String> = + before.as_object().unwrap().iter().filter(|(k, v)| after.get(k.as_str()) != Some(*v)).map(|(k, _)| k).collect(); + let changed: Vec<&String> = + changed.into_iter().filter(|k| !(opens_a_panel.contains(&id) && arrangement.contains(&k.as_str()))).collect(); + if !changed.is_empty() { + failed.push(format!("{id} ({context}): changed {changed:?}")); + } + } + } + } + if errors.len() == contexts.len() { + failed.push(format!("{id}: {errors:?}")); + } + } + assert!(failed.is_empty(), "{failed:#?}"); + } + #[test] fn distribute_rows_menu_dispatches_an_undoable_edit() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From befc2e03462bde1588a28356c258267d3753c935 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:37:21 -0400 Subject: [PATCH 2/2] Styles: renaming a style keeps its Export Tagging (#1790) * Styles: renaming a style keeps its Export Tagging Renaming a paragraph or character style, or moving it into a style group, moves its Export Tagging to the new name. Deleting a style drops its tagging and leaves the replacement's own tagging unchanged. * Styles: a paragraph style can't be renamed onto another style's name * Styles: a refused rename says the name is taken and shows the General section Renaming a paragraph or character style onto a name another style has now says so ("a paragraph style named `Body` already exists"); empty or bracketed names keep the generic message. The Paragraph Style Options dialog switches to General, where the name field is, when the rename is refused. Paragraph style names are trimmed like character style names, and a renamed style never picks up an export tag left under its new name. * Styles: the rename test's stale tagging takes the Split Document field * ROADMAP: date the style rename line 2026-10-24 --- ROADMAP.md | 1 + crates/engine/src/cmd/style.rs | 118 ++++++++++++++++++++++++++++++--- crates/ui-egui/src/dialogs.rs | 36 ++++++++-- 3 files changed, 139 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 541a56e5d..963bc5960 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change. - **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change. - **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change. - **2026-10-18** · Repeated placed-PDF layer variants are prepared once per asset and hidden-name set, with recovery warnings retained. All-visible named layers keep their original source bytes, including PDFs whose cross-reference tables the reader repairs, in plain, PDF/X-4, booklet and book-merge output. Layer changes that require writing still reject an unusable cross-reference table with a file-named damage error; the first placement error is retained (#906). No readiness percentage change. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 9568d842e..5d440a5d4 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -258,7 +258,7 @@ pub fn specs() -> Vec { if !exists { return Err(bad("style.exportTag", format!("no style `{name}`"))); } - let key = format!("{}:{name}", if character { "c" } else { "p" }); + let key = export_tag_key(!character, &name); let (tag, class) = (str_param(p, "tag").map(str::to_string), str_param(p, "class").map(str::to_string)); let split = p.get("split").and_then(Value::as_bool); if character && split == Some(true) { @@ -941,7 +941,7 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string(); let chars: CharAttrs = attrs("style.paragraph.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; - let rename = str_param(p, "rename").map(str::to_string); + let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string); let based = p.get("basedOn").cloned(); s.edit(|d, _| { if let Some(Value::String(b)) = &based @@ -949,6 +949,14 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { { return Err(bad("style.paragraph.edit", "based-on would create a cycle")); } + if let Some(n) = &rename { + if n.is_empty() || n.starts_with('[') { + return Err(bad("style.paragraph.edit", format!("can't rename to `{n}`"))); + } + if d.styles.para(n).is_some() { + return Err(bad("style.paragraph.edit", format!("a paragraph style named `{n}` already exists"))); + } + } // 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))?; @@ -971,24 +979,35 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { }) } -/// Rename a paragraph or character style everywhere it's used. +/// Rename a paragraph or character style everywhere it's used; its Export Tagging follows. fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) { + let st = d.styles_mut(); if para { - for s in &mut d.styles_mut().paragraph { + for s in &mut st.paragraph { if s.name == from { s.name = to.to_string(); } } } else { - for s in &mut d.styles_mut().character { + for s in &mut st.character { if s.name == from { s.name = to.to_string(); } } } + // An entry already under `to` belongs to no style (names are unique), so it never survives. + st.export_tags.remove(&export_tag_key(para, to)); + if let Some(tag) = st.export_tags.remove(&export_tag_key(para, from)) { + st.export_tags.insert(export_tag_key(para, to), tag); + } redirect_style_refs(d, para, from, to); } +/// The `Styles::export_tags` key of a paragraph (`para`) or character style. +fn export_tag_key(para: bool, name: &str) -> String { + format!("{}:{name}", if para { "p" } else { "c" }) +} + /// Point every reference to style `from` at `to`, except the style's own registry entry (its /// `name` field) — shared by renaming (which also updates the entry's name) and deletion (which /// instead removes the entry, after its former uses point at the replacement). @@ -1175,8 +1194,10 @@ fn delete_style(s: &mut Session, p: &Value, para: bool) -> Result { for b in based.into_iter().filter(|b| b.as_deref() == Some(name.as_str())) { *b = parent.clone(); } + // The replacement keeps its own Export Tagging. + st.export_tags.remove(&export_tag_key(para, &name)); // Every other use: styles, stories, table cells, nested and GREP styles, notes, defaults. - rename_style(d, para, &name, &repl); + redirect_style_refs(d, para, &name, &repl); ok() }) } @@ -1230,7 +1251,7 @@ fn edit_char(s: &mut Session, p: &Value) -> Result { .and_then(Value::as_object) .map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()) .unwrap_or_default(); - let rename = str_param(p, "rename").filter(|n| *n != name).map(|n| n.trim().to_string()); + let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string); let based: Option> = match p.get("basedOn") { None => None, Some(Value::Null) => Some(None), @@ -1247,10 +1268,13 @@ fn edit_char(s: &mut Session, p: &Value) -> Result { return Err(bad("style.character.edit", "based-on would create a cycle")); } } - if let Some(n) = &rename - && (n.is_empty() || n.starts_with('[') || d.styles.char_style(n).is_some()) - { - return Err(bad("style.character.edit", format!("can't rename to `{n}`"))); + if let Some(n) = &rename { + if n.is_empty() || n.starts_with('[') { + return Err(bad("style.character.edit", format!("can't rename to `{n}`"))); + } + if d.styles.char_style(n).is_some() { + return Err(bad("style.character.edit", format!("a character style named `{n}` already exists"))); + } } let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", format!("no character style `{name}`")))?; st.chars.merge(&chars); @@ -2843,6 +2867,78 @@ mod export_tag_tests { assert!(text.contains("/S/H1") && text.contains("/S/P"), "structure elements"); assert!(designcraft_render::pdf_page_count(&pdf) == Some(1)); } + + #[test] + fn export_tagging_follows_a_renamed_style_and_goes_with_a_deleted_one() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap(); + s.execute("style.character.create", &json!({"name": "Term"})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Head", "tag": "h2", "class": "head"})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Body", "tag": "p", "class": "body"})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Term", "character": true, "tag": "em", "class": "term"})).unwrap(); + let tag = |s: &Session, name: &str, character: bool| { + s.doc().unwrap().doc.styles.export_tag(name, character).map(|t| (t.tag.clone(), t.class.clone())) + }; + let pair = |t: &str, c: &str| Some((t.to_string(), c.to_string())); + // A rename onto another style's name is refused, so neither tagging is lost. + assert!(s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": "Body"})).is_err()); + assert_eq!((tag(&s, "Head", false), tag(&s, "Body", false)), (pair("h2", "head"), pair("p", "body"))); + + s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": "Title"})).unwrap(); + s.execute("style.character.edit", &json!({"name": "Term", "rename": "Word"})).unwrap(); + assert_eq!(tag(&s, "Title", false), pair("h2", "head")); + assert_eq!(tag(&s, "Head", false), None); + assert_eq!(tag(&s, "Word", true), pair("em", "term")); + assert_eq!(tag(&s, "Term", true), None); + // Moving a style into a group renames it to Group/Name. + s.execute("style.group", &json!({"kind": "paragraph", "names": ["Title"], "group": "Heads"})).unwrap(); + assert_eq!(tag(&s, "Heads/Title", false), pair("h2", "head")); + assert_eq!(tag(&s, "Title", false), None); + + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(tag(&s, "Head", false), pair("h2", "head")); + assert_eq!(tag(&s, "Title", false), None); + assert_eq!(tag(&s, "Term", true), pair("em", "term")); + assert_eq!(tag(&s, "Word", true), None); + + // Deleting drops the deleted style's tagging; the replacement keeps its own. + s.execute("style.paragraph.delete", &json!({"name": "Head", "replaceWith": "Body"})).unwrap(); + assert_eq!(tag(&s, "Head", false), None); + assert_eq!(tag(&s, "Body", false), pair("p", "body")); + s.execute("style.character.delete", &json!({"name": "Term"})).unwrap(); + assert_eq!(tag(&s, "Term", true), None); + assert!(s.doc().unwrap().doc.styles.export_tags.keys().all(|k| k == "p:Body")); + } + + #[test] + fn a_rename_onto_a_taken_name_says_so_and_a_trimmed_rename_drops_a_stale_tag() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for (kind, a, b) in [("paragraph", "Head", "Body"), ("character", "Term", "Word")] { + s.execute(&format!("style.{kind}.create"), &json!({"name": a})).unwrap(); + s.execute(&format!("style.{kind}.create"), &json!({"name": b})).unwrap(); + let e = s.execute(&format!("style.{kind}.edit"), &json!({"name": a, "rename": b})).unwrap_err().to_string(); + assert!(e.contains(&format!("a {kind} style named `{b}` already exists")), "{e}"); + let e = s.execute(&format!("style.{kind}.edit"), &json!({"name": a, "rename": "[None]"})).unwrap_err().to_string(); + assert!(e.contains("can't rename to `[None]`"), "{e}"); + } + // An entry left under a name no style has doesn't attach to a style renamed onto it. + s.edit(|d, _| { + d.styles_mut() + .export_tags + .insert("p:Title".into(), designcraft_doc::ExportTag { tag: "h6".into(), class: "stale".into(), ..Default::default() }); + Ok(()) + }) + .unwrap(); + s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": " Title "})).unwrap(); + let st = &s.doc().unwrap().doc.styles; + assert!(st.para("Title").is_some(), "the new name is trimmed"); + assert_eq!(st.export_tag("Title", false), None); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9e44501f0..20a0c58c5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3876,9 +3876,9 @@ 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"); + let rename = d.s("rename").trim().to_string(); 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}); + 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); } @@ -3890,11 +3890,17 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul if !based.is_empty() { params["basedOn"] = if based == designcraft_doc::NO_PARA_STYLE { Value::Null } else { json!(based) }; } - if !rename.is_empty() && rename != name { + let renaming = !rename.is_empty() && rename != name; + if renaming { params["rename"] = json!(rename); } - let style = if !rename.is_empty() && rename != name { rename } else { name }; - (app.run("style.paragraph.edit", params)?, style) + let r = app.run("style.paragraph.edit", params).inspect_err(|_| { + // A refused name is shown next to the name field. + if renaming { + d.fields.insert("section".into(), json!("general")); + } + })?; + (r, if renaming { rename } else { name }) }; // 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. @@ -7987,6 +7993,26 @@ mod tests { draw(&mut app, &ctx); } + #[test] + fn paragraph_style_options_show_a_refused_rename_on_general_and_trim_a_new_name() { + let (mut app, _) = ruled_paragraph(); + let ctx = egui::Context::default(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled", "section": "rules"}))); + draw(&mut app, &ctx); + app.ui.dialog.as_mut().unwrap().fields.insert("rename".into(), json!("Ruled Child")); + let e = confirm(&mut app).unwrap_err(); + let d = app.ui.dialog.clone().expect("the dialog stays open"); + assert_eq!(d.s("section"), "general", "the name field is on General"); + assert!(d.s("status").contains("already exists"), "{e}"); + + app.ui.dialog.as_mut().unwrap().fields.insert("rename".into(), json!(" Fresh ")); + app.ui.dialog.as_mut().unwrap().fields.insert("x.tag".into(), json!("h3")); + confirm(&mut app).unwrap(); + let st = &app.session.doc().unwrap().doc.styles; + assert!(st.para("Fresh").is_some()); + assert_eq!(st.export_tag("Fresh", false).map(|t| t.tag.as_str()), Some("h3")); + } + /// A frame with two paragraphs of [Basic Paragraph]; the first has a local Word Spacing minimum. fn two_paragraphs() -> (DesignApp, Value) { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());