From 6aaa4f0b753aa008f3ca18e7910083f79a58a1ad Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 04:37:44 +1100 Subject: [PATCH] Distribute selected table rows evenly and keep the edit undoable. The rows share one fixed height that preserves their composed total. Merged rows and rows that are not laid out are refused, and the table stays unchanged. --- ROADMAP.md | 1 + crates/engine/src/cmd/table.rs | 165 +++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 21 ++++ crates/ui-egui/src/panels/table.rs | 8 ++ 4 files changed, 195 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index aa4c17396..ef8841d99 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -93,6 +93,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - 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. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. - Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`). +- Distribute Rows Evenly (`table.distributeRows`, Table menu and panel): selected rows receive equal fixed heights preserving their composed total height, with undo/redo. Row-spanning merges and rows outside the composed frames are rejected; shrinking tall cells can overset their text. - Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF. - Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X). - Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there. diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index c2f925c09..ab9c04b49 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -203,6 +203,15 @@ fn table_specs() -> Vec { cmd!("table.setRowHeight", "Row Height", [], None, "{height, mode?: atLeast|exactly}", in_table, set_row_height), cmd!("table.setColumnWidth", "Column Width", [], None, "{width}", in_table, set_col_width), cmd!("table.distributeColumns", "Distribute Columns Evenly", ["Table"], None, "{}", in_table, distribute_cols), + cmd!( + "table.distributeRows", + "Distribute Rows Evenly", + ["Table"], + None, + "{story?, table?, rows?: [a,b]} — equal fixed heights preserving the selected rows' composed total height; may overset cell text; unmerge row-spanning cells first", + in_table, + distribute_rows + ), cmd!( "table.options", "Table Options", @@ -732,6 +741,63 @@ fn set_col_width(s: &mut Session, p: &Value) -> Result { }) } +fn distribute_rows(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.distributeRows"; + if !p.is_object() { + return Err(bad(ID, "parameters must be an object")); + } + if let Some(rows) = p.get("rows") { + let valid = rows.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(|v| v.as_u64().is_some_and(|n| usize::try_from(n).is_ok()))); + if !valid { + return Err(bad(ID, "rows must be two non-negative row indices")); + } + } + for key in ["row", "story", "table"] { + if let Some(v) = p.get(key) + && !v.as_u64().is_some_and(|n| usize::try_from(n).is_ok()) + { + return Err(bad(ID, format!("{key} must be a non-negative integer"))); + } + } + let g = target(s, p, ID)?; + let st = s.doc()?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; + if t.cells.iter().any(|c| c.row_span > 1) { + return Err(bad(ID, "unmerge cells that span rows first")); + } + let cs = s.cache.get(&st.doc, g.story, None); + let mut total = 0.0; + for row in g.range.r0..=g.range.r1 { + let height = cs + .frames + .iter() + .flat_map(|f| &f.tables) + .filter(|f| f.table == g.table) + .flat_map(|f| &f.cells) + .filter(|c| c.row == row) + .map(|c| c.rect.height()) + .reduce(f64::max) + .ok_or_else(|| bad(ID, "all selected rows must be laid out; enlarge or thread the text frame first"))?; + if !height.is_finite() || height <= 0.0 { + return Err(bad(ID, "invalid composed row height")); + } + total += height; + } + let count = g.range.r1 - g.range.r0 + 1; + let height = total / count as f64; + if !height.is_finite() { + return Err(bad(ID, "invalid total row height")); + } + edit_table(s, &g, ID, |t| { + let rows = t.rows.get_mut(g.range.r0..=g.range.r1).ok_or_else(|| bad(ID, "invalid row range"))?; + for row in rows { + row.height = height; + row.mode = RowHeightMode::Exactly; + } + Ok(json!({"rows": count, "height": height})) + }) +} + fn distribute_cols(s: &mut Session, p: &Value) -> Result { let g = target(s, p, "table.distributeColumns")?; edit_table(s, &g, "table.distributeColumns", |t| { @@ -1315,6 +1381,105 @@ mod sort_tests { use crate::Session; + fn distribution_session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "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, "headerRows": 1})).unwrap(); + for (row, height) in [(0, 25), (1, 40), (2, 80), (3, 30)] { + s.execute("table.setRowHeight", &json!({"row": row, "height": height, "mode": "exactly"})).unwrap(); + s.execute("table.setCell", &json!({"row": row, "col": 0, "text": format!("row {row}")})).unwrap(); + } + s + } + + fn table(s: &Session) -> designcraft_doc::Table { + (**s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap()).clone() + } + + #[test] + fn distribute_selected_rows_preserves_height_content_and_undo() { + let mut s = distribution_session(); + s.execute("table.select", &json!({"rows": [1, 2], "what": "row"})).unwrap(); + let before = table(&s); + let selection = s.doc().unwrap().selection.cells; + let result = s.execute("table.distributeRows", &json!({})).unwrap(); + assert_eq!(result, json!({"rows": 2, "height": 60.0})); + let after = table(&s); + assert_eq!(after.rows.iter().map(|r| r.height).collect::>(), [25.0, 60.0, 60.0, 30.0]); + assert_eq!(after.cells, before.cells); + assert_eq!(after.rows[0].kind, before.rows[0].kind); + assert_eq!(s.doc().unwrap().selection.cells, selection); + let st = s.doc().unwrap(); + let sid = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap().id; + let cs = s.cache.get(&st.doc, sid, None); + let heights: Vec<_> = + cs.frames.iter().flat_map(|f| &f.tables).flat_map(|f| &f.cells).filter(|c| c.col == 0).map(|c| c.rect.height()).collect(); + assert_eq!(heights, [25.0, 60.0, 60.0, 30.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table(&s), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(table(&s), after); + } + + #[test] + fn distribution_uses_content_grown_heights_and_explicit_table() { + let mut s = distribution_session(); + s.execute("table.setRowHeight", &json!({"rows": [1, 2], "height": 3, "mode": "atLeast"})).unwrap(); + s.execute("table.setCell", &json!({"row": 2, "col": 0, "text": "one\ntwo\nthree"})).unwrap(); + let st = s.doc().unwrap(); + let story = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap(); + let (sid, tid) = (story.id, *story.tables.keys().next().unwrap()); + let cs = s.cache.get(&st.doc, sid, None); + let total: f64 = cs + .frames + .iter() + .flat_map(|f| &f.tables) + .flat_map(|f| &f.cells) + .filter(|c| c.col == 0 && (1..=2).contains(&c.row)) + .map(|c| c.rect.height()) + .sum(); + let result = s.execute("table.distributeRows", &json!({"story": sid.0, "table": tid, "rows": [1, 2]})).unwrap(); + assert!((result["height"].as_f64().unwrap() * 2.0 - total).abs() < 1e-6); + assert!(table(&s).rows[1].height > 3.0); + assert_eq!(table(&s).rows[1].mode, designcraft_doc::RowHeightMode::Exactly); + } + + #[test] + fn distribution_keeps_column_spans_and_caret() { + let mut s = distribution_session(); + s.execute("table.merge", &json!({"rows": [1, 1], "cols": [0, 1]})).unwrap(); + let before = table(&s); + let caret = s.doc().unwrap().selection.text; + let result = s.execute("table.distributeRows", &json!({"rows": [2, 1]})).unwrap(); + assert_eq!(result["height"], 60.0); + assert_eq!(table(&s).cell(1, 0).unwrap().col_span, 2); + assert_eq!(table(&s).cells, before.cells); + assert_eq!(s.doc().unwrap().selection.text, caret); + } + + #[test] + fn distribution_rejects_merges_and_missing_rows_without_changes() { + let mut s = distribution_session(); + s.execute("table.merge", &json!({"rows": [1, 2], "cols": [0, 0]})).unwrap(); + let before = table(&s); + assert!(s.execute("table.distributeRows", &json!({"rows": [1, 2]})).is_err()); + assert_eq!(table(&s), before); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("table.setRowHeight", &json!({"rows": [0, 3], "height": 10000, "mode": "exactly"})).unwrap(); + let before = table(&s); + assert!(s.execute("table.distributeRows", &json!({"rows": [0, 3]})).is_err()); + assert_eq!(table(&s), before); + assert!(s.execute("table.distributeRows", &json!({"rows": [0, 99]})).is_err()); + for params in + [json!({"rows": "all"}), json!({"rows": [0]}), json!({"rows": [-1, 2]}), json!({"row": 1.5}), json!({"table": "bad"}), json!([])] + { + assert!(s.execute("table.distributeRows", ¶ms).is_err(), "{params}"); + assert_eq!(table(&s), before); + } + } + #[test] fn sort_body_rows_by_a_column() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index abff58c62..1d6466bdf 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -576,6 +576,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.splitHorizontally", "cmd:table.splitVertically", "cmd:table.distributeColumns", + "cmd:table.distributeRows", "-", "ui:app.tablePanel", ], @@ -2446,6 +2447,25 @@ mod tests { assert!(quick_apply_items(&s, "preferences").iter().any(|i| i.id == "app.preferences")); } + #[test] + fn distribute_rows_menu_dispatches_an_undoable_edit() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); + app.run("text.select", json!({"story": frame["story"], "anchor": 0, "focus": 0})).unwrap(); + app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap(); + app.run("table.setRowHeight", json!({"row": 0, "height": 40, "mode": "exactly"})).unwrap(); + app.run("table.setRowHeight", json!({"row": 1, "height": 80, "mode": "exactly"})).unwrap(); + app.run("table.selectTable", json!({})).unwrap(); + activate(&mut app, "table.distributeRows", &Value::Null); + assert!(app.ui.dialog.is_none()); + let info = app.run("table.get", json!({})).unwrap(); + assert_eq!(info["rows"][0]["height"], 60.0); + assert_eq!(info["rows"][1]["height"], 60.0); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.run("table.get", json!({})).unwrap()["rows"][0]["height"], 40.0); + } + #[test] fn every_menu_entry_is_a_command() { let mut all = Vec::new(); @@ -2453,6 +2473,7 @@ mod tests { walk(&items, &mut all); } assert!(all.len() > 80, "{}", all.len()); + assert!(all.iter().any(|(label, id, _)| label == "Distribute Rows Evenly" && id == "table.distributeRows")); for (label, id, params) in &all { assert!(ui_label(id).is_some() || designcraft_engine::find_command(id).is_some(), "menu entry {label}: unknown command {id}"); assert!(!label.is_empty() && label != id, "menu entry {id} has no label"); diff --git a/crates/ui-egui/src/panels/table.rs b/crates/ui-egui/src/panels/table.rs index 3f2d871ff..4d298cd1a 100644 --- a/crates/ui-egui/src/panels/table.rs +++ b/crates/ui-egui/src/panels/table.rs @@ -139,6 +139,14 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } ui.end_row(); }); + if ui + .button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Distribute Rows Evenly"))) + .on_hover_text("Equal fixed heights for the selected rows; cell text may become overset.") + .clicked() + && let Err(e) = app.run("table.distributeRows", json!({})) + { + app.status(format!("Table: {e}")); + } divider(ui); caption(ui, crate::i18n::tr(&app.ui.language, "Cell Text")); ui.horizontal(|ui| {