T: move table rows and columns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 04:02:20 -04:00
parent a4e76ed620
commit dbf1610aa6
5 changed files with 103 additions and 3 deletions

View File

@ -83,6 +83,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there).
- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF.
- Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet.
- Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; dragging on the canvas isn't there yet).
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.

View File

@ -691,6 +691,44 @@ impl Table {
}
}
/// Move row `from` to position `to` (indices before the move). Refused when merged cells span
/// rows (the move would cut them).
pub fn move_row(&mut self, from: usize, to: usize) -> Result<(), String> {
let (nr, nc) = (self.nrows(), self.ncols());
if from >= nr || to >= nr {
return Err("no such row".into());
}
if self.regions().iter().any(|r| r.2 > 1) {
return Err("unmerge cells that span rows first".into());
}
let row = self.rows.remove(from);
self.rows.insert(to, row);
let cells: Vec<Cell> = self.cells.drain(from * nc..(from + 1) * nc).collect();
let at = to * nc;
for (k, c) in cells.into_iter().enumerate() {
self.cells.insert(at + k, c);
}
Ok(())
}
/// Move column `from` to position `to`. Refused when merged cells span columns.
pub fn move_col(&mut self, from: usize, to: usize) -> Result<(), String> {
let (nr, nc) = (self.nrows(), self.ncols());
if from >= nc || to >= nc {
return Err("no such column".into());
}
if self.regions().iter().any(|r| r.3 > 1) {
return Err("unmerge cells that span columns first".into());
}
let col = self.columns.remove(from);
self.columns.insert(to, col);
for r in 0..nr {
let c = self.cells.remove(r * nc + from);
self.cells.insert(r * nc + to, c);
}
Ok(())
}
/// Mark the first `header` rows as header rows and the last `footer` rows as footer rows.
pub fn set_header_footer(&mut self, header: usize, footer: usize) {
let n = self.nrows();

View File

@ -112,6 +112,18 @@ fn table_specs() -> Vec<CommandSpec> {
cmd!("table.deleteColumn", "Delete Column", ["Table", "Delete"], None, "{} — the columns of the target cells", in_table, delete_cols),
cmd!("table.delete", "Delete Table", ["Table", "Delete"], None, "{}", in_table, delete_table),
cmd!("table.merge", "Merge Cells", ["Table"], None, "{} — merge the target cell range", in_table, merge),
cmd!(
"table.moveRow",
"Move Row",
[],
None,
"{from?, to} (0-based; from defaults to the target row) — like dragging a row",
in_table,
|s, p| move_rc(s, p, true)
),
cmd!("table.moveColumn", "Move Column", [], None, "{from?, to} (0-based; from defaults to the target column)", in_table, |s, p| move_rc(
s, p, false
)),
cmd!(
"table.sortRows",
"Sort",
@ -1090,6 +1102,17 @@ mod style_tests {
}
}
fn move_rc(s: &mut Session, p: &Value, rows: bool) -> Result<Value> {
const ID: &str = "table.move";
let g = target(s, p, ID)?;
let from = p.get("from").and_then(Value::as_u64).map_or(if rows { g.range.r0 } else { g.range.c0 }, |v| v as usize);
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(ID, "`to` required"))? as usize;
edit_table(s, &g, ID, |t| {
if rows { t.move_row(from, to) } else { t.move_col(from, to) }.map_err(|e| bad(ID, e))?;
Ok(json!({"from": from, "to": to}))
})
}
fn sort_rows(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.sortRows")?;
let col = p.get("column").and_then(Value::as_u64).map_or(g.range.c0, |c| c as usize);
@ -1158,4 +1181,25 @@ mod sort_tests {
let r = s.execute("table.splitHorizontally", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap();
assert_eq!((r["rows"].as_u64(), r["cols"].as_u64()), (Some(5), Some(3)));
}
#[test]
fn move_rows_and_columns() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 3, "cols": 2})).unwrap();
for (r, c, t) in [(0, 0, "a"), (1, 0, "b"), (2, 0, "c"), (0, 1, "x")] {
s.execute("table.setCell", &json!({"rows": [r, r], "cols": [c, c], "text": t})).unwrap();
}
let cell = |s: &Session, r: usize, c: usize| {
let d = &s.doc().unwrap().doc;
let t = d.stories.values().flat_map(|st| st.tables.values()).next().unwrap();
t.cell(r, c).unwrap().text.text.clone()
};
s.execute("table.moveRow", &json!({"from": 0, "to": 2})).unwrap();
assert_eq!([cell(&s, 0, 0), cell(&s, 1, 0), cell(&s, 2, 0)], ["b", "c", "a"]);
s.execute("table.moveColumn", &json!({"from": 1, "to": 0})).unwrap();
assert_eq!((cell(&s, 2, 0), cell(&s, 2, 1)), ("x".to_string(), "a".to_string()));
}
}

View File

@ -28,6 +28,23 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let run = |app: &mut DesignApp, id: &str, p: Value| {
let _ = app.run(id, p);
};
// Move the target row / column (InDesign drags them).
ui.horizontal(|ui| {
caption(ui, "Move");
let (nr, nc) = (rows.len(), cols.len());
for (label, id, from, to, ok) in [
("Row ↑", "table.moveRow", r0, r0.saturating_sub(1), r0 > 0),
("Row ↓", "table.moveRow", r0, r0 + 1, r0 + 1 < nr),
("Col ←", "table.moveColumn", c0, c0.saturating_sub(1), c0 > 0),
("Col →", "table.moveColumn", c0, c0 + 1, c0 + 1 < nc),
] {
if ui.add_enabled(ok, egui::Button::new(label).small()).clicked()
&& let Err(e) = app.run(id, json!({"from": from, "to": to}))
{
app.status(format!("Table: {e}"));
}
}
});
// Table and cell styles: pick to apply, + to save the current look as a new style.
if let Some(st) = app.session.active() {
let styles = &st.doc.styles;

View File

@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|---|---|---|---|---|---|
| P0 | 102 | 99 | 3 | 0 | 99% |
| P1 | 97 | 92 | 4 | 1 | 97% |
| P2 | 76 | 34 | 21 | 21 | 59% |
| P2 | 76 | 34 | 22 | 20 | 59% |
| Area | Parity |
|---|---|
@ -26,7 +26,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Type & text frames | 94% |
| Typography | 98% |
| Styles | 97% |
| Tables | 92% |
| Tables | 94% |
| Long documents | 96% |
| Interactivity & digital | 75% |
| Output & production | 80% |
@ -259,7 +259,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Tables | Tables spanning frames | P1 | D |
| Tables | Sort table rows | P2 | D |
| Tables | Excel import | P2 | D |
| Tables | Drag to move rows/columns | P2 | M |
| Tables | Drag to move rows/columns | P2 | P |
| Long documents | Table of contents | P1 | D |
| Long documents | Index | P2 | D |
| Long documents | Books | P2 | D |