mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 11:16:50 -06:00
Merge PR #1068: Table › Convert Rows › To Header, To Body and To Footer
crates/engine/src/cmd/table.rs: add the PR's three commands before the branch's select commands, which keep their target descriptions (#803). docs/gaps.md: keep the branch's text; Convert Rows leaves the list of missing menu items.
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
use designcraft_compose as compose;
|
||||
use designcraft_doc::story::floor_char_boundary;
|
||||
use designcraft_doc::{
|
||||
AltFills, CellAddr, CellRange, CellStroke, Document, RowHeightMode, Selection, StoryId, StrokeType, Table, TableSel, TextSel,
|
||||
AltFills, CellAddr, CellRange, CellStroke, Document, RowHeightMode, RowKind, Selection, StoryId, StrokeType, Table, TableSel, TextSel,
|
||||
VerticalJustification,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -364,6 +364,33 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
in_table,
|
||||
options
|
||||
),
|
||||
cmd!(
|
||||
"table.convertRowsToHeader",
|
||||
"To Header",
|
||||
["Table", "Convert Rows"],
|
||||
None,
|
||||
"{rows?: [a,b]} — the target rows, at the top of the table or right after its header rows",
|
||||
|s| can_convert_rows(s, RowKind::Header),
|
||||
|s, p| convert_rows(s, p, RowKind::Header)
|
||||
),
|
||||
cmd!(
|
||||
"table.convertRowsToBody",
|
||||
"To Body",
|
||||
["Table", "Convert Rows"],
|
||||
None,
|
||||
"{rows?: [a,b]} — the target header rows next to the body, footer rows next to it, or both",
|
||||
|s| can_convert_rows(s, RowKind::Body),
|
||||
|s, p| convert_rows(s, p, RowKind::Body)
|
||||
),
|
||||
cmd!(
|
||||
"table.convertRowsToFooter",
|
||||
"To Footer",
|
||||
["Table", "Convert Rows"],
|
||||
None,
|
||||
"{rows?: [a,b]} — the target rows, at the bottom of the table or right before its footer rows",
|
||||
|s| can_convert_rows(s, RowKind::Footer),
|
||||
|s, p| convert_rows(s, p, RowKind::Footer)
|
||||
),
|
||||
cmd!(noundo "table.select", "Select Cells", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?, what?: cell|row|column|table}", in_table_or_ids, select),
|
||||
cmd!(noundo "table.selectTable", "Select Table", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| {
|
||||
select(s, &super::with_param(p, "what", json!("table")))
|
||||
@@ -1401,6 +1428,27 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert Rows is greyed out unless the selected cells (or the caret's cell) can change kind.
|
||||
fn can_convert_rows(s: &Session, kind: RowKind) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.cells.is_none() {
|
||||
in_cell(s)?;
|
||||
}
|
||||
let g = target(s, &Value::Null, "table.convertRows").map_err(|e| e.to_string())?;
|
||||
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or("no table")?;
|
||||
t.convert_rows_counts(g.range.r0, g.range.r1, kind).map(|_| ())
|
||||
}
|
||||
|
||||
fn convert_rows(s: &mut Session, p: &Value, kind: RowKind) -> Result<Value> {
|
||||
const ID: &str = "table.convertRows";
|
||||
let g = target(s, p, ID)?;
|
||||
edit_table(s, &g, ID, |t| {
|
||||
let (h, f) = t.convert_rows_counts(g.range.r0, g.range.r1, kind).map_err(|e| bad(ID, e))?;
|
||||
t.set_header_footer(h, f);
|
||||
Ok(json!({"headerRows": h, "footerRows": f}))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- selection ----------
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -1008,3 +1008,92 @@ fn split_selected_block_and_merged_cells_preserves_geometry_and_history() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn header_footer(s: &mut Session) -> (u64, u64) {
|
||||
let t = s.execute("table.get", &json!({})).unwrap();
|
||||
(t["headerRows"].as_u64().unwrap(), t["footerRows"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn convert_enabled(s: &Session, to: &str) -> bool {
|
||||
crate::cmd::find_command(&format!("table.convertRowsTo{to}")).unwrap().info(s).enabled
|
||||
}
|
||||
|
||||
fn select_rows(s: &mut Session, a: usize, b: usize) {
|
||||
s.execute("table.select", &json!({"what": "row", "rows": [a, b]})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rows_follows_the_table_edges() {
|
||||
let (mut s, _, _) = session_with_frame();
|
||||
s.execute("table.insert", &json!({"rows": 6, "cols": 2})).unwrap();
|
||||
// Rows at the top become header rows, in one undo step.
|
||||
select_rows(&mut s, 0, 1);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("table.convertRowsToHeader", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (2, 0));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (0, 0));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (2, 0));
|
||||
// A row with a body row between it and the header rows can't become a header row.
|
||||
select_rows(&mut s, 3, 3);
|
||||
assert!(!convert_enabled(&s, "Header"));
|
||||
assert!(s.execute("table.convertRowsToHeader", &json!({})).is_err());
|
||||
assert_eq!(header_footer(&mut s), (2, 0));
|
||||
// The row right after the header rows can.
|
||||
select_rows(&mut s, 2, 2);
|
||||
s.execute("table.convertRowsToHeader", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (3, 0));
|
||||
// Footer rows come from the bottom.
|
||||
select_rows(&mut s, 3, 3);
|
||||
assert!(!convert_enabled(&s, "Footer"));
|
||||
select_rows(&mut s, 5, 5);
|
||||
s.execute("table.convertRowsToFooter", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (3, 1));
|
||||
select_rows(&mut s, 4, 4);
|
||||
s.execute("table.convertRowsToFooter", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (3, 2));
|
||||
// The last body row stays a body row.
|
||||
select_rows(&mut s, 3, 3);
|
||||
assert!(!convert_enabled(&s, "Header") && !convert_enabled(&s, "Footer") && !convert_enabled(&s, "Body"));
|
||||
// Back to body: only header rows next to the body, and footer rows next to it.
|
||||
select_rows(&mut s, 0, 0);
|
||||
assert!(!convert_enabled(&s, "Body"));
|
||||
select_rows(&mut s, 1, 2);
|
||||
s.execute("table.convertRowsToBody", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (1, 2));
|
||||
select_rows(&mut s, 5, 5);
|
||||
assert!(!convert_enabled(&s, "Body"));
|
||||
select_rows(&mut s, 4, 4);
|
||||
s.execute("table.convertRowsToBody", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (1, 1));
|
||||
// Header rows can't jump to the footer, nor footer rows to the header.
|
||||
select_rows(&mut s, 0, 0);
|
||||
assert!(!convert_enabled(&s, "Footer"));
|
||||
select_rows(&mut s, 5, 5);
|
||||
assert!(!convert_enabled(&s, "Header"));
|
||||
// The whole table back to body rows.
|
||||
s.execute("table.select", &json!({"what": "table"})).unwrap();
|
||||
s.execute("table.convertRowsToBody", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (0, 0));
|
||||
check(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rows_keeps_merged_cells_on_one_side() {
|
||||
let (mut s, _, _) = session_with_frame();
|
||||
s.execute("table.insert", &json!({"rows": 4, "cols": 2})).unwrap();
|
||||
s.execute("table.select", &json!({"rows": [0, 1], "cols": [1, 1]})).unwrap();
|
||||
s.execute("table.merge", &json!({})).unwrap();
|
||||
// Row 0 alone would split the merged cell between header and body.
|
||||
select_rows(&mut s, 0, 0);
|
||||
assert!(!convert_enabled(&s, "Header"));
|
||||
select_rows(&mut s, 0, 1);
|
||||
s.execute("table.convertRowsToHeader", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (2, 0));
|
||||
// Likewise back: row 1 alone would leave half of it in the header.
|
||||
select_rows(&mut s, 1, 1);
|
||||
assert!(!convert_enabled(&s, "Body"));
|
||||
check(&s);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user