Merge #869: Pages panel: Alt-drag copies a page, drop a page on Create new page, Edit page size; duplicates keep their size

# Conflicts:
#	ROADMAP.md
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/src/panels/pages.rs
This commit is contained in:
aiongg
2026-10-14 01:37:07 +00:00
11 changed files with 203 additions and 24 deletions
+1
View File
@@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-14** · Pages panel: Alt-drag copies a page, a page dropped on Create new page is duplicated at the end, Edit page size offers the presets and Custom Page Size…; duplicated pages keep their own size, rule and guides; thumbnails of pages of their own size stay in their rows. No readiness percentage change.
- **2026-10-13** · Control-channel clicks and drags validate positive counts and the complete pending-event budget before queuing input (#732). Oversized requests leave pending gestures intact, preventing unbounded click-count allocation. No readiness percentage change.
- **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change.
- **2026-10-13** · Placed PDFs are drawn through pdfcraft's patched hayro crates: calculator functions whose `idiv` divides by zero or overflows, chains of stitching functions a hundred thousand deep, and mesh shadings with points far off the page, ten thousand overlapping triangles or forty thousand patches no longer panic, overflow the stack, run for over a minute or use more than 2 GiB of memory. A page with a `/UserUnit`, and its trim, bleed and art boxes, are sized in points. No readiness percentage change.
+38 -15
View File
@@ -152,21 +152,44 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
})
}),
cmd!("layout.pages.duplicateSpread", "Duplicate Spread", ["Layout", "Pages"], None, "{spread}", has_doc, |s, p| {
let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize;
s.edit(|d, _| {
let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?;
let after = 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)?;
// 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"))?;
super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?;
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)} → {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);
s.edit(|d, _| {
let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?;
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;
(pg.width, pg.height, pg.margins, pg.columns, pg.liquid) =
(src.width, src.height, src.margins, src.columns.clone(), 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"))?;
super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?;
Ok(json!({"page": after + 1}))
})
}
),
cmd!(
"layout.pages.applyParent",
"Apply Parent to Pages…",
+26
View File
@@ -1258,3 +1258,29 @@ fn alt_arrows_kern_at_the_caret_and_track_a_selection() {
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!((t.anchor, t.focus), (0, 5));
}
#[test]
fn duplicating_a_page_keeps_its_own_size_rule_guides_and_objects() {
// Chris Cicchino's Page Tool video (2:06): the letterhead page is duplicated, then resized.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 252, "height": 144, "facingPages": false})).unwrap();
s.execute("layout.pages.insert", &json!({"count": 1})).unwrap();
s.execute("layout.pageSize", &json!({"pages": [2], "width": 612, "height": 792})).unwrap();
s.execute("liquid.pageRule", &json!({"rule": "scale", "pages": [2]})).unwrap();
s.execute("guide.add", &json!({"spread": 1, "page": 0, "orientation": "vertical", "position": 300})).unwrap();
s.execute("frame.create", &json!({"spread": 1, "rect": [100, 100, 200, 200]})).unwrap();
let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 1})).unwrap();
assert_eq!(r["page"], 2);
let d = s.doc().unwrap().doc.clone();
let (si, pi) = d.page_loc(2).unwrap();
let pg = &d.spreads[si].pages[pi];
assert_eq!((pg.width, pg.height, pg.liquid), (612.0, 792.0, designcraft_doc::LiquidRule::Scale));
assert_eq!(pg.guides.len(), 1);
assert_eq!(d.spreads[si].items.len(), 1);
// At the end of the document (the New Page button): the business card, not the last page's size.
let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "atEnd": true})).unwrap();
assert_eq!(r["page"], 3);
let d = s.doc().unwrap().doc.clone();
let (si, pi) = d.page_loc(3).unwrap();
assert_eq!((d.spreads[si].pages[pi].width, d.spreads[si].pages[pi].height), (252.0, 144.0));
}
+16
View File
@@ -1125,6 +1125,22 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Edit Page", ["Edit Page", "Modifier la page", "Edit Page", "Edit Page", "编辑页面"]),
("Import File", ["Import File", "Importer un fichier", "Import File", "Import File", "导入文件"]),
("Insert Page", ["Insert Page", "Insérer une page", "Insert Page", "Insert Page", "插入页面"]),
(
"Delete selected pages",
["Ausgewählte Seiten löschen", "Supprimer les pages sélectionnées", "Eliminar páginas seleccionadas", "選択したページを削除", "删除选定页面"],
),
("Create new page", ["Neue Seite erstellen", "Créer une page", "Crear página nueva", "新規ページを作成", "新建页面"]),
("Edit page size", ["Seitenformat bearbeiten", "Modifier le format de page", "Editar tamaño de página", "ページサイズを編集", "编辑页面大小"]),
(
"Custom Page Size…",
[
"Benutzerdefiniertes Seitenformat …",
"Format de page personnalisé…",
"Tamaño de página personalizado…",
"カスタムページサイズ…",
"自定页面大小…",
],
),
("Hide Bar", ["Hide Bar", "Masquer la barre", "Hide Bar", "Hide Bar", "隐藏栏"]),
("Properties Panel", ["Properties Panel", "Panneau Propriétés", "Properties Panel", "Properties Panel", "属性面板"]),
("New Workspace…", ["New Workspace…", "Nouvel espace de travail…", "New Workspace…", "New Workspace…", "新建工作区…"]),
+4
View File
@@ -315,6 +315,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Quick Actions", "الإجراءات السريعة"),
("Import File", "استيراد ملف"),
("Insert Page", "إدراج صفحة"),
("Delete selected pages", "حذف الصفحات المحددة"),
("Create new page", "إنشاء صفحة جديدة"),
("Edit page size", "تحرير حجم الصفحة"),
("Custom Page Size…", "حجم صفحة مخصص…"),
("Edit Page", "تحرير الصفحة"),
("Adjust Layout", "ضبط التخطيط"),
("Smart Guides", "أدلة ذكية"),
+4
View File
@@ -414,6 +414,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Edit Page", "Modifica pagina"),
("Import File", "Importa file"),
("Insert Page", "Inserisci pagina"),
("Delete selected pages", "Elimina pagine selezionate"),
("Create new page", "Crea nuova pagina"),
("Edit page size", "Modifica dimensioni pagina"),
("Custom Page Size…", "Dimensioni pagina personalizzate…"),
("Hide Bar", "Nascondi barra"),
("Properties Panel", "Pannello Proprietà"),
("New Workspace…", "Nuova area di lavoro…"),
+4
View File
@@ -534,6 +534,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Edit Page", "ページを編集"),
("Import File", "ファイルを読み込み"),
("Insert Page", "ページを挿入"),
("Delete selected pages", "選択したページを削除"),
("Create new page", "新規ページを作成"),
("Edit page size", "ページサイズを編集"),
("Custom Page Size…", "カスタムページサイズ…"),
("Hide Bar", "バーを隠す"),
("Properties Panel", "プロパティパネル"),
("New Workspace…", "新規ワークスペース…"),
+4
View File
@@ -317,6 +317,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Quick Actions", "Ações rápidas"),
("Import File", "Importar arquivo"),
("Insert Page", "Inserir página"),
("Delete selected pages", "Excluir páginas selecionadas"),
("Create new page", "Criar nova página"),
("Edit page size", "Editar tamanho da página"),
("Custom Page Size…", "Tamanho de página personalizado…"),
("Edit Page", "Editar página"),
("Adjust Layout", "Ajustar layout"),
("Smart Guides", "Guias inteligentes"),
+4
View File
@@ -415,6 +415,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Edit Page", "Редагувати сторінку"),
("Import File", "Імпортувати файл"),
("Insert Page", "Вставити сторінку"),
("Delete selected pages", "Видалити вибрані сторінки"),
("Create new page", "Створити нову сторінку"),
("Edit page size", "Змінити розмір сторінки"),
("Custom Page Size…", "Власний розмір сторінки…"),
("Hide Bar", "Сховати панель"),
("Properties Panel", "Панель властивостей"),
("New Workspace…", "Нове робоче середовище…"),
+100 -7
View File
@@ -176,11 +176,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let cur_spread = doc.page_loc(cur).map(|l| l.0);
let center = ui.available_width() / 2.0;
let dragging = egui::DragAndDrop::has_any_payload(ui.ctx());
let tallest = doc.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.height)).fold(doc.settings.page_height, f64::max).max(1.0);
for (si, sp) in doc.spreads.iter().enumerate() {
let first = doc.first_page_of_spread(si);
let scale = THUMB_H / doc.settings.page_height.max(1.0) as f32;
// One scale for the whole document (pages of their own size show in proportion): the
// document's page size at the usual height, unless a taller page would pass 1.8 times it.
// Each row is as tall as its spread's tallest page.
let scale = (THUMB_H / doc.settings.page_height.max(1.0) as f32).min(1.8 * THUMB_H / tallest as f32);
let row_h = sp.pages.iter().map(|p| p.height as f32 * scale).fold(0.0, f32::max).max(THUMB_H * 0.25);
let spine = sp.spine_x() as f32;
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + THUMB_H + 22.0), Sense::hover());
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + row_h + 22.0), Sense::hover());
let top = row.min.y + 8.0;
// Marker above the current spread.
if cur_spread == Some(si) && !editing_parents {
@@ -262,15 +267,55 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
.color(t.text_dim),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete Page")).clicked() {
if icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete selected pages")).clicked() {
let _ = app.run("layout.pages.delete", json!({"pages": [cur]}));
}
if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "Insert Page")).clicked() {
// Create new page; a page dropped on it is duplicated at the end of the document.
let plus = icons::button(ui, "plus", 20.0, dragging, crate::i18n::tr(&app.ui.language, "Create new page"));
if let Some(payload) = plus.dnd_release_payload::<PageDrag>() {
if let PageDrag::Page(from) = *payload
&& let Some((si, _)) = doc.page_loc(from)
{
let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "atEnd": true}));
}
} else if plus.clicked() {
let _ = app.run("layout.pages.insert", json!({"after": cur, "count": 1}));
}
// Edit page size: InDesign's presets for the current page, or Custom Page Size….
let size = icons::button(ui, "panel-pages", 20.0, false, crate::i18n::tr(&app.ui.language, "Edit page size"));
egui::Popup::menu(&size).show(|ui| {
let Some((si, pi)) = doc.page_loc(cur) else { return };
let pg = &doc.spreads[si].pages[pi];
let landscape = pg.width > pg.height;
let current = crate::panels::properties::preset_name(pg.width, pg.height);
for (name, w, h) in crate::panels::properties::PAGE_PRESETS {
let (w, h) = if landscape { (h, w) } else { (w, h) };
if ui.selectable_label(current == *name, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, name))).clicked() {
let _ = app.run("layout.pageSize", json!({"pages": [cur + 1], "width": w, "height": h}));
ui.close();
}
}
ui.separator();
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Custom Page Size…"))).clicked() {
app.ui.dialog =
Some(crate::dialogs::Dialog::new("cmd:layout.pageSize", json!({"pages": [cur + 1], "width": pg.width, "height": pg.height})));
ui.close();
}
});
});
});
// Alt-drag (InDesign's Option-drag) copies the page to where it's dropped.
let copy = ui.input(|i| i.modifiers.alt);
match drop {
Some((PageDrag::Page(from), to)) if copy => {
if let Some((si, _)) = doc.page_loc(from)
&& let Ok(r) = app.run("layout.pages.duplicateSpread", json!({"spread": si}))
&& let Some(new) = r["page"].as_u64().map(|p| p as usize)
&& new != to
{
let _ = app.run("layout.pages.move", json!({"from": new, "to": to}));
}
}
Some((PageDrag::Page(from), to)) if from != to => {
let _ = app.run("layout.pages.move", json!({"from": from, "to": to}));
}
@@ -357,10 +402,13 @@ pub(crate) fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) {
#[cfg(test)]
mod tests {
use egui::{Event, Modifiers, PointerButton, vec2};
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::json;
use egui::{Event, Modifiers, PointerButton};
use egui_kittest::{
Harness,
kittest::{NodeT, Queryable},
};
use super::*;
use crate::test_window;
#[test]
@@ -399,4 +447,49 @@ mod tests {
let thumbs = super::THUMBS.lock().unwrap();
assert!(thumbs.as_ref().unwrap().keys().any(|k| (k.0, k.1) == rev && k.2 == super::ThumbOf::Parent(id)));
}
fn harness(app: DesignApp) -> Harness<'static, DesignApp> {
let mut h = Harness::builder().with_size(vec2(280.0, 520.0)).build_ui_state(
|ui, app: &mut DesignApp| {
let key = egui::Id::new("test_fonts");
if ui.data(|d| d.get_temp::<bool>(key)).is_none() {
crate::theme::install_fonts(ui.ctx(), "");
ui.data_mut(|d| d.insert_temp(key, true));
return;
}
show(app, ui);
},
app,
);
h.run_steps(3);
h
}
/// Release a dragged page over `at`.
fn drop_page(h: &mut Harness<'_, DesignApp>, page: usize, at: egui::Pos2, alt: bool) {
let modifiers = egui::Modifiers { alt, ..Default::default() };
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers });
egui::DragAndDrop::set_payload(&h.ctx, PageDrag::Page(page));
h.event(egui::Event::PointerMoved(at));
h.run_steps(2);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.run_steps(3);
}
#[test]
fn a_page_dropped_on_create_new_page_is_duplicated_at_the_end() {
// Chris Cicchino's Page Tool video (2:06): "click and drag onto my plus sign I can duplicate that".
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 252, "height": 144, "facingPages": false})).unwrap();
app.run("layout.pages.insert", json!({"count": 1})).unwrap();
app.run("layout.pageSize", json!({"pages": [2], "width": 612, "height": 792})).unwrap();
let mut h = harness(app);
let plus = h.get_by_label("Create new page").rect().center();
drop_page(&mut h, 1, plus, false);
let d = h.state().session.doc().unwrap().doc.clone();
assert_eq!(d.page_count(), 3);
let (si, pi) = d.page_loc(2).unwrap();
assert_eq!(d.spreads[si].pages[pi].width, 612.0, "a copy of the letterhead");
let _ = h.get_by_label("Edit page size");
}
}
+2 -2
View File
@@ -168,7 +168,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
// ---------------------------------------------------------------- No Selection
/// Page-size presets for the Document dropdown (portrait, points).
const PAGE_PRESETS: &[(&str, f64, f64)] = &[
pub(crate) const PAGE_PRESETS: &[(&str, f64, f64)] = &[
("Letter", 612.0, 792.0),
("Legal", 612.0, 1008.0),
("Tabloid", 792.0, 1224.0),
@@ -181,7 +181,7 @@ const PAGE_PRESETS: &[(&str, f64, f64)] = &[
("B5", 498.898, 708.661),
];
fn preset_name(w: f64, h: f64) -> &'static str {
pub(crate) fn preset_name(w: f64, h: f64) -> &'static str {
let (a, b) = (w.min(h), w.max(h));
PAGE_PRESETS.iter().find(|p| (p.1 - a).abs() < 0.6 && (p.2 - b).abs() < 0.6).map(|p| p.0).unwrap_or("Custom")
}