mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 04:06:54 -06:00
Merge main into the explicit-visibility branch
This commit is contained in:
@@ -371,6 +371,8 @@ 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.
|
||||
- **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.
|
||||
|
||||
+107
-11
@@ -258,7 +258,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
{
|
||||
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<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<Value> {
|
||||
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<Value> {
|
||||
.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<Option<String>> = match p.get("basedOn") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
@@ -1247,10 +1268,13 @@ fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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)]
|
||||
|
||||
@@ -3888,9 +3888,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);
|
||||
}
|
||||
@@ -3902,11 +3902,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.
|
||||
@@ -8022,6 +8028,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());
|
||||
|
||||
+139
-1
@@ -3372,13 +3372,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<Item = (&'static str, &'static str, Option<&'static str>)> {
|
||||
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).
|
||||
@@ -5246,6 +5286,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());
|
||||
|
||||
Reference in New Issue
Block a user