mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 22:35:26 +00:00
* Command API: refuse a page index past the end, and say how many pages went layout.pages.delete answered ok for an index past the last page and deleted nothing for it, so a caller off by one removed a different range and read a success; on a 336-page book that is page 337 instead of 336. Both commands now check every index against the document and refuse it with the sentence the MCP export commands have used since #70, return how many pages they touched so a no-op and a success are told apart, and state in their params that the indices count from 0. * Pages: count the pages a delete will remove, not the indices it was given delete_pages refused [0, 0, 1, 2] on a four-page document as if it would leave none, because the guard measured the length of the list instead of the pages it names. Same family as the index check next to it: the count was of the wrong thing. A test in the engine pins it. * Pages: refuse a malformed page list instead of dropping what it cannot read Review finding: page_indices used filter_map, so a list holding a string, a fraction or nothing at all was silently shortened — the caller was told a page was handled when it never was, which is the same mistake the command now refuses one level down. Every entry must be a whole number, and pages must be a list. Tests cover all three shapes, and the new test functions carry docstrings so the coverage check has nothing to report.
961 lines
48 KiB
Rust
961 lines
48 KiB
Rust
use super::layers::set_layer;
|
||
use super::margins::{adjust_layout, column_guides_unlocked, margin_boxes, move_column_guide, set_margins_columns, set_page_width};
|
||
use super::pages::{document_setup, page_options, page_size};
|
||
use super::parents::{
|
||
parent_label_taken, parent_name_param, parent_options, parent_page_size, parent_page_target, parent_prefix_param, parent_ref, parent_spread_index,
|
||
};
|
||
use super::sections::{ChapterEdit, SectionEdit, chapter_numbering, start_number};
|
||
use super::{CommandSpec, LAYER_COLORS, Layer, LayerId, Margins, Result, SpreadId, Value, bad, cmd, has_doc, json, ok, str_param};
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
"layout.pages.insert",
|
||
"Insert Pages…",
|
||
["Layout", "Pages"],
|
||
Some("Cmd+Shift+P"),
|
||
"{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page; past it: at the end), before?: 0-based page index (instead of `after`; 0: at the start), parent?: id|\"A-Parent\"|\"A\"|null}",
|
||
has_doc,
|
||
|s, p| {
|
||
const ID: &str = "layout.pages.insert";
|
||
// A count that is given but is not a number of pages used to insert one page:
|
||
// `0`, `-1` and `1.5` all came out as 1, and 10000 was quietly capped.
|
||
let count = match p.get("count").filter(|v| !v.is_null()) {
|
||
None => 1,
|
||
Some(v) => match v.as_u64().filter(|n| (1..=9999).contains(n)).map(|n| n as usize) {
|
||
Some(n) => n,
|
||
None => return Err(bad(ID, format!("count {v}: 1 to 9999 pages"))),
|
||
},
|
||
};
|
||
let st = s.doc()?;
|
||
let n = st.doc.page_count();
|
||
// A placement given (null included) must be a page index; only when neither is
|
||
// given do the pages go after the last.
|
||
if p.get("after").is_some() && p.get("before").is_some() {
|
||
return Err(bad("layout.pages.insert", "give `after` or `before`, not both"));
|
||
}
|
||
// `before` names a page of the document; an `after` past the last page appends
|
||
// (`insert_pages_bounds.rs` pins that).
|
||
let index = |k: &str| match p.get(k) {
|
||
None => Ok(None),
|
||
Some(v) => v
|
||
.as_u64()
|
||
.map(|v| usize::try_from(v).unwrap_or(usize::MAX))
|
||
.filter(|&i| k == "after" || i < n)
|
||
.map(|i| Some(i.min(n.saturating_sub(1))))
|
||
.ok_or_else(|| bad(ID, format!("`{k}` must be the index of one of the {n} pages, not {v}"))),
|
||
};
|
||
// The page the new ones follow; none: they go first.
|
||
let after = match (index("after")?, index("before")?) {
|
||
(_, Some(b)) => b.checked_sub(1),
|
||
(a, None) => Some(a.unwrap_or(n.saturating_sub(1))),
|
||
};
|
||
let parent = parent_ref(ID, &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
|
||
s.edit(|d, _| {
|
||
let ids = d.insert_pages(after, count, parent)?;
|
||
Ok(json!({"pages": ids.len(), "total": d.page_count()}))
|
||
})
|
||
}
|
||
),
|
||
cmd!(
|
||
"layout.rotateSpreadView",
|
||
"Rotate Spread",
|
||
[],
|
||
None,
|
||
"{spread?: index (0), angle: 90 (clockwise) | -90 | 180 | 0 (clear)} — turn one spread on screen (output is unaffected)",
|
||
has_doc,
|
||
|s, p| {
|
||
let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||
let angle = match p.get("angle") {
|
||
None => 90,
|
||
Some(v) => v.as_i64().ok_or_else(|| bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees"))?,
|
||
};
|
||
if angle % 90 != 0 {
|
||
return Err(bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees"));
|
||
}
|
||
s.edit(|d, _| {
|
||
let sp = d.spreads.get_mut(si).ok_or_else(|| bad("layout.rotateSpreadView", "no such spread"))?;
|
||
let pg = std::sync::Arc::make_mut(sp).pages.first_mut().ok_or_else(|| bad("layout.rotateSpreadView", "empty spread"))?;
|
||
pg.view_rotation = if angle == 0 { 0 } else { ((pg.view_rotation as i64 + angle.div_euclid(90)).rem_euclid(4)) as u8 };
|
||
Ok(json!({"rotation": pg.view_rotation as u32 * 90}))
|
||
})
|
||
}
|
||
),
|
||
cmd!(
|
||
"layout.pages.delete",
|
||
"Delete Pages",
|
||
["Layout", "Pages"],
|
||
None,
|
||
"{pages: [0-based page indices]} → {deleted: how many pages were removed}",
|
||
has_doc,
|
||
|s, p| {
|
||
const ID: &str = "layout.pages.delete";
|
||
let pages = page_indices(ID, s.doc()?.doc.page_count(), p)?;
|
||
let deleted = distinct_pages(&pages);
|
||
s.edit(|d, sel| {
|
||
d.delete_pages(&pages)?;
|
||
sel.items.retain(|i| d.item(*i).is_some());
|
||
sel.text = sel.text.filter(|t| d.story(t.story).is_some());
|
||
Ok(json!({"deleted": deleted}))
|
||
})
|
||
}
|
||
),
|
||
cmd!("layout.pages.move", "Move Pages…", ["Layout", "Pages"], None, "{from, to}", has_doc, |s, p| {
|
||
let from = p.get("from").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.move", "missing from"))? as usize;
|
||
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.move", "missing to"))? as usize;
|
||
s.edit(|d, _| {
|
||
d.move_page(from, to)?;
|
||
ok()
|
||
})
|
||
}),
|
||
cmd!(
|
||
"layout.pages.duplicateSpread",
|
||
"Duplicate Spread",
|
||
["Layout", "Pages"],
|
||
None,
|
||
"{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there), to?: page index the copied pages move to in the same undo step (the Pages panel's Alt-drag)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items",
|
||
has_doc,
|
||
|s, p| {
|
||
let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||
let at_end = p.get("atEnd").and_then(Value::as_bool).unwrap_or(false);
|
||
let to = p.get("to").and_then(Value::as_u64).map(|t| t as usize);
|
||
s.edit(|d, _| {
|
||
let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?;
|
||
// The copies land at `to`..`to + n` of the document with them in it.
|
||
if to.is_some_and(|t| t > d.page_count()) {
|
||
return Err(bad("layout.pages.duplicateSpread", "no such page"));
|
||
}
|
||
let after = if at_end { d.page_count() - 1 } else { d.first_page_of_spread(si) + sp.pages.len() - 1 };
|
||
let parent = sp.pages.first().and_then(|p| p.parent);
|
||
d.insert_pages(Some(after), sp.pages.len(), parent)?;
|
||
// The copies are the pages they copy: size, margins, columns, guides, liquid rule.
|
||
for (k, src) in sp.pages.iter().enumerate() {
|
||
let Some((nsi, npi)) = d.page_loc(after + 1 + k) else { continue };
|
||
let nsp = std::sync::Arc::make_mut(&mut d.spreads[nsi]);
|
||
let pg = &mut nsp.pages[npi];
|
||
let dx = pg.x - src.x;
|
||
// A copy on the other side of the spine has its dragged columns mirrored.
|
||
let columns = pg.columns_from(src);
|
||
(pg.width, pg.height, pg.margins, pg.columns, pg.liquid) = (src.width, src.height, src.margins, columns, src.liquid);
|
||
pg.guides = src.guides.clone();
|
||
for g in pg.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) {
|
||
g.position += dx;
|
||
}
|
||
nsp.relayout();
|
||
}
|
||
// Copy items onto the new spread(s) at the same positions.
|
||
let src = d.clone();
|
||
let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect();
|
||
let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?;
|
||
crate::cmd::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?;
|
||
let first = after + 1;
|
||
let Some(to) = to else { return Ok(json!({"page": first})) };
|
||
// Moving a page shifts the ones between; order the moves so each copy keeps
|
||
// its place among the others.
|
||
let n = sp.pages.len();
|
||
if first < to {
|
||
for k in (0..n).rev() {
|
||
d.move_page(first + k, to + k)?;
|
||
}
|
||
} else {
|
||
for k in 0..n {
|
||
d.move_page(first + k, to + k)?;
|
||
}
|
||
}
|
||
Ok(json!({"page": to}))
|
||
})
|
||
}
|
||
),
|
||
cmd!(
|
||
"layout.pages.applyParent",
|
||
"Apply Parent to Pages…",
|
||
["Layout", "Pages"],
|
||
None,
|
||
"{pages: [0-based page indices], parent: id|\"A-Parent\"|\"A\"|null (null or \"[None]\" clears)} → {applied: how many pages took the parent}",
|
||
has_doc,
|
||
|s, p| {
|
||
const ID: &str = "layout.pages.applyParent";
|
||
let pages = page_indices(ID, s.doc()?.doc.page_count(), p)?;
|
||
let applied = distinct_pages(&pages);
|
||
let parent = parent_ref(ID, &s.doc()?.doc, p.get("parent"))?.unwrap_or(None);
|
||
s.edit(|d, _| {
|
||
d.apply_parent(&pages, parent)?;
|
||
Ok(json!({"applied": applied}))
|
||
})
|
||
}
|
||
),
|
||
cmd!(
|
||
"layout.parents.new",
|
||
"New Parent…",
|
||
["Layout", "Pages"],
|
||
None,
|
||
"{prefix?: 1–4 characters without `-` (default: the next free letter), name?: \"Parent\", basedOn?: id|\"A-Parent\"|\"A\"|null} → {id, prefix}",
|
||
has_doc,
|
||
|s, p| {
|
||
const ID: &str = "layout.parents.new";
|
||
let name = parent_name_param(ID, p)?.unwrap_or_else(|| "Parent".into());
|
||
let prefix = parent_prefix_param(ID, p)?;
|
||
let base = parent_ref(ID, &s.doc()?.doc, p.get("basedOn"))?.flatten();
|
||
s.edit(|d, _| {
|
||
let prefix = prefix.unwrap_or_else(|| d.next_parent_prefix());
|
||
let label = designcraft_doc::ParentInfo { prefix: prefix.clone(), name: name.clone(), based_on: None }.label();
|
||
if parent_label_taken(d, &label, None) {
|
||
return Err(bad(ID, format!("a parent named {label} exists")));
|
||
}
|
||
let src = d.page(0).cloned();
|
||
let (columns, margins) = src
|
||
.as_ref()
|
||
.map_or_else(|| (designcraft_doc::Columns::default(), Margins::uniform(36.0)), |p| (p.columns.clone(), p.margins));
|
||
let id = d.add_parent(&prefix, &name, columns.count, columns.gutter, margins);
|
||
// Dragged column guides carry over to the parent pages they still fit, mirrored
|
||
// on the parent page across the spine from the first page.
|
||
if let (Some(src), Some(sp)) = (&src, d.parents.iter_mut().find(|x| x.id == id)) {
|
||
for pg in &mut std::sync::Arc::make_mut(sp).pages {
|
||
let columns = pg.columns_from(src);
|
||
if columns.valid_positions(pg.margin_rect().width()).is_some() {
|
||
pg.columns.positions = columns.positions;
|
||
}
|
||
}
|
||
}
|
||
if let Some(info) = d.parents.iter_mut().find(|x| x.id == id).map(std::sync::Arc::make_mut).and_then(|x| x.parent.as_mut()) {
|
||
info.based_on = base;
|
||
}
|
||
Ok(json!({"id": id.0, "prefix": prefix}))
|
||
})
|
||
}
|
||
),
|
||
cmd!(
|
||
"layout.parents.duplicate",
|
||
"Duplicate Parent Spread",
|
||
["Layout", "Pages"],
|
||
None,
|
||
"{parent: id|\"A-Parent\"|\"A\"} — a copy with the next free prefix, its pages and items → {id, prefix}",
|
||
has_doc,
|
||
|s, p| {
|
||
const ID: &str = "layout.parents.duplicate";
|
||
let Some(Some(id)) = parent_ref(ID, &s.doc()?.doc, p.get("parent"))? else {
|
||
return Err(bad(ID, "`parent` names the parent to duplicate"));
|
||
};
|
||
s.edit(|d, _| {
|
||
let prefix = d.next_parent_prefix();
|
||
let src = d.clone();
|
||
let ids: Vec<_> =
|
||
src.parent_index(id).and_then(|i| src.parents.get(i)).map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default();
|
||
let (new, at) = d.duplicate_parent_shell(id, &prefix)?;
|
||
crate::cmd::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Parent(at), designcraft_geom::Vec2::ZERO)?;
|
||
Ok(json!({"id": new.0, "prefix": prefix}))
|
||
})
|
||
}
|
||
),
|
||
cmd!(
|
||
"layout.parents.delete",
|
||
"Delete Parent",
|
||
[],
|
||
None,
|
||
"{id: parent spread id} — unapply the parent and keep local overrides as ordinary items",
|
||
has_doc,
|
||
|s, p| {
|
||
let id = p.get("id").and_then(Value::as_u64).map(SpreadId).ok_or_else(|| bad("layout.parents.delete", "missing parent id"))?;
|
||
s.edit(|d, sel| {
|
||
d.delete_parent(id)?;
|
||
*sel = Default::default();
|
||
ok()
|
||
})?;
|
||
let st = s.doc_mut()?;
|
||
if st.doc.parents.is_empty() {
|
||
st.editing_parents = false;
|
||
}
|
||
ok()
|
||
}
|
||
),
|
||
cmd!(
|
||
noundo "layout.parents.edit",
|
||
"Edit Parents",
|
||
[],
|
||
None,
|
||
"{on?: bool, parent?: id|\"A-Parent\"|\"A\"} — show parent spreads on the canvas; `parent` shows that parent (and implies on). Turning on without `parent` shows the first parent",
|
||
has_doc,
|
||
|s, p| {
|
||
const ID: &str = "layout.parents.edit";
|
||
let doc = s.doc()?.doc.clone();
|
||
let parent = match p.get("parent") {
|
||
None | Some(Value::Null) => None,
|
||
v => match parent_ref(ID, &doc, v)? {
|
||
Some(Some(id)) => Some(id),
|
||
_ => return Err(bad(ID, "`parent` names a parent to show")),
|
||
},
|
||
};
|
||
let st = s.doc_mut()?;
|
||
let was = st.editing_parents;
|
||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(parent.is_some() || !was);
|
||
st.editing_parents = on;
|
||
st.selection = Default::default();
|
||
st.bump_revision();
|
||
// The parent to show: the one asked for, or the first when editing starts.
|
||
let index = match parent {
|
||
Some(id) => doc.parent_index(id),
|
||
None if !was && !doc.parents.is_empty() => Some(0),
|
||
None => None,
|
||
};
|
||
let shown = index.filter(|_| on);
|
||
if let Some(i) = shown {
|
||
s.ui_requests.push(crate::UiRequest::View { params: json!({"showSpread": designcraft_doc::SpreadRef::Parent(i)}) });
|
||
}
|
||
Ok(json!({"on": on, "parent": shown.and_then(|i| doc.parents.get(i)).map(|x| x.id.0)}))
|
||
}
|
||
),
|
||
cmd!(
|
||
"layout.parents.options",
|
||
"Parent Options…",
|
||
[],
|
||
None,
|
||
"{parent: id|\"B-Parent\"|\"B\", prefix?: 1–4 characters without `-`, name?, basedOn?: id|\"A-Parent\"|\"A\"|null (= [None])} — rename the parent; with a base it shows the base's items under its own, and so do the pages that use it → {parent, prefix, name, basedOn}",
|
||
has_doc,
|
||
parent_options
|
||
),
|
||
cmd!(
|
||
"layout.marginsAndColumns",
|
||
"Margins and Columns…",
|
||
["Layout"],
|
||
None,
|
||
"{pages?: [index] (default all), margins?: number|{top,bottom,inside,outside}, columns?, gutter?, adjustLayout?: bool (objects follow the margins), parent? (a parent's prefix, label or index)} — document pages take these as their own margins; with `parent` the parent's pages change instead (`pages` then indexes them), and so do the document pages without their own",
|
||
has_doc,
|
||
|s, p| {
|
||
let pages: Option<Vec<usize>> =
|
||
p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect());
|
||
let parent = match p.get("parent") {
|
||
None | Some(Value::Null) => None,
|
||
Some(v) => Some(parent_spread_index(&s.doc()?.doc, v).ok_or_else(|| bad("layout.marginsAndColumns", "no such parent"))?),
|
||
};
|
||
let p = p.clone();
|
||
let adjust = p.get("adjustLayout").and_then(Value::as_bool).unwrap_or(false);
|
||
s.edit(|d, _| {
|
||
let before = adjust.then(|| margin_boxes(d));
|
||
if let Some(pi) = parent {
|
||
let n = d.parents.get(pi).map_or(0, |sp| sp.pages.len());
|
||
let sp = d.parents.get_mut(pi).map(std::sync::Arc::make_mut);
|
||
if let Some(sp) = sp {
|
||
for qi in pages.clone().unwrap_or_else(|| (0..n).collect()) {
|
||
if let Some(pg) = sp.pages.get_mut(qi) {
|
||
set_margins_columns(pg, &p);
|
||
}
|
||
}
|
||
}
|
||
d.sync_parent_margins();
|
||
} else {
|
||
let all: Vec<usize> = pages.clone().unwrap_or_else(|| (0..d.page_count()).collect());
|
||
for abs in all {
|
||
let Some((si, pi)) = d.page_loc(abs) else { continue };
|
||
let Some(pg) = d.spreads.get_mut(si).map(std::sync::Arc::make_mut).and_then(|sp| sp.pages.get_mut(pi)) else { continue };
|
||
set_margins_columns(pg, &p);
|
||
pg.local_margins = true;
|
||
}
|
||
}
|
||
if let Some(b) = before {
|
||
adjust_layout(d, &b);
|
||
}
|
||
ok()
|
||
})
|
||
}
|
||
),
|
||
cmd!(
|
||
"layout.moveColumnGuide",
|
||
"Move Column Guide",
|
||
[],
|
||
None,
|
||
"{spread?, parent? (a parent's prefix, label or index: its spread instead of `spread`), page (index in the spread), gutter (0 = between the first two columns), position (the gutter's new left edge, spread x)} → {positions (each column's left and right edge from the margin box's left edge), left (where the gutter's left edge went, spread x)} — both guides of the gutter move, keeping its width, and stop at the neighbouring guides and the margins; a document page takes the result as its own columns, a parent page passes it to the pages that follow it; refused while column guides are locked",
|
||
column_guides_unlocked,
|
||
move_column_guide
|
||
),
|
||
cmd!(
|
||
"layout.documentSetup",
|
||
"Document Setup…",
|
||
["File"],
|
||
Some("Cmd+Alt+P"),
|
||
"{width?, height?, pages?: count, startPage?: n, facingPages?, binding?: leftToRight|rightToLeft, intent?: print|web|mobile, margins?: n | {top, bottom, inside, outside} (every page and parent page), bleed?, slug?: n | [top, bottom, inside, outside], adjustLayout?: bool (objects follow the new page size and margins)} → the document setup (margins: the first page's)",
|
||
has_doc,
|
||
|s, p| {
|
||
let p = p.clone();
|
||
if p.as_object().is_none_or(|o| o.is_empty()) {
|
||
return Ok(document_setup(&s.doc()?.doc));
|
||
}
|
||
let edges = |k: &str| -> Option<[f64; 4]> {
|
||
match p.get(k)? {
|
||
Value::Array(a) if a.len() == 4 => {
|
||
let v: Vec<f64> = a.iter().filter_map(Value::as_f64).map(|v| v.max(0.0)).collect();
|
||
(v.len() == 4).then(|| [v[0], v[1], v[2], v[3]])
|
||
}
|
||
v => v.as_f64().map(|b| [b.max(0.0); 4]),
|
||
}
|
||
};
|
||
let (bleed, slug) = (edges("bleed"), edges("slug"));
|
||
for k in ["width", "height"] {
|
||
if let Some(v) = p.get(k)
|
||
&& !v.as_f64().is_some_and(|x| x > 0.0 && x <= 15552.0)
|
||
{
|
||
return Err(bad("layout.documentSetup", format!("{k}: page size out of range (0 < size ≤ 216 in)")));
|
||
}
|
||
}
|
||
let margins: Option<Margins> = match p.get("margins") {
|
||
None => None,
|
||
Some(Value::Number(n)) => n.as_f64().map(Margins::uniform),
|
||
Some(v) => serde_json::from_value(v.clone()).ok(),
|
||
};
|
||
if margins.is_some_and(|m| ![m.top, m.bottom, m.inside, m.outside].iter().all(|v| v.is_finite() && (0.0..=15552.0).contains(v))) {
|
||
return Err(bad("layout.documentSetup", "margins: 0 to 216 in"));
|
||
}
|
||
if p.get("margins").is_some() && margins.is_none() {
|
||
return Err(bad("layout.documentSetup", "margins: a number or {top, bottom, inside, outside}"));
|
||
}
|
||
let intent: Option<designcraft_doc::Intent> = p.get("intent").and_then(|v| serde_json::from_value(v.clone()).ok());
|
||
let pages = p.get("pages").and_then(Value::as_u64).map(|n| n.clamp(1, 9999) as usize);
|
||
let adjust = p.get("adjustLayout").and_then(Value::as_bool).unwrap_or(false);
|
||
s.edit(|d, _| {
|
||
let before = adjust.then(|| margin_boxes(d));
|
||
if let Some(f) = p.get("facingPages").and_then(Value::as_bool) {
|
||
d.settings.facing_pages = f;
|
||
}
|
||
if let Some(b) = p.get("binding").and_then(Value::as_str) {
|
||
d.settings.right_to_left_binding = b == "rightToLeft";
|
||
d.order_parent_pages();
|
||
}
|
||
if let Some(b) = bleed {
|
||
d.settings.bleed = b;
|
||
}
|
||
if let Some(b) = slug {
|
||
d.settings.slug = b;
|
||
}
|
||
if let Some(i) = intent {
|
||
d.settings.intent = i;
|
||
}
|
||
if let Some(n) = p.get("startPage").map(|v| start_number("layout.documentSetup", "startPage", v)).transpose()? {
|
||
if !d.sections.iter().any(|x| x.start == 0) {
|
||
d.sections.insert(
|
||
0,
|
||
designcraft_doc::Section {
|
||
start: 0,
|
||
start_number: None,
|
||
style: Default::default(),
|
||
prefix: String::new(),
|
||
marker: String::new(),
|
||
include_prefix: false,
|
||
layout: None,
|
||
},
|
||
);
|
||
}
|
||
if let Some(first) = d.sections.iter_mut().find(|x| x.start == 0) {
|
||
first.start_number = Some(n);
|
||
}
|
||
}
|
||
// Number of Pages: add at the end (with the last page's parent) or remove from the end.
|
||
if let Some(n) = pages {
|
||
let have = d.page_count();
|
||
if n > have {
|
||
let parent = d.page_loc(have - 1).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
|
||
d.insert_pages(Some(have - 1), n - have, parent)?;
|
||
} else if n < have {
|
||
d.delete_pages(&(n..have).collect::<Vec<_>>())?;
|
||
}
|
||
}
|
||
let w = p.get("width").and_then(Value::as_f64);
|
||
let h = p.get("height").and_then(Value::as_f64);
|
||
// Pages with a liquid rule re-flow their objects (Adjust Layout aside).
|
||
let liquid_before: Vec<(usize, usize, designcraft_geom::Rect)> = if adjust {
|
||
vec![]
|
||
} else {
|
||
d.spreads
|
||
.iter()
|
||
.enumerate()
|
||
.flat_map(|(si, sp)| {
|
||
sp.pages.iter().enumerate().filter(|(_, pg)| !pg.liquid.is_off()).map(move |(pi, pg)| (si, pi, pg.bounds()))
|
||
})
|
||
.collect()
|
||
};
|
||
if w.is_some() || h.is_some() {
|
||
d.settings.page_width = w.unwrap_or(d.settings.page_width);
|
||
d.settings.page_height = h.unwrap_or(d.settings.page_height);
|
||
for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) {
|
||
let sp = std::sync::Arc::make_mut(sp);
|
||
for pg in &mut sp.pages {
|
||
set_page_width(pg, d.settings.page_width);
|
||
pg.height = d.settings.page_height;
|
||
}
|
||
sp.relayout();
|
||
}
|
||
}
|
||
if let Some(m) = margins {
|
||
for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) {
|
||
for pg in &mut std::sync::Arc::make_mut(sp).pages {
|
||
pg.margins = m;
|
||
}
|
||
}
|
||
// Pages that now match their parent page follow its later changes; local
|
||
// columns stay local.
|
||
d.unmark_matching_local_margins();
|
||
d.sync_parent_margins();
|
||
}
|
||
d.repaginate();
|
||
if let Some(b) = before {
|
||
adjust_layout(d, &b);
|
||
}
|
||
for (si, pi, old) in liquid_before {
|
||
crate::cmd::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, old);
|
||
}
|
||
Ok(document_setup(d))
|
||
})
|
||
}
|
||
),
|
||
cmd!(
|
||
"layout.pages.toSpread",
|
||
"Add Page to Spread",
|
||
["Layout", "Pages"],
|
||
None,
|
||
"{page (1-based), spread (0-based)} — the page joins that spread (up to 10 pages; the spread stops shuffling)",
|
||
has_doc,
|
||
|s, p| {
|
||
let page =
|
||
p.get("page").and_then(Value::as_u64).filter(|n| *n >= 1).ok_or_else(|| bad("layout.pages.toSpread", "missing page"))? as usize;
|
||
let spread = p.get("spread").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.toSpread", "missing spread"))? as usize;
|
||
s.edit(|d, _| {
|
||
d.page_to_spread(page - 1, spread)?;
|
||
Ok(json!({"spreads": d.spreads.len()}))
|
||
})
|
||
}
|
||
),
|
||
cmd!(
|
||
"layout.spreadShuffle",
|
||
"Allow Selected Spread to Shuffle",
|
||
["Layout", "Pages"],
|
||
None,
|
||
"{spread (0-based), allow: bool} — off keeps the spread's pages together when pages are added or removed",
|
||
has_doc,
|
||
|s, p| {
|
||
let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||
let allow = p.get("allow").and_then(Value::as_bool).unwrap_or(true);
|
||
s.edit(|d, _| {
|
||
let sp = d.spreads.get_mut(si).ok_or_else(|| bad("layout.spreadShuffle", "no such spread"))?;
|
||
std::sync::Arc::make_mut(sp).allow_shuffle = allow;
|
||
if allow {
|
||
d.repaginate();
|
||
}
|
||
ok()
|
||
})
|
||
}
|
||
),
|
||
cmd!(
|
||
"layout.pageSize",
|
||
"Page Size",
|
||
[],
|
||
None,
|
||
"{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…, anchor?: 0–8 (reference point that stays put, row-major from the top-left; default 0)} — pages of their own size (Page tool); objects keep their place relative to the anchor, or follow the page's liquid rule",
|
||
has_doc,
|
||
page_size
|
||
),
|
||
cmd!(
|
||
"layout.parentPageSize",
|
||
"Parent Page Size",
|
||
[],
|
||
None,
|
||
"{parent: spread id, page: 0-based parent page index, width?, height?, anchor?: 0–8} — resize only this parent page, keeping document page sizes independent",
|
||
has_doc,
|
||
parent_page_size
|
||
),
|
||
cmd!(
|
||
noundo "layout.selectParentPage", "Select Parent Page", [], None,
|
||
"{parent: spread id|null, page?: 0-based parent page index} — select a parent page with the Page tool; null clears the selection",
|
||
has_doc, |s, p| {
|
||
let target = match p.get("parent") {
|
||
Some(Value::Null) => None,
|
||
_ => Some(parent_page_target(&s.doc()?.doc, p, "layout.selectParentPage")?),
|
||
};
|
||
let st = s.doc_mut()?;
|
||
st.selection = Default::default();
|
||
st.selection.parent_page = target.map(|(si, pi)| (st.doc.parents[si].id, pi));
|
||
st.bump_revision();
|
||
ok()
|
||
}
|
||
),
|
||
cmd!(
|
||
"layout.pageOptions",
|
||
"Page Options",
|
||
[],
|
||
None,
|
||
"{pages: [1-based], showParentOverlay?: bool} — Page tool options (Show Parent Page Overlay: the parent page's size drawn over the page)",
|
||
has_doc,
|
||
page_options
|
||
),
|
||
cmd!(
|
||
noundo "layout.selectPage",
|
||
"Select Page",
|
||
[],
|
||
None,
|
||
"{page: 1-based | null, ids?: [item ids] (objects selected with it: the Page tool on an object-based page)} — the page the Page tool selects (its handles, the Control panel)",
|
||
has_doc,
|
||
|s, p| {
|
||
let n = s.doc()?.doc.page_count();
|
||
let page = match p.get("page") {
|
||
None | Some(Value::Null) => None,
|
||
Some(v) => Some(v.as_u64().filter(|v| *v >= 1 && *v as usize <= n).ok_or_else(|| bad("layout.selectPage", format!("`page`: 1–{n}")))? as usize - 1),
|
||
};
|
||
let ids: Vec<designcraft_doc::ItemId> = {
|
||
let d = &s.doc()?.doc;
|
||
p.get("ids")
|
||
.and_then(Value::as_array)
|
||
.map(|a| a.iter().filter_map(Value::as_u64).map(designcraft_doc::ItemId).filter(|id| d.item(*id).is_some()).collect())
|
||
.unwrap_or_default()
|
||
};
|
||
let st = s.doc_mut()?;
|
||
if page.is_some() {
|
||
st.selection = designcraft_doc::Selection::items(ids);
|
||
}
|
||
st.selection.page = page;
|
||
st.selection.parent_page = None;
|
||
st.bump_revision();
|
||
Ok(json!({"page": page.map(|p| p + 1)}))
|
||
}
|
||
),
|
||
cmd!(
|
||
"layout.section",
|
||
"Numbering & Section Options…",
|
||
["Layout"],
|
||
None,
|
||
"{page (1-based; starts a section there), startNumber?: n (1–99999)|null (continue), style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|arabicLeadingZero|arabicThreeDigits|arabicFourDigits|kanji, prefix? (up to 8 characters; no + or comma), includePrefix?, marker?, remove?: bool}",
|
||
has_doc,
|
||
|s, p| {
|
||
let edit = SectionEdit::parse("layout.section", s, p)?;
|
||
s.edit(|d, _| {
|
||
edit.apply(d);
|
||
Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::<Vec<_>>()}))
|
||
})
|
||
}
|
||
),
|
||
cmd!(
|
||
"layout.chapterNumbering",
|
||
"Document Chapter Numbering",
|
||
[],
|
||
None,
|
||
"{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|arabicLeadingZero|arabicThreeDigits|arabicFourDigits, start?: n (Start Chapter Numbering at), source?: automatic|userDefined|sameAsPrevious} → {chapterNumber, label, style, source, book: the open book holding the document, or null}; no parameters reports the setting",
|
||
has_doc,
|
||
|s, p| {
|
||
if p.as_object().is_none_or(|o| o.is_empty()) {
|
||
return chapter_numbering(s);
|
||
}
|
||
let edit = ChapterEdit::parse("layout.chapterNumbering", p)?;
|
||
s.edit(|d, _| {
|
||
edit.apply(d);
|
||
ok()
|
||
})?;
|
||
chapter_numbering(s)
|
||
}
|
||
),
|
||
cmd!(
|
||
"layout.numberingOptions",
|
||
"Apply Numbering & Section Options",
|
||
[],
|
||
None,
|
||
"{page, …the settings as layout.section, chapter?: {…the settings as layout.chapterNumbering}} — the section and the document's chapter numbering in one undo step; if either is refused nothing changes",
|
||
has_doc,
|
||
|s, p| {
|
||
let section = SectionEdit::parse("layout.numberingOptions", s, p)?;
|
||
let chapter = match p.get("chapter") {
|
||
Some(c) => Some(ChapterEdit::parse("layout.numberingOptions", c)?),
|
||
None => None,
|
||
};
|
||
s.edit(|d, _| {
|
||
section.apply(d);
|
||
if let Some(chapter) = &chapter {
|
||
chapter.apply(d);
|
||
}
|
||
Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::<Vec<_>>(), "chapter": d.chapter_label()}))
|
||
})
|
||
}
|
||
),
|
||
cmd!("layer.move", "Move Layer", [], None, "{id, to: index (0 = top/frontmost)}", has_doc, |s, p| {
|
||
let id = LayerId(p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("layer.move", "missing id"))?);
|
||
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("layer.move", "missing to"))?;
|
||
s.edit(|d, _| {
|
||
let i = d.layers.iter().position(|l| l.id == id).ok_or_else(|| bad("layer.move", "no such layer"))?;
|
||
let to = usize::try_from(to).ok().filter(|t| *t < d.layers.len()).ok_or_else(|| bad("layer.move", "to out of range"))?;
|
||
let l = d.layers.remove(i);
|
||
d.layers.insert(to, l);
|
||
ok()
|
||
})
|
||
}),
|
||
cmd!(noundo "layer.selectItems", "Select All on Layer", [], None, "{id}", has_doc, |s, p| {
|
||
let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0));
|
||
let st = s.doc_mut()?;
|
||
let ids: Vec<_> = st.doc.spreads.iter().flat_map(|sp| sp.items.iter().filter(|i| i.layer == id && !i.locked).map(|i| i.id)).collect();
|
||
st.selection = designcraft_doc::Selection::items(ids);
|
||
st.bump_revision();
|
||
ok()
|
||
}),
|
||
cmd!("layer.new", "New Layer…", [], None, "{name?}", has_doc, |s, p| {
|
||
let name = str_param(p, "name").map(str::to_string);
|
||
let r = s.edit(|d, _| {
|
||
let id = LayerId(d.alloc());
|
||
let n = d.layers.len();
|
||
let name = name.clone().unwrap_or_else(|| format!("Layer {}", n + 1));
|
||
d.layers.insert(
|
||
0,
|
||
Layer {
|
||
id,
|
||
name,
|
||
color: LAYER_COLORS[n % LAYER_COLORS.len()].1,
|
||
visible: true,
|
||
locked: false,
|
||
printable: true,
|
||
show_guides: true,
|
||
suppress_wrap_when_hidden: false,
|
||
},
|
||
);
|
||
Ok(id)
|
||
})?;
|
||
s.doc_mut()?.active_layer = r;
|
||
Ok(json!({"id": r.0}))
|
||
}),
|
||
cmd!(
|
||
"layer.set",
|
||
"Layer Options…",
|
||
[],
|
||
None,
|
||
"{id, name?, color?: [r,g,b] | layer colour name (\"Light Blue\", \"Red\", …), visible?, locked?, printable?, showGuides?, suppressWrapWhenHidden?} — names are unique and not empty",
|
||
has_doc,
|
||
set_layer
|
||
),
|
||
cmd!("layer.delete", "Delete Layer", [], None, "{id}", has_doc, |s, p| {
|
||
let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0));
|
||
s.edit(|d, sel| {
|
||
if d.layers.len() < 2 {
|
||
return Err(bad("layer.delete", "a document needs at least one layer"));
|
||
}
|
||
let doomed: Vec<_> =
|
||
d.spreads.iter().chain(d.parents.iter()).flat_map(|sp| sp.items.iter().filter(|i| i.layer == id).map(|i| i.id)).collect();
|
||
for i in doomed {
|
||
let _ = d.remove_item(i);
|
||
}
|
||
d.layers.retain(|l| l.id != id);
|
||
*sel = Default::default();
|
||
ok()
|
||
})
|
||
}),
|
||
cmd!(
|
||
"layer.merge",
|
||
"Merge Layers",
|
||
[],
|
||
None,
|
||
"{ids: [layer ids], into?: layer id (default: the first)} — their objects move to `into`, the other layers are deleted",
|
||
has_doc,
|
||
|s, p| {
|
||
let ids: Vec<LayerId> =
|
||
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(LayerId).collect()).unwrap_or_default();
|
||
let into = p
|
||
.get("into")
|
||
.and_then(Value::as_u64)
|
||
.map(LayerId)
|
||
.or_else(|| ids.first().copied())
|
||
.ok_or_else(|| bad("layer.merge", "no layers"))?;
|
||
s.edit(|d, _| {
|
||
if d.layer(into).is_none() || ids.iter().any(|l| d.layer(*l).is_none()) {
|
||
return Err(bad("layer.merge", "no such layer"));
|
||
}
|
||
let gone: Vec<LayerId> = ids.iter().copied().filter(|l| *l != into).collect();
|
||
for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) {
|
||
if !sp.items.iter().any(|i| gone.contains(&i.layer)) {
|
||
continue;
|
||
}
|
||
for it in &mut std::sync::Arc::make_mut(sp).items {
|
||
if gone.contains(&it.layer) {
|
||
std::sync::Arc::make_mut(it).layer = into;
|
||
}
|
||
}
|
||
}
|
||
d.layers.retain(|l| !gone.contains(&l.id));
|
||
Ok(json!({"merged": gone.len()}))
|
||
})
|
||
}
|
||
),
|
||
cmd!("layer.deleteUnused", "Delete Unused Layers", [], None, "{} — layers without objects (one layer always stays)", has_doc, |s, _| {
|
||
s.edit(|d, _| {
|
||
let used: std::collections::HashSet<LayerId> =
|
||
d.spreads.iter().chain(d.parents.iter()).flat_map(|sp| sp.items.iter().map(|i| i.layer)).collect();
|
||
let before = d.layers.len();
|
||
if used.is_empty() {
|
||
// Nothing anywhere: keep the first layer.
|
||
d.layers.truncate(1);
|
||
} else {
|
||
d.layers.retain(|l| used.contains(&l.id));
|
||
}
|
||
Ok(json!({"deleted": before - d.layers.len()}))
|
||
})
|
||
}),
|
||
cmd!(
|
||
"layer.others",
|
||
"Hide/Lock Others",
|
||
[],
|
||
None,
|
||
"{id, hide?: bool, lock?: bool, show?: true (Show All Layers), unlock?: true (Unlock All Layers)} — Hide Others / Lock Others act on every layer but `id`",
|
||
has_doc,
|
||
|s, p| {
|
||
let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0));
|
||
let f = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||
let (hide, lock, show, unlock) = (f("hide"), f("lock"), f("show"), f("unlock"));
|
||
s.edit(|d, _| {
|
||
for l in &mut d.layers {
|
||
if show {
|
||
l.visible = true;
|
||
}
|
||
if unlock {
|
||
l.locked = false;
|
||
}
|
||
if l.id != id {
|
||
if hide {
|
||
l.visible = false;
|
||
}
|
||
if lock {
|
||
l.locked = true;
|
||
}
|
||
}
|
||
}
|
||
ok()
|
||
})
|
||
}
|
||
),
|
||
cmd!(noundo "layer.activate", "Set Active Layer", [], None, "{id}", has_doc, |s, p| {
|
||
let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0));
|
||
let st = s.doc_mut()?;
|
||
if st.doc.layer(id).is_none() {
|
||
return Err(bad("layer.activate", "no such layer"));
|
||
}
|
||
st.active_layer = id;
|
||
st.bump_revision();
|
||
ok()
|
||
}),
|
||
]
|
||
}
|
||
|
||
/// The page indices a `layout.pages.*` command was given, in the order it passed them.
|
||
///
|
||
/// Both commands count pages from 0. An index past the last page used to be dropped in
|
||
/// silence, so a caller that was off by one deleted a different range and still read
|
||
/// `ok`; the sentence is the one the MCP export commands have used since #70.
|
||
fn page_indices(id: &str, count: usize, p: &Value) -> Result<Vec<usize>> {
|
||
// A list that is missing, is not a list, or holds something that is not a whole number is
|
||
// refused rather than filtered: dropping an entry silently is the same mistake this fixes,
|
||
// one level up — the caller would be told a page was handled when it never was.
|
||
let raw = p.get("pages").and_then(Value::as_array).ok_or_else(|| bad(id, "pages takes a list of page indices"))?;
|
||
let mut out = Vec::with_capacity(raw.len());
|
||
for v in raw {
|
||
let n = v.as_u64().ok_or_else(|| bad(id, format!("pages takes whole numbers, and {v} is not one")))?;
|
||
let i = usize::try_from(n).unwrap_or(usize::MAX);
|
||
if i >= count {
|
||
return Err(bad(id, format!("no page {i} (the document has {count} pages, indices are 0-based)")));
|
||
}
|
||
out.push(i);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// How many pages a list of indices names, counting a repeated index once.
|
||
fn distinct_pages(pages: &[usize]) -> usize {
|
||
let mut v = pages.to_vec();
|
||
v.sort_unstable();
|
||
v.dedup();
|
||
v.len()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod page_index_tests {
|
||
use serde_json::json;
|
||
|
||
fn four_pages() -> crate::Session {
|
||
let mut s = crate::Session::new();
|
||
s.execute("file.new", &json!({"pages": 4})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn pages_left(s: &crate::Session) -> usize {
|
||
s.doc().unwrap().doc.page_count()
|
||
}
|
||
|
||
/// An index past the last page is an error that names it, and nothing is removed.
|
||
#[test]
|
||
fn a_page_index_past_the_end_is_refused_and_nothing_is_deleted() {
|
||
let mut s = four_pages();
|
||
let err = s.execute("layout.pages.delete", &json!({"pages": [4]})).unwrap_err().to_string();
|
||
assert!(err.contains("no page 4"), "the message names the index: {err}");
|
||
assert!(err.contains("indices are 0-based"), "and the base: {err}");
|
||
assert_eq!(pages_left(&s), 4, "nothing went");
|
||
}
|
||
|
||
/// The reply says how many pages went, and a page named twice is one page.
|
||
#[test]
|
||
fn deleting_says_how_many_pages_went() {
|
||
let mut s = four_pages();
|
||
let r = s.execute("layout.pages.delete", &json!({"pages": [0, 1]})).unwrap();
|
||
assert_eq!(r["deleted"], 2);
|
||
assert_eq!(pages_left(&s), 2);
|
||
// A page named twice is one page, not two.
|
||
let mut s = four_pages();
|
||
let r = s.execute("layout.pages.delete", &json!({"pages": [1, 1]})).unwrap();
|
||
assert_eq!(r["deleted"], 1);
|
||
assert_eq!(pages_left(&s), 3);
|
||
}
|
||
|
||
/// Three pages named of four leave one, which is allowed even with a repeat.
|
||
#[test]
|
||
fn a_page_named_twice_does_not_count_as_two_against_the_last_one_left() {
|
||
// Four pages, three named (one of them twice): that leaves one and is allowed.
|
||
let mut s = four_pages();
|
||
let r = s.execute("layout.pages.delete", &json!({"pages": [0, 0, 1, 2]})).unwrap();
|
||
assert_eq!(r["deleted"], 3);
|
||
assert_eq!(pages_left(&s), 1);
|
||
}
|
||
|
||
/// Applying a parent reports how many pages took it, and the pages really do.
|
||
#[test]
|
||
fn applying_a_parent_says_how_many_pages_took_it() {
|
||
let mut s = four_pages();
|
||
s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap();
|
||
let r = s.execute("layout.pages.applyParent", &json!({"pages": [1, 2], "parent": "B"})).unwrap();
|
||
assert_eq!(r["applied"], 2);
|
||
let doc = s.doc().unwrap();
|
||
for page in [1usize, 2] {
|
||
assert!(doc.doc.page(page).unwrap().parent.is_some(), "page {page} took the parent");
|
||
}
|
||
}
|
||
|
||
/// Refused before the edit starts, so no page changes parent.
|
||
#[test]
|
||
fn a_page_index_past_the_end_is_refused_by_a_parent() {
|
||
let mut s = four_pages();
|
||
// Pages are born on the default parent A, so what must not change is which parent they have.
|
||
let b = s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap();
|
||
let before: Vec<_> = (0..4).map(|i| s.doc().unwrap().doc.page(i).unwrap().parent.map(|p| p.0)).collect();
|
||
let err = s.execute("layout.pages.applyParent", &json!({"pages": [4], "parent": "B"})).unwrap_err().to_string();
|
||
assert!(err.contains("no page 4"), "{err}");
|
||
let after: Vec<_> = (0..4).map(|i| s.doc().unwrap().doc.page(i).unwrap().parent.map(|p| p.0)).collect();
|
||
assert_eq!(before, after, "no page changed parent");
|
||
assert!(!after.contains(&Some(b)), "and none took B");
|
||
}
|
||
|
||
/// A malformed list is refused instead of quietly dropping the entries it cannot read.
|
||
#[test]
|
||
fn a_malformed_page_list_is_refused_instead_of_ignored() {
|
||
let mut s = four_pages();
|
||
for list in [json!({"pages": "4"}), json!({"pages": [1, "2"]}), json!({})] {
|
||
let err = s.execute("layout.pages.delete", &list).unwrap_err().to_string();
|
||
assert!(err.contains("pages takes"), "refused: {list} → {err}");
|
||
}
|
||
assert_eq!(pages_left(&s), 4, "and nothing went");
|
||
}
|
||
|
||
/// Both commands' `params` doc states that the indices count from 0.
|
||
#[test]
|
||
fn both_commands_state_the_base_in_their_params() {
|
||
for id in ["layout.pages.delete", "layout.pages.applyParent"] {
|
||
let spec = crate::cmd::command_specs().iter().find(|s| s.id == id).unwrap();
|
||
assert!(spec.params.contains("0-based"), "{id} says its base: {}", spec.params);
|
||
}
|
||
}
|
||
}
|