feat(ui): Step and Repeat dialog with Create as a grid and Preview

Edit > Step and Repeat opened the generic command form (Count 1..1000, Dx, Dy, Rows, Columns, Ids). It now has InDesign's dialog: Repeat (Count, or Rows and Columns when Create as a grid is on), Offset (Vertical, Horizontal, in the document's ruler units or any unit typed) and Preview, which shows the copies on the page while the dialog is open. Cancel, Esc and ui.dialog.cancel take the preview away; OK makes the copies once, as one undo step. A value that isn't a number keeps the dialog open and says so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
moppeeerandClaude Opus 5.5 authored and paaspaas00 committed 2026-10-12 18:29:02 +02:00
1 parent d020b4e85d
commit 7ee66df165
10 files changed
+262 -7

No files matched your search

+1
View File
@@ -132,6 +132,7 @@ Newest first.
- **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change.
- **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change.
- **2026-10-12** · Edit › Step and Repeat has its InDesign dialog: Repeat (Count, or Rows and Columns with Create as a grid), Offset (Vertical, Horizontal, in ruler units) and Preview, which shows the copies while the dialog is open; Cancel takes them away and OK makes them as one undo step. No readiness percentage change.
- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change.
- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change.
- **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change.
+1 -2
View File
@@ -302,8 +302,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) ->
}
"ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)),
"ui.dialog.cancel" => {
app.cancel_pdf_import();
app.ui.dialog = None;
crate::dialogs::cancel(app);
ok(Value::Null)
}
"ui.resize" => {
+214 -4
View File
@@ -1656,6 +1656,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
ui.end_row();
});
}
STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d),
id if id.starts_with("cmd:") => command_form(app, ui, &mut d),
"goToPage" => {
ui.horizontal(|ui| {
@@ -1733,14 +1734,20 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
Some(true) => {
let _ = confirm(app);
}
Some(false) => {
app.ui.dialog = None;
app.cancel_pdf_import();
}
Some(false) => cancel(app),
None => {}
}
}
/// Close the open dialog without applying it (Cancel, Esc, the control channel): a previewing
/// dialog puts the document back as it was.
pub fn cancel(app: &mut DesignApp) {
if app.ui.dialog.take().is_some_and(|d| d.fields.contains_key(PREVIEWED)) {
app.session.cancel_interaction();
}
app.cancel_pdf_import();
}
/// Apply the open dialog.
pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) };
@@ -2167,6 +2174,20 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
"alert" => Ok(Value::Null),
"findChange" if d.b("objectMode") => Ok(Value::Null),
"findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})),
STEP_AND_REPEAT => {
// The preview goes; the copies are made once, as one undo step.
if d.fields.contains_key(PREVIEWED) {
app.session.cancel_interaction();
}
let r = step_and_repeat_params(app, &d).and_then(|p| app.run("edit.stepAndRepeat", p));
if let Err(e) = &r {
let mut d = d.clone();
d.fields.remove(PREVIEWED);
d.fields.insert("status".into(), json!(e));
app.ui.dialog = Some(d);
}
r
}
id if id.starts_with("cmd:") => {
let cid = &id[4..];
let doc = designcraft_engine::find_command(cid).map_or("", |c| c.params);
@@ -3558,6 +3579,120 @@ fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
}
const STEP_AND_REPEAT: &str = "cmd:edit.stepAndRepeat";
/// The params a previewing dialog shows on the page (its interaction is open while it is set).
const PREVIEWED: &str = "_previewed";
/// Edit › Step and Repeat, as in InDesign: Repeat (Count, or Rows and Columns with Create as a
/// grid), Offset (Vertical, Horizontal, in the document's ruler units) and Preview, which shows
/// the copies on the page while the dialog is open.
fn step_and_repeat_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
let (hu, vu) = app.session.active().map_or((Unit::Picas, Unit::Picas), |s| (s.doc.settings.horizontal_units, s.doc.settings.vertical_units));
for (k, v) in [
("count", json!("1")),
("rows", json!("1")),
("columns", json!("1")),
("vertical", json!(format_measure(12.0, vu))),
("horizontal", json!(format_measure(12.0, hu))),
] {
d.fields.entry(k).or_insert(v);
}
let lang = app.ui.language.clone();
let tr = |s: &str| crate::i18n::tr(&lang, s).to_string();
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
crate::rtl::label(ui, egui::RichText::new(tr("Repeat")).font(semibold(12.0)));
egui::Grid::new("sr_repeat").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
let grid = d.b("grid");
let fields: &[(&str, &str)] = if grid { &[("rows", "Rows:"), ("columns", "Columns:")] } else { &[("count", "Count:")] };
for (k, l) in fields {
crate::rtl::label(ui, tr(l));
text_field(ui, d, k, 70.0);
ui.end_row();
}
crate::rtl::label(ui, "");
check(ui, d, "grid", &tr("Create as a grid"));
ui.end_row();
});
ui.add_space(10.0);
crate::rtl::label(ui, egui::RichText::new(tr("Offset")).font(semibold(12.0)));
ui.horizontal(|ui| {
for (k, l) in [("vertical", "Vertical:"), ("horizontal", "Horizontal:")] {
crate::rtl::label(ui, tr(l));
text_field(ui, d, k, 80.0);
ui.add_space(12.0);
}
});
});
ui.add_space(32.0);
check(ui, d, "preview", &tr("Preview"));
});
step_and_repeat_preview(app, d);
if let Some(st) = d.fields.get("status").and_then(Value::as_str) {
ui.add_space(6.0);
crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
}
}
/// `edit.stepAndRepeat`'s params from the dialog: whole counts, offsets in the ruler units
/// (or any unit typed: "-0.75 in", "3p"). Empty fields are 1 copy and 1p0.
fn step_and_repeat_params(app: &DesignApp, d: &Dialog) -> Result<Value, String> {
let (hu, vu) = app.session.active().map_or((Unit::Picas, Unit::Picas), |s| (s.doc.settings.horizontal_units, s.doc.settings.vertical_units));
let invalid = |s: &str| format!("{}: {}", crate::i18n::tr(&app.ui.language, "Invalid value"), s.trim());
// Fields not shown yet (a dialog confirmed over the control channel) take the defaults.
let whole = |k: &str| {
let s = d.s(k);
if s.trim().is_empty() { Ok(1) } else { s.trim().parse::<u64>().map_err(|_| invalid(&s)) }
};
let measure = |k: &str, u: Unit| {
let s = d.s(k);
if s.trim().is_empty() {
return Ok(12.0);
}
parse_measure(&s, u).ok().filter(|v| v.is_finite()).ok_or_else(|| invalid(&s))
};
let (dx, dy) = (measure("horizontal", hu)?, measure("vertical", vu)?);
Ok(if d.b("grid") {
json!({"rows": whole("rows")?, "columns": whole("columns")?, "dx": dx, "dy": dy})
} else {
json!({"count": whole("count")?, "dx": dx, "dy": dy})
})
}
/// Preview on: the copies show on the page (redone when a field changes); off: they go.
fn step_and_repeat_preview(app: &mut DesignApp, d: &mut Dialog) {
let shown = d.fields.get(PREVIEWED).cloned();
if !d.b("preview") {
if shown.is_some() {
app.session.cancel_interaction();
d.fields.remove(PREVIEWED);
d.fields.remove("status");
}
return;
}
let params = match step_and_repeat_params(app, d) {
Ok(p) => p,
Err(e) => {
d.fields.insert("status".into(), json!(e));
return;
}
};
if shown.as_ref() == Some(&params) {
return;
}
if shown.is_none()
&& let Err(e) = app.session.begin_interaction("Step and Repeat")
{
d.fields.insert("status".into(), json!(e.to_string()));
return;
}
match app.session.preview("edit.stepAndRepeat", params.clone()) {
Ok(()) => d.fields.remove("status"),
Err(e) => d.fields.insert("status".into(), json!(e.to_string())),
};
d.fields.insert(PREVIEWED.into(), params);
}
/// New Cross-Reference: link to a paragraph (paragraph styles on the left, their paragraphs on
/// the right) or a text anchor, with a cross-reference format.
fn insert_xref(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
@@ -3744,6 +3879,81 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
mod tests {
use super::*;
#[test]
fn step_and_repeat_dialog_previews_and_makes_the_copies_as_one_undo_step() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let id = app.run("frame.create", json!({"rect": [72, 600, 540, 640]})).unwrap()["id"].as_u64().unwrap();
app.run("selection.set", json!({"ids": [id]})).unwrap();
let open = |app: &mut DesignApp| {
crate::menus::activate(app, "edit.stepAndRepeat", &Value::Null);
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(STEP_AND_REPEAT));
};
let set = |app: &mut DesignApp, k: &str, v: Value| {
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
};
let items = |app: &DesignApp| app.session.doc().unwrap().doc.spreads[0].items.len();
let undo = |app: &DesignApp| app.session.doc().unwrap().history.undo.len();
let (n0, u0) = (items(&app), undo(&app));
let ctx = dialog_context("en");
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0));
open(&mut app);
dialog_frame(&mut app, &ctx, screen, vec![]);
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
for expected in ["Step and Repeat", "Repeat", "Count:", "Create as a grid", "Offset", "Vertical:", "Horizontal:", "Preview", "OK", "Cancel"] {
visible_label(&labels, screen, expected);
}
// Preview: the copies show, and change with the fields, without an undo step.
set(&mut app, "count", json!("5"));
set(&mut app, "vertical", json!("-0.75 in"));
set(&mut app, "horizontal", json!("0"));
set(&mut app, "preview", json!(true));
dialog_frame(&mut app, &ctx, screen, vec![]);
assert_eq!((items(&app), undo(&app)), (n0 + 5, u0));
set(&mut app, "count", json!("3"));
dialog_frame(&mut app, &ctx, screen, vec![]);
assert_eq!(items(&app), n0 + 3);
set(&mut app, "preview", json!(false));
dialog_frame(&mut app, &ctx, screen, vec![]);
assert_eq!(items(&app), n0, "Preview off takes them away");
set(&mut app, "preview", json!(true));
dialog_frame(&mut app, &ctx, screen, vec![]);
assert_eq!(items(&app), n0 + 3);
// Cancel puts the document back.
cancel(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!((items(&app), undo(&app)), (n0, u0));
// OK after a preview makes the copies once, as one undo step, 0.75 in apart.
app.run("selection.set", json!({"ids": [id]})).unwrap();
open(&mut app);
set(&mut app, "count", json!("2"));
set(&mut app, "vertical", json!("-0.75 in"));
set(&mut app, "horizontal", json!("0"));
set(&mut app, "preview", json!(true));
dialog_frame(&mut app, &ctx, screen, vec![]);
let r = confirm(&mut app).unwrap();
assert_eq!((items(&app), undo(&app)), (n0 + 2, u0 + 1));
let last = r["ids"][1].as_u64().unwrap();
let top = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(last)).unwrap().bounds().min_y();
assert!((top - (600.0 - 108.0)).abs() < 1e-6, "{top}");
// Create as a grid: Rows × Columns cells, the original being one.
app.run("selection.set", json!({"ids": [id]})).unwrap();
open(&mut app);
set(&mut app, "grid", json!(true));
set(&mut app, "rows", json!("2"));
set(&mut app, "columns", json!("3"));
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
visible_label(&labels, screen, "Rows:");
visible_label(&labels, screen, "Columns:");
assert_eq!(confirm(&mut app).unwrap()["created"], 5);
// A value that isn't a measure keeps the dialog open and says so.
open(&mut app);
set(&mut app, "vertical", json!("up"));
assert!(confirm(&mut app).is_err());
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(d.s("status"), "Invalid value: up");
}
fn dialog_context(language: &str) -> egui::Context {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, language);
+8
View File
@@ -739,6 +739,14 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
("Apply Cell Style", ["Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "应用单元格样式"]),
("Apply Color", ["Apply Color", "Apply Color", "Apply Color", "Apply Color", "应用颜色"]),
("Repeat", ["Wiederholen", "Répéter", "Repetir", "繰り返し", "重复"]),
("Count:", ["Anzahl:", "Nombre :", "Recuento:", "繰り返し数:", "计数:"]),
("Create as a grid", ["Als Raster erstellen", "Créer en tant que grille", "Crear como cuadrícula", "グリッドとして作成", "创建为网格"]),
("Rows:", ["Zeilen:", "Rangées :", "Filas:", "行:", "行:"]),
("Columns:", ["Spalten:", "Colonnes :", "Columnas:", "列:", "列:"]),
("Vertical:", ["Vertikal:", "Vertical :", "Vertical:", "垂直方向:", "垂直:"]),
("Horizontal:", ["Horizontal:", "Horizontal :", "Horizontal:", "水平方向:", "水平:"]),
("Invalid value", ["Ungültiger Wert", "Valeur non valide", "Valor no válido", "無効な値", "无效值"]),
("Apply Condition", ["Apply Condition", "Apply Condition", "Apply Condition", "Apply Condition", "应用条件"]),
(
"Apply Parent to Pages…",
+8
View File
@@ -1041,6 +1041,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Formatting affects container (J)", "تطبيق التنسيق على الحاوية (J)"),
("Formatting affects text (J)", "تطبيق التنسيق على النص (J)"),
("Apply Color", "تطبيق اللون"),
("Repeat", "تكرار"),
("Count:", "العدد:"),
("Create as a grid", "إنشاء كشبكة"),
("Rows:", "الصفوف:"),
("Columns:", "الأعمدة:"),
("Vertical:", "رأسي:"),
("Horizontal:", "أفقي:"),
("Invalid value", "قيمة غير صالحة"),
("Apply None", "إزالة اللون"),
("Height Only", "الارتفاع فقط"),
("Width Only", "العرض فقط"),
+8
View File
@@ -412,6 +412,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Apply Attributes (Eyedropper)", "Applica attributi (Contagocce)"),
("Apply Cell Style", "Applica stile di cella"),
("Apply Color", "Applica colore"),
("Repeat", "Ripeti"),
("Count:", "Conteggio:"),
("Create as a grid", "Crea come griglia"),
("Rows:", "Righe:"),
("Columns:", "Colonne:"),
("Vertical:", "Verticale:"),
("Horizontal:", "Orizzontale:"),
("Invalid value", "Valore non valido"),
("Apply Condition", "Applica condizione"),
("Apply Parent to Pages…", "Applica pagina mastro alle pagine…"),
("ArtCraft App Page…", "Pagina dell'app ArtCraft…"),
+5
View File
@@ -414,6 +414,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Apply Attributes (Eyedropper)", "属性を適用 (スポイト)"),
("Apply Cell Style", "セルスタイルを適用"),
("Apply Color", "カラーを適用"),
("Repeat", "繰り返し"),
("Count:", "繰り返し数:"),
("Create as a grid", "グリッドとして作成"),
("Rows:", "行:"),
("Invalid value", "無効な値"),
("Apply Condition", "条件を適用"),
("Apply Parent to Pages…", "ページに親ページを適用…"),
("ArtCraft App Page…", "ArtCraft アプリページ…"),
+8
View File
@@ -1045,6 +1045,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Formatting affects container (J)", "Formatação afeta o contêiner (J)"),
("Formatting affects text (J)", "Formatação afeta o texto (J)"),
("Apply Color", "Aplicar cor"),
("Repeat", "Repetir"),
("Count:", "Contagem:"),
("Create as a grid", "Criar como grade"),
("Rows:", "Linhas:"),
("Columns:", "Colunas:"),
("Vertical:", "Vertical:"),
("Horizontal:", "Horizontal:"),
("Invalid value", "Valor inválido"),
("Apply None", "Aplicar nenhum"),
("Height Only", "Somente altura"),
("Width Only", "Somente largura"),
+8
View File
@@ -415,6 +415,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Apply Attributes (Eyedropper)", "Застосувати атрибути (піпетка)"),
("Apply Cell Style", "Застосувати стиль клітинки"),
("Apply Color", "Застосувати колір"),
("Repeat", "Повторення"),
("Count:", "Кількість:"),
("Create as a grid", "Створити як сітку"),
("Rows:", "Рядки:"),
("Columns:", "Стовпці:"),
("Vertical:", "Вертикально:"),
("Horizontal:", "Горизонтально:"),
("Invalid value", "Неприпустиме значення"),
("Apply Condition", "Застосувати умову"),
("Apply Parent to Pages…", "Застосувати шаблон до сторінок…"),
("ArtCraft App Page…", "Сторінка програми ArtCraft…"),
+1 -1
View File
@@ -276,7 +276,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands.
- Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline.
- EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`).
- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options.
- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options.
- Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands.
- Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named.
- Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands.