refactor(engine): split cmd/table.rs into table/

This commit is contained in:
aiongg
2026-10-26 21:16:49 +00:00
parent a82063e50a
commit 8f10bd1b9f
7 changed files with 1429 additions and 1391 deletions
File diff suppressed because it is too large Load Diff
+88
View File
@@ -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 }))
}
+145
View File
@@ -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())
})
}
+470
View File
@@ -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)
}
+280
View File
@@ -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;
}
}
})
}
+201
View File
@@ -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,
}
}
}
+222
View File
@@ -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)
}