Distribute selected table rows evenly and keep the edit undoable. (#224)
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.
This commit is contained in:
parent
3d2bda4d6b
commit
67d4539df7
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -203,6 +203,15 @@ fn table_specs() -> Vec<CommandSpec> {
|
|||
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",
|
||||
|
|
@ -743,6 +752,63 @@ fn set_col_width(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
})
|
||||
}
|
||||
|
||||
fn distribute_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
let g = target(s, p, "table.distributeColumns")?;
|
||||
edit_table(s, &g, "table.distributeColumns", |t| {
|
||||
|
|
@ -1326,6 +1392,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::<Vec<_>>(), [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();
|
||||
|
|
|
|||
|
|
@ -576,6 +576,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
|||
"cmd:table.splitHorizontally",
|
||||
"cmd:table.splitVertically",
|
||||
"cmd:table.distributeColumns",
|
||||
"cmd:table.distributeRows",
|
||||
"-",
|
||||
"ui:app.tablePanel",
|
||||
],
|
||||
|
|
@ -2705,6 +2706,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();
|
||||
|
|
@ -2712,6 +2732,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");
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
Loading…
Reference in New Issue