mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 13:57:18 -06:00
refactor(engine): split cmd/table.rs into table/
This commit is contained in:
+23
-1391
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
use super::layout::touch_cells;
|
||||
use super::*;
|
||||
|
||||
pub(super) fn cell(s: &Session) -> Result<(Path, usize, usize), CmdError> {
|
||||
s.sel.focus.path.cell().ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into()))
|
||||
}
|
||||
|
||||
pub(super) fn with_table(s: &mut Session, f: impl Fn(&mut Table)) -> CmdResult {
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
|
||||
f(t);
|
||||
touch_cells(s, &tp)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Selected cells: (row, cell) pairs covered by the selection (at least the caret's cell).
|
||||
pub fn selected_cells(s: &Session) -> Vec<(usize, usize)> {
|
||||
// Whole cells selected (a column, say): just those, by grid column, so merged cells count.
|
||||
if let Some(range) = s.cell_range()
|
||||
&& s.sel.focus.path.cell().is_some_and(|(tp, _, _)| tp == range.table)
|
||||
&& let Some(t) = s.doc.table(range.story, &range.table)
|
||||
{
|
||||
return range.cells(t);
|
||||
}
|
||||
let (a, b) = s.sel.ordered();
|
||||
let (Some((ta, ra, ca)), Some((tb, rb, cb))) = (a.path.cell(), b.path.cell()) else { return Vec::new() };
|
||||
if ta != tb {
|
||||
return vec![(rb, cb)];
|
||||
}
|
||||
let (r0, r1) = (ra.min(rb), ra.max(rb));
|
||||
let (c0, c1) = (ca.min(cb), ca.max(cb));
|
||||
let mut v = Vec::new();
|
||||
for r in r0..=r1 {
|
||||
for c in c0..=c1 {
|
||||
v.push((r, c));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub(super) fn with_cells(s: &mut Session, f: impl Fn(&mut wordcraft_doc::Cell)) -> CmdResult {
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let cells = selected_cells(s);
|
||||
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
|
||||
for (r, c) in cells {
|
||||
if let Some(cl) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) {
|
||||
f(cl);
|
||||
}
|
||||
}
|
||||
touch_cells(s, &tp)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
pub(crate) fn cell_text(c: &wordcraft_doc::Cell) -> String {
|
||||
c.blocks.iter().filter_map(|b| b.as_para().map(|p| p.plain_text())).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// The language of a cell's text: that of its first paragraph with text.
|
||||
pub(super) fn cell_lang(doc: &wordcraft_doc::Document, c: &wordcraft_doc::Cell) -> Option<String> {
|
||||
c.blocks.iter().filter_map(|b| b.as_para()).find(|p| !p.plain_text().trim().is_empty()).and_then(|p| crate::cmd::para_lang(doc, p))
|
||||
}
|
||||
|
||||
/// The grid columns cell `c` of row `r` covers (first, last).
|
||||
pub(in crate::cmd) fn grid_cols(t: &Table, r: usize, c: usize) -> (usize, usize) {
|
||||
let g = t.grid_col(r, c);
|
||||
let span = t.rows.get(r).and_then(|row| row.cells.get(c)).map_or(1, wordcraft_doc::Cell::span);
|
||||
(g, g + span - 1)
|
||||
}
|
||||
|
||||
/// The cell of the table at index `i` of paragraph path `p`, if the paragraph is in one there:
|
||||
/// (the table's path, row, cell).
|
||||
pub(super) fn cell_at(p: &[u32], i: usize) -> Option<(Path, usize, usize)> {
|
||||
match (p.get(..=i), p.get(i + 1), p.get(i + 2)) {
|
||||
(Some(t), Some(r), Some(c)) if p.len() > i + 3 => Some((Path(t.to_vec()), *r as usize, *c as usize)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where row `r` of table `tp` starts and ends: its first cell's start and its last cell's end.
|
||||
pub(crate) fn row_ends(doc: &Document, story: StoryRef, tp: &Path, r: usize) -> Option<(Pos, Pos)> {
|
||||
let mut row = tp.0.clone();
|
||||
row.push(u32::try_from(r).ok()?);
|
||||
let mut paths = doc.para_paths(story).into_iter().filter(|q| q.0.starts_with(&row));
|
||||
let first = paths.next()?;
|
||||
let last = paths.next_back().unwrap_or_else(|| first.clone());
|
||||
let len = doc.para(story, &last).map_or(0, Paragraph::len);
|
||||
Some((Pos { story, path: first, off: 0 }, Pos { story, path: last, off: len }))
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use super::structure::{del_col, del_row};
|
||||
use super::*;
|
||||
|
||||
/// Backspace with whole cells selected (#1844): whole rows go like Delete Rows (every row: the
|
||||
/// table, like Delete Table), whole columns like Delete Columns, with their undo and Track
|
||||
/// Changes. `None` for any other block of cells, and for a single cell of a table with more
|
||||
/// (Backspace then clears them).
|
||||
pub(crate) fn backspace_cells(s: &mut Session) -> Option<CmdResult> {
|
||||
let cr = s.cell_range()?.clone();
|
||||
let (tp, _, _) = s.sel.focus.path.cell()?;
|
||||
if cr.story != s.sel.focus.story || cr.table != tp {
|
||||
return None;
|
||||
}
|
||||
let t = s.doc.table(cr.story, &tp)?;
|
||||
// One cell of several is just that cell, also in a one-row or one-column table (Word's cell
|
||||
// selection doesn't take the end-of-row mark): Backspace clears it.
|
||||
let cells: usize = t.rows.iter().map(|row| row.cells.len()).sum();
|
||||
if cells > 1 && cr.cells(t).len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1));
|
||||
let r = if cr.cols == (0, last_col) {
|
||||
del_row(s, &Value::Null)
|
||||
} else if cr.rows == (0, last_row) {
|
||||
del_col(s, &Value::Null)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
s.column = None;
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// Delete with whole cells selected (#2802): each cell is emptied to one empty paragraph, as in
|
||||
/// Word, nested tables included. That paragraph keeps the formatting of the cell's last one (its
|
||||
/// mark is the end-of-cell mark). `None` without whole cells, and with Track Changes on (the text
|
||||
/// is marked deleted as for any selection).
|
||||
pub(crate) fn clear_cells(s: &mut Session) -> Option<CmdResult> {
|
||||
if s.doc.settings.track_changes {
|
||||
return None;
|
||||
}
|
||||
let cr = s.cell_range()?.clone();
|
||||
let t = s.doc.table(cr.story, &cr.table)?;
|
||||
let prefixes: Vec<Vec<u32>> = cr.cells(t).into_iter().map(|(r, c)| cell_prefix(&cr.table, r, c)).collect();
|
||||
let spans = text_spans(s, cr.story, &prefixes);
|
||||
Some(clear_spans(s, cr.table.0.len().saturating_add(2), &spans))
|
||||
}
|
||||
|
||||
/// Empty each span (one cell's text, from its first paragraph to its last; the cell's path is the
|
||||
/// first `depth` steps of its paths) to one paragraph with the last paragraph's formatting, bottom
|
||||
/// up so earlier spans stay valid; the caret goes to the start of the first cell.
|
||||
fn clear_spans(s: &mut Session, depth: usize, spans: &[(Pos, Pos)]) -> CmdResult {
|
||||
// A cell's first block (its first paragraph may have been in a nested table that goes).
|
||||
let first_block = |p: &Pos| {
|
||||
let mut v = p.path.0.get(..depth).unwrap_or_default().to_vec();
|
||||
v.push(0);
|
||||
Pos { story: p.story, path: Path(v), off: 0 }
|
||||
};
|
||||
for (a, b) in spans {
|
||||
crate::cmd::controls::check_delete(s, a, b)?;
|
||||
}
|
||||
for (a, b) in spans.iter().rev() {
|
||||
let Some(last) = s.doc.para(b.story, &b.path).map(|p| (p.props.clone(), p.mark.clone())) else { continue };
|
||||
s.doc.delete_range(a, b)?;
|
||||
// Now the cell's only block, unless it holds a table nested deeper than the paragraph
|
||||
// walk (`delete_range` leaves those).
|
||||
let first = first_block(a);
|
||||
if s.doc.container(first.story, &first.path).is_some_and(|bl| bl.len() == 1)
|
||||
&& let Ok(p) = s.doc.para_mut(first.story, &first.path)
|
||||
{
|
||||
(p.props, p.mark) = last;
|
||||
p.touch();
|
||||
}
|
||||
}
|
||||
let at = spans.first().map(|(a, _)| s.doc.clamp(&first_block(a))).unwrap_or_else(|| s.sel.focus.clone());
|
||||
s.sel = Selection::caret(at);
|
||||
s.column = None;
|
||||
s.column_mode = false;
|
||||
s.goal_x = None;
|
||||
s.pending = None;
|
||||
crate::cmd::controls::restore_placeholder(s)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// The path prefix of cell (`r`, `c`) of the table at `tp`.
|
||||
pub(super) fn cell_prefix(tp: &Path, r: usize, c: usize) -> Vec<u32> {
|
||||
tp.0.iter().copied().chain([r as u32, c as u32]).collect()
|
||||
}
|
||||
|
||||
/// The text under each path prefix (a cell, a row or a whole table): from its first paragraph's
|
||||
/// start to its last paragraph's end. Prefixes without paragraphs are left out.
|
||||
pub(super) fn text_spans(s: &Session, story: StoryRef, prefixes: &[Vec<u32>]) -> Vec<(Pos, Pos)> {
|
||||
let paths = s.doc.para_paths(story);
|
||||
prefixes
|
||||
.iter()
|
||||
.filter_map(|pre| {
|
||||
let mut inside = paths.iter().filter(|p| p.0.starts_with(pre));
|
||||
let first = inside.next()?.clone();
|
||||
let last = inside.next_back().cloned().unwrap_or_else(|| first.clone());
|
||||
let end = s.doc.para(story, &last).map_or(0, Paragraph::len);
|
||||
Some((Pos { story, path: first, off: 0 }, Pos { story, path: last, off: end }))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Before a table deletion removes the text `spans`: refuse when that deletes a content control
|
||||
/// locked against deletion or edits content locked against editing (#1329). With Track Changes
|
||||
/// on, text not yet deleted is marked deleted instead and the structure stays (like Word deleting
|
||||
/// a table with tracking on; the model has no tracked row or column deletion), returning the
|
||||
/// result. With no text at all (an empty table, or one whose deletion was accepted) there is
|
||||
/// nothing for Reject All to restore and the structure goes as without tracking; with only text
|
||||
/// already marked deleted, nothing changes (removing it would lose what Reject All restores).
|
||||
pub(super) fn guard_delete(s: &mut Session, spans: &[(Pos, Pos)]) -> Result<Option<Value>, CmdError> {
|
||||
for (a, b) in spans {
|
||||
crate::cmd::controls::check_delete(s, a, b)?;
|
||||
}
|
||||
if !s.doc.settings.track_changes {
|
||||
return Ok(None);
|
||||
}
|
||||
if !spans.iter().any(|(a, b)| has_text(s, a, b, true)) {
|
||||
if spans.iter().any(|(a, b)| has_text(s, a, b, false)) {
|
||||
let msg = "The table's text is already marked deleted; accept or reject the change first.";
|
||||
s.status = msg.to_string();
|
||||
return Err(CmdError::Failed(msg.to_string()));
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
let rid = crate::cmd::new_revision(s, RevisionKind::Delete);
|
||||
for (a, b) in spans.iter().rev() {
|
||||
crate::cmd::track_delete_as(s, a, b, rid)?;
|
||||
}
|
||||
if let Some((a, _)) = spans.first() {
|
||||
s.sel = Selection::caret(s.doc.clamp(a));
|
||||
}
|
||||
sel_result(s).map(Some)
|
||||
}
|
||||
|
||||
/// Whether `a..b` holds text: any text, or (`live`) text that isn't a tracked deletion.
|
||||
fn has_text(s: &Session, a: &Pos, b: &Pos, live: bool) -> bool {
|
||||
s.doc.paths_between(a, b).iter().any(|path| {
|
||||
let Some(p) = s.doc.para(a.story, path) else { return false };
|
||||
let from = if *path == a.path { a.off } else { 0 };
|
||||
let to = if *path == b.path { b.off } else { p.len() };
|
||||
p.run_ranges().any(|(r, c)| (!live || c.del.is_none()) && r.end > from && r.start < to && !r.is_empty())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
use super::cells::{cell, cell_lang, cell_text, selected_cells, with_table};
|
||||
use super::layout::autofit;
|
||||
use super::*;
|
||||
|
||||
/// Sort the table's rows by a column. Numbers are read in each cell's language (`1,5` is one and
|
||||
/// a half in Polish) and sort first, numerically; other text sorts in the column's language.
|
||||
pub(super) fn sort_table(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let col = p::u64(v, "column").unwrap_or(0) as usize;
|
||||
let desc = p::bool(v, "descending").unwrap_or(false);
|
||||
let header = p::bool(v, "header");
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?;
|
||||
let skip = usize::from(header.unwrap_or(t.rows.first().is_some_and(|r| r.props.header))).min(t.rows.len());
|
||||
let column = || t.rows.iter().skip(skip).map(|r| r.cells.get(col));
|
||||
let lang = column().flatten().find_map(|c| cell_lang(&s.doc, c)).or_else(|| crate::cmd::default_lang(s));
|
||||
// A total order (numbers, then text), so sorting never sees inconsistent comparisons.
|
||||
let keys: Vec<(Option<f64>, SortKey)> = column()
|
||||
.map(|c| {
|
||||
let text = c.map(cell_text).unwrap_or_default();
|
||||
let num_lang = c.and_then(|c| cell_lang(&s.doc, c)).or_else(|| lang.clone());
|
||||
let num = parse_number(text.trim(), num_lang.as_deref()).filter(|x| x.is_finite());
|
||||
(num, if num.is_some() { SortKey::default() } else { sort_key(text.trim(), lang.as_deref()) })
|
||||
})
|
||||
.collect();
|
||||
with_table(s, move |t| {
|
||||
if skip > t.rows.len() {
|
||||
return;
|
||||
}
|
||||
let body = t.rows.split_off(skip);
|
||||
let mut rows: Vec<_> = keys.iter().cloned().zip(body).collect();
|
||||
rows.sort_by(|((a, ka), _), ((b, kb), _)| {
|
||||
let o = match (a, b) {
|
||||
(Some(x), Some(y)) => x.total_cmp(y),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => ka.cmp(kb),
|
||||
};
|
||||
if desc { o.reverse() } else { o }
|
||||
});
|
||||
t.rows.extend(rows.into_iter().map(|(_, r)| r));
|
||||
})
|
||||
}
|
||||
|
||||
/// Table Layout › Data › Convert to Text: the selected rows, or the whole table with the caret
|
||||
/// in one cell, become paragraphs. Each row is one paragraph with its cells set apart by the
|
||||
/// separator (a tab, a comma or another character); with `paragraph` each cell paragraph
|
||||
/// stays a separate paragraph. The text keeps its formatting and objects, a table nested in a cell stays
|
||||
/// a table, and rows above and below the selected ones stay a table each.
|
||||
pub(super) fn to_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let sep = match p::str(v, "separator") {
|
||||
None | Some("tab") => Some('\t'),
|
||||
Some("comma") => Some(','),
|
||||
Some("paragraph") => None,
|
||||
Some("other") => match p::str(v, "char").and_then(|c| c.chars().next()) {
|
||||
// U+FFFC marks an object in the text, so it cannot set the cells apart.
|
||||
Some(c) if c != '\n' && c != '\r' && c != wordcraft_doc::para::OBJ => Some(c),
|
||||
_ => return Err(CmdError::Params("`char` (the separating character) is required with `other`".into())),
|
||||
},
|
||||
Some(x) => return Err(CmdError::Params(format!("unknown separator `{x}`"))),
|
||||
};
|
||||
let story = s.sel.focus.story;
|
||||
let mut t = s.doc.table(story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?;
|
||||
// Several cells selected: their rows. A caret (or a selection inside one cell): every row.
|
||||
let cells = selected_cells(s);
|
||||
let (r0, r1) = match (cells.iter().map(|x| x.0).min(), cells.iter().map(|x| x.0).max()) {
|
||||
(Some(a), Some(b)) if cells.len() > 1 || s.cell_range().is_some() => (a, b.min(t.rows.len().saturating_sub(1))),
|
||||
_ => (0, t.rows.len().saturating_sub(1)),
|
||||
};
|
||||
// `split_off` ends the vertical merges and row content controls that a cut crosses, and opens
|
||||
// them again (without their ids) below it.
|
||||
let mut tail = t.split_off(r1 + 1);
|
||||
let rows = t.split_off(r0).rows;
|
||||
t.controls.close = std::mem::take(&mut tail.controls.close);
|
||||
let mut blocks = Vec::new();
|
||||
if !t.rows.is_empty() {
|
||||
let mut head = Table { props: t.props.clone(), grid: t.grid.clone(), rows: std::mem::take(&mut t.rows), controls: Default::default() };
|
||||
head.controls.open = std::mem::take(&mut t.controls.open);
|
||||
blocks.push(Block::Table(head));
|
||||
}
|
||||
let text_from = blocks.len();
|
||||
for row in rows {
|
||||
row_to_text(row, sep, &mut blocks);
|
||||
}
|
||||
// Rows without cells (only from a damaged file) still leave a paragraph in their place.
|
||||
if blocks.len() == text_from {
|
||||
blocks.push(Block::Para(Paragraph::new()));
|
||||
}
|
||||
// The caret goes to the first converted paragraph (into a nested table when there is none).
|
||||
let caret = match blocks.iter().enumerate().skip(text_from).find(|(_, b)| matches!(b, Block::Para(_))) {
|
||||
Some((k, _)) => tp.with_last(tp.last() + k),
|
||||
None => Path(tp.with_last(tp.last() + text_from).0.into_iter().chain([0, 0, 0]).collect()),
|
||||
};
|
||||
if !tail.rows.is_empty() {
|
||||
tail.controls.close = std::mem::take(&mut t.controls.close);
|
||||
// A floating table keeps its position in the part above; the part below goes in the text
|
||||
// flow.
|
||||
if text_from > 0 {
|
||||
tail.props.float = None;
|
||||
}
|
||||
blocks.push(Block::Table(tail));
|
||||
}
|
||||
// A content control around the table keeps both ends: on the text where no table part is
|
||||
// left to carry them.
|
||||
let (open, close) = (std::mem::take(&mut t.controls.open), std::mem::take(&mut t.controls.close));
|
||||
match blocks.get_mut(text_from) {
|
||||
Some(Block::Para(p)) => {
|
||||
for (k, mut control) in open.into_iter().enumerate() {
|
||||
control.block = true;
|
||||
let start = InlineObject::ControlStart { control: Box::new(control) };
|
||||
p.insert_object(k * wordcraft_doc::para::OBJ.len_utf8(), start, &Default::default())?;
|
||||
}
|
||||
}
|
||||
Some(Block::Table(x)) => {
|
||||
let inner = std::mem::replace(&mut x.controls.open, open);
|
||||
x.controls.open.extend(inner);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
match blocks.last_mut() {
|
||||
Some(Block::Para(p)) => {
|
||||
// The count is read from the file; at most 1,024 ends go onto the text.
|
||||
for _ in 0..close.min(1024) {
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::ControlEnd, &Default::default())?;
|
||||
}
|
||||
}
|
||||
Some(Block::Table(x)) => x.controls.close = x.controls.close.saturating_add(close),
|
||||
None => {}
|
||||
}
|
||||
s.doc.remove_block(story, &tp)?;
|
||||
for (k, b) in blocks.into_iter().enumerate() {
|
||||
s.doc.insert_block(story, &tp.with_last(tp.last() + k), b)?;
|
||||
}
|
||||
s.sel = Selection::caret(s.doc.clamp(&Pos { story, path: caret, off: 0 }));
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// One row of Convert to Text as blocks: a paragraph per row (and per further paragraph in a
|
||||
/// cell) with the cells set apart by `sep`, or each cell's blocks as they are without one.
|
||||
fn row_to_text(row: wordcraft_doc::Row, sep: Option<char>, out: &mut Vec<Block>) {
|
||||
let Some(sep) = sep else {
|
||||
for b in row.cells.into_iter().flat_map(|c| c.blocks) {
|
||||
out.push(match std::sync::Arc::unwrap_or_clone(b) {
|
||||
Block::Para(mut para) => {
|
||||
para.section = None;
|
||||
para.touch();
|
||||
Block::Para(para)
|
||||
}
|
||||
other => other,
|
||||
});
|
||||
}
|
||||
return;
|
||||
};
|
||||
let mut line: Option<Paragraph> = None;
|
||||
for (ci, cell) in row.cells.into_iter().enumerate() {
|
||||
if ci > 0 {
|
||||
// The separator takes no formatting from the text before it.
|
||||
line.get_or_insert_with(Paragraph::new).append(Paragraph::with_text(sep.encode_utf8(&mut [0; 4]), Default::default()));
|
||||
}
|
||||
let mut first = true;
|
||||
for b in cell.blocks {
|
||||
match std::sync::Arc::unwrap_or_clone(b) {
|
||||
Block::Para(mut para) => {
|
||||
para.section = None;
|
||||
match line.as_mut() {
|
||||
Some(p) if first => p.append(para),
|
||||
_ => {
|
||||
if let Some(done) = line.take() {
|
||||
out.push(Block::Para(done));
|
||||
}
|
||||
para.touch();
|
||||
line = Some(para);
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
// A nested table stays a table between the row's paragraphs.
|
||||
if let Some(done) = line.take() {
|
||||
out.push(Block::Para(done));
|
||||
}
|
||||
out.push(other);
|
||||
}
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
if let Some(done) = line {
|
||||
out.push(Block::Para(done));
|
||||
}
|
||||
}
|
||||
|
||||
/// Table Layout › Data › Formula: inserts a formula field at the caret, evaluated now and again
|
||||
/// by Update Field. What a formula can use is described in [`super::table_formula`]. A formula
|
||||
/// that doesn't evaluate (an unknown function, a syntax error) is an error and inserts nothing.
|
||||
pub(super) fn formula(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let raw = p::str(v, "formula").unwrap_or("=SUM(ABOVE)");
|
||||
let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?;
|
||||
let default_lang = crate::cmd::default_lang(s);
|
||||
let instr = auto_formula_instr(t, r, c, raw, default_lang.as_deref());
|
||||
let text = crate::cmd::table_formula::evaluate_field_with(t, Some(&s.doc), default_lang.as_deref(), r, c, &instr)?;
|
||||
let props = s.typing_props();
|
||||
let at = s.sel.focus.clone();
|
||||
let end = s.doc.insert_object(
|
||||
&at,
|
||||
wordcraft_doc::InlineObject::Field {
|
||||
instr: instr.clone(),
|
||||
result: text.clone(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
&props,
|
||||
)?;
|
||||
s.sel = Selection::caret(end);
|
||||
Ok(json!({"result": text, "formula": instr}))
|
||||
}
|
||||
|
||||
/// A formula as a field stores it: upper case, with one leading `=`. The `\#` numeric picture and
|
||||
/// the `\*` switches are kept as typed.
|
||||
pub(crate) fn formula_instr(raw: &str) -> String {
|
||||
let body = raw.trim();
|
||||
let body = body.strip_prefix('=').unwrap_or(body);
|
||||
match crate::cmd::table_formula::split_switch(body) {
|
||||
(formula, Some(switch)) => format!("={} {switch}", formula.trim().to_ascii_uppercase()),
|
||||
(formula, None) => format!("={}", formula.trim().to_ascii_uppercase()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A bare function name (`=SUM`, no parentheses) takes its direction from the cell: ABOVE when a
|
||||
/// cell above holds a number, else LEFT.
|
||||
fn auto_formula_instr(t: &Table, r: usize, c: usize, raw: &str, lang: Option<&str>) -> String {
|
||||
let instr = formula_instr(raw);
|
||||
let body = instr.strip_prefix('=').unwrap_or(&instr);
|
||||
if !body.contains('(') && matches!(body, "SUM" | "AVERAGE" | "COUNT" | "MAX" | "MIN" | "PRODUCT") {
|
||||
let g = t.grid_col(r, c);
|
||||
let has_above = (0..r).rev().any(|row| {
|
||||
t.cell_at_grid(row, g)
|
||||
.and_then(|ci| t.rows.get(row)?.cells.get(ci))
|
||||
.and_then(|cl| crate::cmd::table_formula::cell_number_in(cl, lang))
|
||||
.is_some()
|
||||
});
|
||||
let dir = if has_above { "ABOVE" } else { "LEFT" };
|
||||
format!("={body}({dir})")
|
||||
} else {
|
||||
instr
|
||||
}
|
||||
}
|
||||
|
||||
/// The paragraphs Convert Text to Table would convert: the selected ones, as text. Refused
|
||||
/// rather than dropping content when a table lies between them.
|
||||
pub fn text_table_lines(s: &Session) -> Result<Vec<String>, CmdError> {
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a.path.parent() != b.path.parent() || a.story != b.story {
|
||||
return Err(CmdError::Failed("select paragraphs to convert".into()));
|
||||
}
|
||||
let lines: Vec<String> =
|
||||
(a.path.last()..=b.path.last()).filter_map(|i| s.doc.para(a.story, &a.path.with_last(i)).map(|p| p.plain_text())).collect();
|
||||
// Refuse rather than drop content: a table between the paragraphs would be removed with them.
|
||||
if lines.len() != b.path.last() - a.path.last() + 1 {
|
||||
return Err(CmdError::Failed("select only paragraphs to convert".into()));
|
||||
}
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
/// The cells Convert Text to Table makes of `lines`, row by row, and the column count. `sep`
|
||||
/// `None` separates at paragraphs: each paragraph is a cell, `columns` (default 1) to a row.
|
||||
/// Otherwise each paragraph starts a row; `columns` defaults to the most fields in one, and a
|
||||
/// paragraph with more fields than that carries on over the next rows.
|
||||
pub fn text_table_cells(lines: &[String], sep: Option<char>, columns: Option<usize>) -> (Vec<Vec<&str>>, usize) {
|
||||
let Some(sep) = sep else {
|
||||
let cols = columns.unwrap_or(1).clamp(1, wordcraft_doc::table::MAX_COLS);
|
||||
return (lines.chunks(cols).map(|ch| ch.iter().map(String::as_str).collect()).collect(), cols);
|
||||
};
|
||||
let cols = columns.unwrap_or_else(|| lines.iter().map(|l| l.split(sep).count()).max().unwrap_or(1)).max(1);
|
||||
let mut rows = Vec::new();
|
||||
for l in lines {
|
||||
let fields: Vec<&str> = l.split(sep).collect();
|
||||
rows.extend(fields.chunks(cols.min(wordcraft_doc::table::MAX_COLS)).map(<[&str]>::to_vec));
|
||||
}
|
||||
(rows, cols)
|
||||
}
|
||||
|
||||
/// The cells Convert Text to Table makes of `paras`, row by row, and the column count, as
|
||||
/// [`text_table_cells`] makes them of their text. Each cell is its field of the source paragraph
|
||||
/// cut out of it, trimmed of surrounding white space, so its formatting, objects (pictures,
|
||||
/// fields, notes…) and tracked changes go with it.
|
||||
fn text_table_paras(paras: Vec<Paragraph>, sep: Option<char>, columns: Option<usize>) -> Result<(Vec<Vec<Paragraph>>, usize), CmdError> {
|
||||
use wordcraft_doc::para::OBJ;
|
||||
use wordcraft_doc::table::MAX_COLS;
|
||||
// Each paragraph's fields as byte ranges; an object is never a separator.
|
||||
let fields: Vec<Vec<std::ops::Range<usize>>> = paras
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut out = Vec::new();
|
||||
let mut from = 0;
|
||||
if let Some(sep) = sep.filter(|c| *c != OBJ) {
|
||||
for (i, c) in p.text.char_indices().filter(|(_, c)| *c == sep) {
|
||||
out.push(from..i);
|
||||
from = i + c.len_utf8();
|
||||
}
|
||||
}
|
||||
out.push(from..p.text.len());
|
||||
out
|
||||
})
|
||||
.collect();
|
||||
let cols = match sep {
|
||||
None => columns.unwrap_or(1).clamp(1, MAX_COLS),
|
||||
Some(_) => columns.unwrap_or_else(|| fields.iter().map(Vec::len).max().unwrap_or(1)).max(1),
|
||||
};
|
||||
let per_row = cols.min(MAX_COLS);
|
||||
let mut rows: Vec<Vec<Paragraph>> = Vec::new();
|
||||
let mut row: Vec<Paragraph> = Vec::new();
|
||||
for (mut work, ranges) in paras.into_iter().zip(fields) {
|
||||
// The section break stays out of the cells.
|
||||
work.section = None;
|
||||
let mut pieces = Vec::with_capacity(ranges.len());
|
||||
// Last field first, so the earlier offsets stay valid.
|
||||
for r in ranges.into_iter().rev() {
|
||||
let field = work.text.get(r.clone()).unwrap_or("");
|
||||
let start = r.start + (field.len() - field.trim_start().len());
|
||||
let end = (r.start + field.trim_end().len()).max(start);
|
||||
let mut piece = work.split_off(start)?;
|
||||
piece.split_off(end - start)?;
|
||||
pieces.push(piece);
|
||||
}
|
||||
pieces.reverse();
|
||||
for piece in pieces {
|
||||
row.push(piece);
|
||||
if row.len() == per_row {
|
||||
rows.push(std::mem::take(&mut row));
|
||||
}
|
||||
}
|
||||
// With a separator, each paragraph starts a row.
|
||||
if sep.is_some() && !row.is_empty() {
|
||||
rows.push(std::mem::take(&mut row));
|
||||
}
|
||||
}
|
||||
if !row.is_empty() {
|
||||
rows.push(row);
|
||||
}
|
||||
Ok((rows, cols))
|
||||
}
|
||||
|
||||
/// Word's largest Initial column width, 22 in, in points.
|
||||
const MAX_INITIAL_WIDTH: f64 = 22.0 * 72.0;
|
||||
|
||||
pub(super) fn from_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let sep = match p::str(v, "separator") {
|
||||
None | Some("tab") => Some('\t'),
|
||||
Some("comma") => Some(','),
|
||||
Some("paragraph") => None,
|
||||
Some("other") => match p::str(v, "char").and_then(|c| c.chars().next()) {
|
||||
Some(c) if c != '\n' && c != '\r' => Some(c),
|
||||
_ => return Err(CmdError::Params("`char` (the separating character) is required with `other`".into())),
|
||||
},
|
||||
Some(x) => return Err(CmdError::Params(format!("unknown separator `{x}`"))),
|
||||
};
|
||||
let fit = match p::str(v, "autofit") {
|
||||
None | Some("initial") => None,
|
||||
Some(m @ ("fixed" | "contents" | "window")) => Some(m),
|
||||
Some(x) => return Err(CmdError::Params(format!("unknown AutoFit behavior `{x}`"))),
|
||||
};
|
||||
// Initial column width: 0 (or none) is Auto, the text width shared out; Word takes 0 to 22 in.
|
||||
let col_width = match v.get("width") {
|
||||
// Only the Initial column width uses it; AutoFit sizes the columns itself.
|
||||
_ if matches!(fit, Some("contents" | "window")) => 0.0,
|
||||
None | Some(Value::Null) => 0.0,
|
||||
Some(w) => match w.as_f64() {
|
||||
Some(w) if (0.0..=MAX_INITIAL_WIDTH).contains(&w) => w as f32,
|
||||
_ => return Err(CmdError::Params("`width` must be between 0 and 1584 pt (22 in)".into())),
|
||||
},
|
||||
};
|
||||
let columns = p::u64(v, "columns").map(|n| n.clamp(1, wordcraft_doc::table::MAX_COLS as u64) as usize);
|
||||
let (a, b) = s.sel.ordered();
|
||||
// Checks the selection: only paragraphs, a table between them is refused.
|
||||
text_table_lines(s)?;
|
||||
let paras: Vec<Paragraph> = (a.path.last()..=b.path.last()).filter_map(|i| s.doc.para(a.story, &a.path.with_last(i)).cloned()).collect();
|
||||
let (cells, cols) = text_table_paras(paras, sep, columns)?;
|
||||
// A table has at most 63 columns; a longer row would lose its last fields.
|
||||
if cols > wordcraft_doc::table::MAX_COLS {
|
||||
return Err(CmdError::Failed("a table can have at most 63 columns".into()));
|
||||
}
|
||||
if cells.len() > wordcraft_doc::table::MAX_ROWS {
|
||||
return Err(CmdError::Failed(format!("a table can have at most {} rows", wordcraft_doc::table::MAX_ROWS)));
|
||||
}
|
||||
let width = crate::cmd::page::sect(s).text_width();
|
||||
let mut t = Table::new(cells.len().max(1), cols, width);
|
||||
if col_width > 0.0 {
|
||||
// Every column gets that preferred width, even past the margins (layout fits the table
|
||||
// to the text width, as Word draws it); the table itself has no preferred width.
|
||||
t.grid.iter_mut().for_each(|w| *w = col_width);
|
||||
t.rows.iter_mut().flat_map(|row| row.cells.iter_mut()).for_each(|c| c.props.width = Some(col_width));
|
||||
}
|
||||
for (r, l) in cells.into_iter().enumerate() {
|
||||
for (c, para) in l.into_iter().enumerate() {
|
||||
if let Some(cl) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) {
|
||||
cl.blocks = vec![para_block(para)];
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in (a.path.last()..=b.path.last()).rev() {
|
||||
s.doc.remove_block(a.story, &a.path.with_last(i))?;
|
||||
}
|
||||
s.doc.insert_block(a.story, &a.path, Block::Table(t))?;
|
||||
let mut first = a.path.0.clone();
|
||||
first.extend([0, 0, 0]);
|
||||
s.sel = Selection::caret(Pos { story: a.story, path: Path(first), off: 0 });
|
||||
match fit {
|
||||
Some(mode) => autofit(s, &json!({ "mode": mode })),
|
||||
None => sel_result(s),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn quick_table(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let kind = p::str(v, "kind").unwrap_or("tabular");
|
||||
let data: Vec<Vec<&str>> = match kind {
|
||||
"calendar" => {
|
||||
let mut rows = vec![vec!["M", "T", "W", "T", "F", "S", "S"]];
|
||||
let days = [
|
||||
"", "", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22",
|
||||
"23", "24", "25", "26", "27", "28", "29", "30", "31", "", "", "",
|
||||
];
|
||||
for ch in days.chunks(7) {
|
||||
rows.push(ch.to_vec());
|
||||
}
|
||||
rows
|
||||
}
|
||||
"matrix" => vec![
|
||||
vec!["", "Column A", "Column B", "Column C"],
|
||||
vec!["Row 1", "1", "2", "3"],
|
||||
vec!["Row 2", "4", "5", "6"],
|
||||
vec!["Row 3", "7", "8", "9"],
|
||||
],
|
||||
_ => vec![
|
||||
vec!["Item", "Needed"],
|
||||
vec!["Books", "1"],
|
||||
vec!["Magazines", "3"],
|
||||
vec!["Notebooks", "1"],
|
||||
vec!["Paper pads", "1"],
|
||||
vec!["Pens", "3"],
|
||||
vec!["Pencils", "2"],
|
||||
],
|
||||
};
|
||||
let cols = data.iter().map(Vec::len).max().unwrap_or(1);
|
||||
let width = crate::cmd::page::sect(s).text_width();
|
||||
let mut t = Table::new(data.len(), cols, width);
|
||||
t.props.style = Some(if kind == "calendar" { "GridTable1Light" } else { "GridTable4AccentBlue" }.into());
|
||||
for (r, row) in data.iter().enumerate() {
|
||||
for (c, txt) in row.iter().enumerate() {
|
||||
if let Some(cl) = t.rows.get_mut(r).and_then(|x| x.cells.get_mut(c)) {
|
||||
cl.blocks = vec![para_block(Paragraph::with_text(txt, Default::default()))];
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(r) = t.rows.first_mut() {
|
||||
r.props.header = true;
|
||||
}
|
||||
let story = s.sel.focus.story;
|
||||
let at = crate::cmd::delete_selection(s)?;
|
||||
let new = s.doc.split_paragraph(&at)?;
|
||||
s.doc.insert_block(story, &new.path, Block::Table(t))?;
|
||||
let mut first = new.path.0.clone();
|
||||
first.extend([0, 0, 0]);
|
||||
s.sel = Selection::caret(Pos { story, path: Path(first), off: 0 });
|
||||
let _ = StoryRef::Body;
|
||||
sel_result(s)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
use super::cells::{cell, with_cells, with_table};
|
||||
use super::*;
|
||||
|
||||
/// Table Layout › AutoFit: Contents sizes the columns to their text, Window spreads them across
|
||||
/// the text column, Fixed keeps the widths as they are.
|
||||
pub(super) fn autofit(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let mode = p::str(v, "mode").unwrap_or("window");
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let story = s.sel.focus.story;
|
||||
let tw = crate::cmd::page::sect(s).text_width();
|
||||
match mode {
|
||||
"fixed" => with_table(s, |t| t.props.fixed = true),
|
||||
"contents" => {
|
||||
let t = s.doc.table(story, &tp).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into()))?;
|
||||
let avail = (tw - t.props.indent.filter(|i| i.is_finite()).unwrap_or(0.0).max(0.0)).max(72.0);
|
||||
let widths = wordcraft_layout::autofit_widths(&wordcraft_layout::measure_table_columns(&s.doc, t), avail);
|
||||
with_table(s, |t| {
|
||||
t.props.fixed = false;
|
||||
t.props.width = None;
|
||||
t.props.width_pct = None;
|
||||
t.grid = widths.iter().map(|w| w.max(6.0)).collect();
|
||||
sync_cell_widths(t);
|
||||
})
|
||||
}
|
||||
_ => with_table(s, |t| {
|
||||
t.props.fixed = false;
|
||||
t.props.width_pct = Some(100.0);
|
||||
let n = t.grid.len().max(1) as f32;
|
||||
t.grid.iter_mut().for_each(|g| *g = tw / n);
|
||||
sync_cell_widths(t);
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Give every cell the width of the grid columns it spans, so the cells' preferred widths
|
||||
/// (saved to .docx) agree with the grid.
|
||||
pub(crate) fn sync_cell_widths(t: &mut Table) {
|
||||
let grid = t.grid.clone();
|
||||
for row in &mut t.rows {
|
||||
// A row's cells start after the grid columns it skips (`w:gridBefore`).
|
||||
let mut g = row.first_grid();
|
||||
for c in &mut row.cells {
|
||||
let span = c.span();
|
||||
let w: f32 = grid.get(g..g.saturating_add(span).min(grid.len())).unwrap_or(&[]).iter().sum();
|
||||
if w > 0.0 {
|
||||
c.props.width = Some(w);
|
||||
}
|
||||
g = g.saturating_add(span);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the width of the grid column cell `c` of row `r` starts in. The table's preferred width
|
||||
/// goes, so the layout doesn't scale the column back.
|
||||
/// Give every grid column the `cells` (row, cell) pairs touch the width `w`.
|
||||
pub(super) fn set_column_widths(t: &mut Table, cells: &[(usize, usize)], w: f32) {
|
||||
let mut cols: Vec<usize> = cells.iter().map(|&(r, c)| t.grid_col(r, c)).collect();
|
||||
cols.sort_unstable();
|
||||
cols.dedup();
|
||||
let mut any = false;
|
||||
for g in cols {
|
||||
if let Some(x) = t.grid.get_mut(g) {
|
||||
*x = w;
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if any {
|
||||
t.props.width = None;
|
||||
t.props.width_pct = None;
|
||||
sync_cell_widths(t);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_column_width(t: &mut Table, r: usize, c: usize, w: f32) {
|
||||
let g = t.grid_col(r, c);
|
||||
if let Some(x) = t.grid.get_mut(g) {
|
||||
*x = w;
|
||||
t.props.width = None;
|
||||
t.props.width_pct = None;
|
||||
sync_cell_widths(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Table Properties: table, row, column and cell settings in one undoable step. Row, column and
|
||||
/// cell settings apply to the caret's. Without any setting it returns the current values.
|
||||
pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let keys = [
|
||||
"align",
|
||||
"rtl",
|
||||
"width",
|
||||
"widthPct",
|
||||
"indent",
|
||||
"rowHeight",
|
||||
"rowHeightRule",
|
||||
"allowBreak",
|
||||
"headerRow",
|
||||
"columnWidth",
|
||||
"cellWidth",
|
||||
"valign",
|
||||
];
|
||||
if !keys.iter().any(|k| v.get(*k).is_some()) {
|
||||
let t = s.doc.table(s.sel.focus.story, &tp).cloned().unwrap_or_default();
|
||||
let mut out = serde_json::to_value(&t.props).unwrap_or(Value::Null);
|
||||
let row = t.rows.get(r);
|
||||
let cl = row.and_then(|x| x.cells.get(c));
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.insert("row".into(), row.map(|x| serde_json::to_value(&x.props).unwrap_or(Value::Null)).unwrap_or(Value::Null));
|
||||
o.insert("columnWidth".into(), t.grid.get(t.grid_col(r, c)).map_or(Value::Null, |w| json!(w)));
|
||||
o.insert("cell".into(), cl.map(|x| serde_json::to_value(&x.props).unwrap_or(Value::Null)).unwrap_or(Value::Null));
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
let align = match p::str(v, "align") {
|
||||
None => None,
|
||||
Some("left") => Some(Align::Left),
|
||||
Some("center") => Some(Align::Center),
|
||||
Some("right") => Some(Align::Right),
|
||||
Some(x) => return Err(CmdError::Params(format!("unknown table alignment `{x}`"))),
|
||||
};
|
||||
let rule = match p::str(v, "rowHeightRule") {
|
||||
None => None,
|
||||
Some("atLeast") => Some(HeightRule::AtLeast),
|
||||
Some("exact") => Some(HeightRule::Exact),
|
||||
Some(x) => return Err(CmdError::Params(format!("unknown height rule `{x}`"))),
|
||||
};
|
||||
let valign = match p::str(v, "valign") {
|
||||
None => None,
|
||||
Some("top") => Some(VAlign::Top),
|
||||
Some("center") => Some(VAlign::Center),
|
||||
Some("bottom") => Some(VAlign::Bottom),
|
||||
Some(x) => return Err(CmdError::Params(format!("unknown vertical alignment `{x}`"))),
|
||||
};
|
||||
// `null` clears a preferred size (automatic); a number sets it.
|
||||
let size = |k: &str, lo: f32, hi: f32| -> Option<Option<f32>> {
|
||||
match v.get(k)? {
|
||||
Value::Null => Some(None),
|
||||
x => x.as_f64().map(|f| f as f32).filter(|f| f.is_finite()).map(|f| Some(f.clamp(lo, hi))),
|
||||
}
|
||||
};
|
||||
let width = size("width", 6.0, 1584.0);
|
||||
let width_pct = p::f32(v, "widthPct").map(|x| x.clamp(1.0, 100.0));
|
||||
let indent = p::f32(v, "indent").map(|x| x.clamp(-1584.0, 1584.0));
|
||||
let row_height = size("rowHeight", 1.0, 1584.0);
|
||||
let allow_break = p::bool(v, "allowBreak");
|
||||
let header = p::bool(v, "headerRow");
|
||||
let column_width = p::f32(v, "columnWidth").map(|x| x.clamp(6.0, 1584.0));
|
||||
let cell_width = size("cellWidth", 6.0, 1584.0);
|
||||
let rtl = p::bool(v, "rtl");
|
||||
with_table(s, |t| {
|
||||
if let Some(a) = align {
|
||||
t.props.align = Some(a);
|
||||
}
|
||||
if let Some(on) = rtl {
|
||||
t.props.bidi_visual = on;
|
||||
}
|
||||
if let Some(i) = indent {
|
||||
t.props.indent = Some(i);
|
||||
}
|
||||
if let Some(cw) = column_width {
|
||||
set_column_width(t, r, c, cw);
|
||||
}
|
||||
if let Some(w) = width {
|
||||
t.props.width = w;
|
||||
t.props.width_pct = None;
|
||||
}
|
||||
if let Some(pct) = width_pct {
|
||||
t.props.width_pct = Some(pct);
|
||||
}
|
||||
if let Some(row) = t.rows.get_mut(r) {
|
||||
if let Some(h) = row_height {
|
||||
row.props.height = h;
|
||||
row.props.height_rule = if h.is_some() { rule.unwrap_or(HeightRule::AtLeast) } else { HeightRule::Auto };
|
||||
} else if let Some(rule) = rule
|
||||
&& row.props.height.is_some()
|
||||
{
|
||||
row.props.height_rule = rule;
|
||||
}
|
||||
if let Some(b) = allow_break {
|
||||
row.props.cant_split = !b;
|
||||
}
|
||||
if let Some(h) = header {
|
||||
row.props.header = h;
|
||||
}
|
||||
if let Some(cl) = row.cells.get_mut(c) {
|
||||
if let Some(w) = cell_width {
|
||||
cl.props.width = w;
|
||||
}
|
||||
if let Some(va) = valign {
|
||||
cl.props.valign = Some(va);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Table Layout › Text Direction: turn the selected cells' text. Without a value it cycles the
|
||||
/// caret cell's direction (horizontal → down → up) and gives every selected cell the result.
|
||||
pub(crate) fn text_direction(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let dir = match p::str(v, "value") {
|
||||
Some("horizontal" | "lrTb") => TextDirection::Horizontal,
|
||||
Some("down" | "tbRl") => TextDirection::Down,
|
||||
Some("up" | "btLr") => TextDirection::Up,
|
||||
Some(x) => return Err(CmdError::Params(format!("unknown text direction `{x}`"))),
|
||||
None => {
|
||||
let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into()))?;
|
||||
t.rows.get(r).and_then(|row| row.cells.get(c)).map(|cl| cl.props.text_direction).unwrap_or_default().next()
|
||||
}
|
||||
};
|
||||
with_cells(s, |cl| cl.props.text_direction = dir)
|
||||
}
|
||||
|
||||
/// Bump the revision of every paragraph in a table (table style changes alter their layout).
|
||||
pub(crate) fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> {
|
||||
let story = s.sel.focus.story;
|
||||
let paths: Vec<Path> = s.doc.para_paths(story).into_iter().filter(|p| p.0.len() > tp.0.len() && p.0.starts_with(&tp.0)).collect();
|
||||
for p in paths {
|
||||
s.doc.para_mut(story, &p)?.touch();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn fix_caret(s: &mut Session, tp: &Path, r: usize, c: usize) -> CmdResult {
|
||||
let story = s.sel.focus.story;
|
||||
let Some(t) = s.doc.table(story, tp) else {
|
||||
s.sel = Selection::caret(s.doc.clamp(&Pos { story, path: tp.clone(), off: 0 }));
|
||||
return sel_result(s);
|
||||
};
|
||||
let r = r.min(t.rows.len().saturating_sub(1));
|
||||
let c = c.min(t.rows.get(r).map(|x| x.cells.len().saturating_sub(1)).unwrap_or(0));
|
||||
let mut path = tp.0.clone();
|
||||
path.extend([r as u32, c as u32, 0]);
|
||||
s.sel = Selection::caret(Pos { story, path: Path(path), off: 0 });
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
pub(super) fn borders(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let kind = p::str(v, "kind").unwrap_or("all");
|
||||
let w = p::f32(v, "width").unwrap_or(0.5).clamp(0.25, 6.0);
|
||||
let color = p::str(v, "color").and_then(Rgb::parse);
|
||||
let b = Border { style: BorderStyle::Single, width: w, color, space: 0.0 };
|
||||
let none = Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 };
|
||||
with_table(s, |t| {
|
||||
let mut cur = t.props.borders.unwrap_or_default();
|
||||
match kind {
|
||||
"all" => cur = Borders::all(b),
|
||||
"none" => cur = Borders::all(none),
|
||||
"outside" => {
|
||||
cur.top = Some(b);
|
||||
cur.bottom = Some(b);
|
||||
cur.left = Some(b);
|
||||
cur.right = Some(b);
|
||||
// The cleared inside sides must not fall back to the table style (Word writes nil).
|
||||
cur.between = Some(none);
|
||||
cur.inside_v = Some(none);
|
||||
}
|
||||
"inside" => {
|
||||
cur.between = Some(b);
|
||||
cur.inside_v = Some(b);
|
||||
// The cleared outer sides must not fall back to the table style (Word writes nil).
|
||||
cur.top = Some(none);
|
||||
cur.bottom = Some(none);
|
||||
cur.left = Some(none);
|
||||
cur.right = Some(none);
|
||||
}
|
||||
"top" => cur.top = Some(b),
|
||||
"bottom" => cur.bottom = Some(b),
|
||||
"left" => cur.left = Some(b),
|
||||
"right" => cur.right = Some(b),
|
||||
_ => {}
|
||||
}
|
||||
t.props.borders = Some(cur);
|
||||
for r in &mut t.rows {
|
||||
for c in &mut r.cells {
|
||||
c.props.borders = None;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use super::cells::{cell, cell_at, grid_cols, row_ends, selected_cells};
|
||||
use super::*;
|
||||
|
||||
/// Ctrl+A in a table, as in Word: the cell, then its row, then the table. A nested table that
|
||||
/// is already fully selected grows to the cell around it. Returns `None` when the next step is
|
||||
/// the whole story (not in a table, or the top-level table is already selected).
|
||||
pub(crate) fn grow_select_all(s: &mut Session) -> Option<CmdResult> {
|
||||
let story = s.sel.focus.story;
|
||||
if let Some(cr) = s.cell_range().filter(|cr| cr.story == story).cloned() {
|
||||
let tp = cr.table.clone();
|
||||
let (last_row, last_col, one_cell) = {
|
||||
let t = s.doc.table(story, &tp)?;
|
||||
let last_row = t.rows.len().saturating_sub(1);
|
||||
let last_col = t.cols().saturating_sub(1);
|
||||
let one_cell = cr.rows.0 == cr.rows.1
|
||||
&& t.rows.get(cr.rows.0).is_some_and(|row| (0..row.cells.len()).any(|c| grid_cols(t, cr.rows.0, c) == cr.cols));
|
||||
(last_row, last_col, one_cell)
|
||||
};
|
||||
if cr.rows == (0, last_row) && cr.cols == (0, last_col) {
|
||||
return tp.cell().map(|(otp, or, oc)| select_cells(s, &otp, Some((or, Some(oc)))));
|
||||
}
|
||||
if cr.rows.0 == cr.rows.1 && cr.cols == (0, last_col) {
|
||||
return Some(select_cells(s, &tp, None));
|
||||
}
|
||||
if one_cell {
|
||||
return Some(select_cells(s, &tp, Some((cr.rows.0, None))));
|
||||
}
|
||||
return Some(select_cells(s, &tp, None));
|
||||
}
|
||||
let (a, b) = s.sel.ordered();
|
||||
let (tp, r, c) = a.path.cell()?;
|
||||
if b.path.cell() != Some((tp.clone(), r, c)) {
|
||||
return None;
|
||||
}
|
||||
Some(select_cells(s, &tp, Some((r, Some(c)))))
|
||||
}
|
||||
|
||||
pub(super) fn select_cells(s: &mut Session, tp: &Path, which: Option<(usize, Option<usize>)>) -> CmdResult {
|
||||
let story = s.sel.focus.story;
|
||||
let Some(t) = s.doc.table(story, tp) else { return sel_result(s) };
|
||||
let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1));
|
||||
let (rows, cols) = match which {
|
||||
None => ((0, last_row), (0, last_col)),
|
||||
Some((r, None)) => ((r, r), (0, last_col)),
|
||||
Some((r, Some(c))) => ((r, r), grid_cols(t, r, c)),
|
||||
};
|
||||
select_range(s, story, tp, rows, cols)
|
||||
}
|
||||
|
||||
/// Table Layout › Table › Select › Select Column: the grid columns of the selected cells, every row.
|
||||
pub(super) fn select_column(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let story = s.sel.focus.story;
|
||||
let t = s.doc.table(story, &tp).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into()))?;
|
||||
let (mut g0, mut g1) = grid_cols(t, r, c);
|
||||
for (r, c) in selected_cells(s) {
|
||||
let (a, b) = grid_cols(t, r, c);
|
||||
(g0, g1) = (g0.min(a), g1.max(b));
|
||||
}
|
||||
let rows = (0, t.rows.len().saturating_sub(1));
|
||||
select_range(s, story, &tp, rows, (g0, g1))
|
||||
}
|
||||
|
||||
/// Select whole cells of table `tp`: rows `rows`, and of them every cell reaching into grid
|
||||
/// columns `cols` (both inclusive, either order). The selection runs from the first cell's start
|
||||
/// to the last cell's end; a block of the cells' paragraphs (`CellRange`) says which cells they
|
||||
/// are, so the app shades them and, when they aren't one run of the text (part of a column),
|
||||
/// edits and formatting leave the cells between the corners alone.
|
||||
pub(crate) fn select_range(s: &mut Session, story: StoryRef, tp: &Path, rows: (usize, usize), cols: (usize, usize)) -> CmdResult {
|
||||
let t = s.doc.table(story, tp).ok_or_else(|| CmdError::Failed("there's no table there".into()))?;
|
||||
let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1));
|
||||
let rows = (rows.0.min(rows.1).min(last_row), rows.0.max(rows.1).min(last_row));
|
||||
let cols = (cols.0.min(cols.1).min(last_col), cols.0.max(cols.1).min(last_col));
|
||||
let stream = rows.0 == rows.1 || (cols.0 == 0 && cols.1 == last_col);
|
||||
let range = crate::CellRange { story, table: tp.clone(), rows, cols, stream };
|
||||
let picked: std::collections::BTreeSet<(usize, usize)> = range.cells(t).into_iter().collect();
|
||||
// Every paragraph of the picked cells (nested tables' too), in document order.
|
||||
let depth = tp.0.len();
|
||||
let in_picked = |q: &Path| match (q.0.get(depth), q.0.get(depth + 1)) {
|
||||
(Some(r), Some(c)) => q.0.len() > depth + 2 && q.0.starts_with(&tp.0) && picked.contains(&(*r as usize, *c as usize)),
|
||||
_ => false,
|
||||
};
|
||||
let segments: Vec<(Pos, Pos)> = s
|
||||
.doc
|
||||
.para_paths(story)
|
||||
.into_iter()
|
||||
.filter(|q| in_picked(q))
|
||||
.map(|q| {
|
||||
let len = s.doc.para(story, &q).map_or(0, Paragraph::len);
|
||||
(Pos { story, path: q.clone(), off: 0 }, Pos { story, path: q, off: len })
|
||||
})
|
||||
.collect();
|
||||
let (Some(first), Some(last)) = (segments.first(), segments.last()) else {
|
||||
return Err(CmdError::Failed("there are no cells to select there".into()));
|
||||
};
|
||||
// The end corner is in the last cell's own last paragraph (not a nested table's), so it
|
||||
// names the cell.
|
||||
let focus = segments.iter().rev().find(|(_, b)| b.path.0.len() == depth + 3).map_or(&last.1, |(_, b)| b).clone();
|
||||
let sel = Selection { anchor: first.0.clone(), focus };
|
||||
s.sel = sel.clone();
|
||||
s.column = Some(crate::ColumnBlock { sel, anchor_x: 0.0, focus_x: 0.0, segments, cells: Some(range) });
|
||||
s.column_mode = false;
|
||||
s.widened = None;
|
||||
s.goal_x = None;
|
||||
s.pending = None;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Extend the selection from its anchor to `to` the way Word does around tables: from one cell
|
||||
/// into another of the same table it takes whole cells, the rectangle between them; between a
|
||||
/// table's cell and text outside the table, whole rows, from the cell's row out to the table's
|
||||
/// edge facing the other end. Where the selection really runs is kept (`Session::sel_ends`), so
|
||||
/// extending it again goes on from there.
|
||||
pub(crate) fn extend_selection(s: &mut Session, to: Pos) {
|
||||
let ends = Selection { anchor: s.sel.anchor.clone(), focus: to };
|
||||
// Shift+Up and Shift+Down keep their goal column.
|
||||
let goal_x = s.goal_x;
|
||||
s.sel = ends.clone();
|
||||
s.column = None;
|
||||
widen(s, &ends);
|
||||
s.widened = if s.sel == ends { None } else { Some(crate::session::Widened { sel: s.sel.clone(), ends }) };
|
||||
s.goal_x = goal_x;
|
||||
}
|
||||
|
||||
/// Shift+Right (Shift+Left in right-to-left text) out of the cell the selection is in takes that
|
||||
/// cell whole first, as Word does (its end-of-cell mark); the next press goes on into the next
|
||||
/// cell. `ends` is where the selection ran before the step, `to` where the step went, `cells` the
|
||||
/// whole cells selected before it. Whether it took the cell.
|
||||
pub(crate) fn select_cell_first(s: &mut Session, ends: &Selection, to: &Pos, cells: Option<&crate::CellRange>) -> bool {
|
||||
let (a, f) = (&ends.anchor, &ends.focus);
|
||||
if a.story != f.story || to.story != f.story || to <= f {
|
||||
return false;
|
||||
}
|
||||
// The innermost cell both ends are in.
|
||||
let mut cell = None;
|
||||
let mut i = 0;
|
||||
while let (Some(x), Some(y)) = (cell_at(&a.path.0, i), cell_at(&f.path.0, i)) {
|
||||
if x != y {
|
||||
break;
|
||||
}
|
||||
cell = Some(x);
|
||||
i += 3;
|
||||
}
|
||||
let Some((tp, r, c)) = cell else { return false };
|
||||
// Still in the cell, or that cell alone is already selected: nothing to take first.
|
||||
if f.path.0.get(..tp.0.len() + 2).is_some_and(|prefix| to.path.0.starts_with(prefix)) {
|
||||
return false;
|
||||
}
|
||||
let Some(cols) = s.doc.table(f.story, &tp).map(|t| grid_cols(t, r, c)) else { return false };
|
||||
if cells.is_some_and(|cr| cr.story == f.story && cr.table == tp && cr.rows == (r, r) && cr.cols == cols) {
|
||||
return false;
|
||||
}
|
||||
select_range(s, f.story, &tp, (r, r), cols).is_ok()
|
||||
}
|
||||
|
||||
/// Widen the selection between `ends` (already `s.sel`) by Word's table rule (see
|
||||
/// `extend_selection`).
|
||||
fn widen(s: &mut Session, ends: &Selection) {
|
||||
let (a, f) = (&ends.anchor, &ends.focus);
|
||||
if a.story != f.story {
|
||||
return;
|
||||
}
|
||||
// Down through the tables both ends are in, one nesting level (three indexes) at a time.
|
||||
let mut i = 0;
|
||||
while let (Some(ab), Some(fb)) = (a.path.0.get(i), f.path.0.get(i)) {
|
||||
match (cell_at(&a.path.0, i), cell_at(&f.path.0, i)) {
|
||||
// The same cell: look inside it.
|
||||
(Some(x), Some(y)) if ab == fb && x == y => i += 3,
|
||||
// Two cells of one table: the rectangle between them.
|
||||
(Some((tp, ra, ca)), Some((_, rf, cf))) if ab == fb => {
|
||||
let cols = s.doc.table(a.story, &tp).map(|t| {
|
||||
let ((g0, g1), (h0, h1)) = (grid_cols(t, ra, ca), grid_cols(t, rf, cf));
|
||||
(g0.min(h0), g1.max(h1))
|
||||
});
|
||||
if let Some(cols) = cols {
|
||||
// A table without paragraphs keeps the plain selection.
|
||||
let _ = select_range(s, a.story, &tp, (ra, rf), cols);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Different blocks: an end in a table takes its whole row, out to the table's edge
|
||||
// facing the other end.
|
||||
(x, y) if ab != fb => {
|
||||
let forward = ab < fb;
|
||||
if let Some((tp, r, _)) = x
|
||||
&& let Some((start, end)) = row_ends(&s.doc, a.story, &tp, r)
|
||||
{
|
||||
s.sel.anchor = if forward { start } else { end };
|
||||
}
|
||||
if let Some((tp, r, _)) = y
|
||||
&& let Some((start, end)) = row_ends(&s.doc, f.story, &tp, r)
|
||||
{
|
||||
s.sel.focus = if forward { end } else { start };
|
||||
}
|
||||
return;
|
||||
}
|
||||
// One paragraph.
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
use super::cells::{cell, selected_cells};
|
||||
use super::clear::{cell_prefix, guard_delete, text_spans};
|
||||
use super::layout::{fix_caret, sync_cell_widths};
|
||||
use super::*;
|
||||
|
||||
/// Insert Above / Below: rows like the caret's, as many as the selection spans; the caret goes into the first.
|
||||
pub(in crate::cmd) fn row(s: &mut Session, below: bool) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let cells = selected_cells(s);
|
||||
let n = cells.iter().map(|x| x.0).collect::<std::collections::BTreeSet<_>>().len().max(1);
|
||||
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
|
||||
for _ in 0..n {
|
||||
t.insert_row(if below { r + 1 } else { r }, r);
|
||||
}
|
||||
fix_caret(s, &tp, if below { r + 1 } else { r }, c)
|
||||
}
|
||||
|
||||
/// Insert Left / Right: a column beside the caret's cell; the caret goes into it.
|
||||
pub(in crate::cmd) fn col(s: &mut Session, right: bool) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
|
||||
let g = t.grid_col(r, c);
|
||||
let span = t.rows.get(r).and_then(|x| x.cells.get(c)).map(|x| x.span()).unwrap_or(1);
|
||||
t.insert_col(if right { g + span } else { g });
|
||||
// Keep the table within its width: shrink all columns proportionally.
|
||||
let total: f32 = t.grid.iter().sum();
|
||||
let before: f32 = total - t.grid.get(if right { g + span } else { g }).copied().unwrap_or(0.0);
|
||||
if before > 0.0 {
|
||||
let k = before / total;
|
||||
t.grid.iter_mut().for_each(|w| *w *= k);
|
||||
}
|
||||
// The inserted cells carry the pre-scale column width, and the layout prefers a cell width
|
||||
// over the grid: sync them, or the shrink never reaches the layout or the saved `w:tcW`.
|
||||
sync_cell_widths(t);
|
||||
fix_caret(s, &tp, r, if right { c + 1 } else { c })
|
||||
}
|
||||
|
||||
pub(super) fn del_row(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (tp, r, _) = cell(s)?;
|
||||
let rows: std::collections::BTreeSet<usize> = selected_cells(s).into_iter().map(|x| x.0).collect();
|
||||
let story = s.sel.focus.story;
|
||||
if rows.len() >= s.doc.table(story, &tp).map_or(0, |t| t.rows.len()) {
|
||||
return del_table(s, &Value::Null);
|
||||
}
|
||||
let prefixes: Vec<Vec<u32>> = rows.iter().map(|rr| tp.0.iter().copied().chain([*rr as u32]).collect()).collect();
|
||||
let spans = text_spans(s, story, &prefixes);
|
||||
if let Some(done) = guard_delete(s, &spans)? {
|
||||
return Ok(done);
|
||||
}
|
||||
let t = s.doc.table_mut(story, &tp)?;
|
||||
for rr in rows.iter().rev() {
|
||||
t.delete_row(*rr);
|
||||
}
|
||||
fix_caret(s, &tp, r, 0)
|
||||
}
|
||||
|
||||
pub(super) fn del_col(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let cells = selected_cells(s);
|
||||
let story = s.sel.focus.story;
|
||||
let Some(t) = s.doc.table(story, &tp) else { return Err(CmdError::Failed("no table".into())) };
|
||||
if t.grid.len() <= 1 {
|
||||
return del_table(s, &Value::Null);
|
||||
}
|
||||
// Every grid column a selected cell covers goes (the caret's alone without a selection).
|
||||
let mut cols = std::collections::BTreeSet::new();
|
||||
for (rr, cc) in cells {
|
||||
let g = t.grid_col(rr, cc);
|
||||
let span = t.rows.get(rr).and_then(|x| x.cells.get(cc)).map_or(1, wordcraft_doc::Cell::span);
|
||||
cols.extend(g..g.saturating_add(span).min(t.grid.len()));
|
||||
}
|
||||
if cols.is_empty() {
|
||||
cols.insert(t.grid_col(r, c));
|
||||
}
|
||||
if cols.len() >= t.grid.len() {
|
||||
return del_table(s, &Value::Null);
|
||||
}
|
||||
// The cells that go with those columns, and their text: those all of whose columns go
|
||||
// (a merged cell that also spans a kept column just narrows, keeping its text).
|
||||
let mut prefixes = Vec::new();
|
||||
for (rr, row) in t.rows.iter().enumerate() {
|
||||
for (cc, x) in row.cells.iter().enumerate() {
|
||||
let g = t.grid_col(rr, cc);
|
||||
let covered = g..g.saturating_add(x.span()).min(t.grid.len());
|
||||
if !covered.is_empty() && covered.clone().all(|k| cols.contains(&k)) {
|
||||
prefixes.push(cell_prefix(&tp, rr, cc));
|
||||
}
|
||||
}
|
||||
}
|
||||
let spans = text_spans(s, story, &prefixes);
|
||||
if let Some(done) = guard_delete(s, &spans)? {
|
||||
return Ok(done);
|
||||
}
|
||||
let t = s.doc.table_mut(story, &tp)?;
|
||||
for g in cols.iter().rev() {
|
||||
t.delete_col(*g);
|
||||
}
|
||||
// Rows left without cells are dropped; if that was every row, no table is left.
|
||||
if t.rows.is_empty() {
|
||||
return del_table(s, &Value::Null);
|
||||
}
|
||||
fix_caret(s, &tp, r, c.saturating_sub(1))
|
||||
}
|
||||
|
||||
pub(super) fn del_table(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let story = s.sel.focus.story;
|
||||
let spans = text_spans(s, story, std::slice::from_ref(&tp.0));
|
||||
if let Some(done) = guard_delete(s, &spans)? {
|
||||
return Ok(done);
|
||||
}
|
||||
s.doc.remove_block(story, &tp)?;
|
||||
s.sel = Selection::caret(s.doc.clamp(&Pos { story, path: tp, off: 0 }));
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
pub(super) fn merge(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let cells = selected_cells(s);
|
||||
if cells.len() < 2 {
|
||||
return Err(CmdError::Failed("select two or more cells to merge".into()));
|
||||
}
|
||||
let story = s.sel.focus.story;
|
||||
let (r0, r1) = (cells.iter().map(|x| x.0).min().unwrap_or(0), cells.iter().map(|x| x.0).max().unwrap_or(0));
|
||||
let t = s.doc.table_mut(story, &tp)?;
|
||||
let c0 = cells.iter().map(|x| x.1).min().unwrap_or(0);
|
||||
let c1 = cells.iter().map(|x| x.1).max().unwrap_or(0);
|
||||
let g0 = t.grid_col(r0, c0);
|
||||
let g1 = t.grid_col(r0, c1) + t.rows.get(r0).and_then(|r| r.cells.get(c1)).map(|c| c.span()).unwrap_or(1) - 1;
|
||||
t.merge(r0, r1, g0, g1);
|
||||
fix_caret(s, &tp, r0, c0)
|
||||
}
|
||||
|
||||
/// The selected cells as Split Cells sees them: one entry per cell, a vertically merged cell
|
||||
/// once (its top row), each as (top row, grid column).
|
||||
pub fn split_targets(s: &Session) -> Vec<(usize, usize)> {
|
||||
let Ok((tp, _, _)) = cell(s) else { return Vec::new() };
|
||||
let Some(t) = s.doc.table(s.sel.focus.story, &tp) else { return Vec::new() };
|
||||
let mut v: Vec<(usize, usize)> = selected_cells(s)
|
||||
.into_iter()
|
||||
.filter(|&(r, c)| t.rows.get(r).is_some_and(|row| c < row.cells.len()))
|
||||
.map(|(r, c)| (t.vertical_extent(r, c).map_or(r, |(top, _)| top), t.grid_col(r, c)))
|
||||
.collect();
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
/// Split Cells: the caret's cell, or the selected cells (merged first unless `mergeFirst` is
|
||||
/// false), into `columns` × `rows` cells.
|
||||
pub(super) fn split(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let cols = p::u64(v, "columns").unwrap_or(2).clamp(1, 63) as usize;
|
||||
let rows = p::u64(v, "rows").unwrap_or(1).clamp(1, 63) as usize;
|
||||
let mut cells = split_targets(s);
|
||||
let merge_first = cells.len() > 1 && p::bool(v, "mergeFirst").unwrap_or(true);
|
||||
if merge_first {
|
||||
// As in Word: the merged cell splits into at most the rows it covered.
|
||||
let mut sel_rows: Vec<usize> = selected_cells(s).iter().map(|&(r, _)| r).collect();
|
||||
sel_rows.sort_unstable();
|
||||
sel_rows.dedup();
|
||||
if rows > sel_rows.len() {
|
||||
return Err(CmdError::Params(format!("The number must be between 1 and {}.", sel_rows.len())));
|
||||
}
|
||||
merge(s, &json!({}))?;
|
||||
cells.clear();
|
||||
} else if cells.len() > 1 && rows > 1 {
|
||||
// Word greys out the row count when the cells split one by one.
|
||||
return Err(CmdError::Params("several cells split one by one keep their rows: `rows` must be 1".into()));
|
||||
}
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
|
||||
if cells.is_empty() {
|
||||
cells.push((t.vertical_extent(r, c).map_or(r, |(top, _)| top), t.grid_col(r, c)));
|
||||
}
|
||||
// As in Word: a merged cell splits into 1 to its height rows; it never adds rows.
|
||||
if let [(top, g)] = cells[..]
|
||||
&& let Some((_, k)) = t.cell_at_grid(top, g).and_then(|ci| t.vertical_extent(top, ci))
|
||||
&& k > 1
|
||||
&& rows > k
|
||||
{
|
||||
return Err(CmdError::Params(format!("The number must be between 1 and {k}.")));
|
||||
}
|
||||
// Rightmost grid column first, bottom up: a split only adds grid columns inside the cell it
|
||||
// splits, so the cells still to split keep their grid places. Each is found again by its grid
|
||||
// column when its turn comes.
|
||||
let first = cells.first().copied();
|
||||
cells.sort_unstable_by_key(|&(top, g)| std::cmp::Reverse((g, top)));
|
||||
for &(top, g) in &cells {
|
||||
let Some(ci) = t.cell_at_grid(top, g) else { continue };
|
||||
// 1 × 1 on a cell that was already merged down changes nothing in Word, at any height; a
|
||||
// cell just merged by Merge cells before split stays one cell.
|
||||
let height = t.vertical_extent(top, ci).map_or(1, |(_, k)| k);
|
||||
if !merge_first && cols == 1 && rows == 1 && height > 1 {
|
||||
continue;
|
||||
}
|
||||
if height > 1 && rows > 1 && !height.is_multiple_of(rows) {
|
||||
return Err(CmdError::Params(format!("The number must be a divisor of {height}.")));
|
||||
}
|
||||
if !t.split_cell_grid(top, ci, cols, rows) {
|
||||
return Err(CmdError::Failed(format!("can't split into {rows} rows: the table would get too many rows")));
|
||||
}
|
||||
}
|
||||
// The caret goes to the first part of the first cell (the top of a merged cell).
|
||||
let (top, g) = first.unwrap_or((r, 0));
|
||||
let c = t.cell_at_grid(top, g).unwrap_or(c);
|
||||
fix_caret(s, &tp, top, c)
|
||||
}
|
||||
|
||||
pub(super) fn split_table(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (tp, r, _) = cell(s)?;
|
||||
if r == 0 {
|
||||
return Err(CmdError::Failed("can't split at the first row".into()));
|
||||
}
|
||||
let story = s.sel.focus.story;
|
||||
let t = s.doc.table_mut(story, &tp)?;
|
||||
let tail = t.split_off(r);
|
||||
let after = tp.with_last(tp.last() + 1);
|
||||
s.doc.insert_block(story, &after, Block::Para(Paragraph::new()))?;
|
||||
s.doc.insert_block(story, &after.with_last(after.last() + 1), Block::Table(tail))?;
|
||||
s.sel = Selection::caret(Pos { story, path: after, off: 0 });
|
||||
sel_result(s)
|
||||
}
|
||||
Reference in New Issue
Block a user