diff --git a/crates/engine/src/cmd/data/validation.rs b/crates/engine/src/cmd/data/validation.rs index 2217a372a..e02ef06b3 100644 --- a/crates/engine/src/cmd/data/validation.rs +++ b/crates/engine/src/cmd/data/validation.rs @@ -18,21 +18,71 @@ fn op_param(s: Option<&str>) -> CfOperator { } } -/// `r` minus `cut`, as up to four non-overlapping rectangles. -fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec { - let Some(i) = r.intersection(cut) else { return vec![r] }; - let mut out = Vec::new(); - if i.start.row > r.start.row { - out.push(RangeRef::new(r.start, CellRef::new(i.start.row - 1, r.end.col))); +/// Removes a set of cuts from one validation range in a row sweep, updating the result once +/// instead of fragmenting it again for every cut. +fn subtract_ranges(r: RangeRef, cuts: &[RangeRef]) -> Vec { + let mut events = std::collections::BTreeMap::>::new(); + for cut in cuts { + let Some(i) = r.intersection(cut) else { continue }; + let columns = (i.start.col, i.end.col); + events.entry(u64::from(i.start.row)).or_default().push((columns, true)); + events.entry(u64::from(i.end.row) + 1).or_default().push((columns, false)); } - if i.end.row < r.end.row { - out.push(RangeRef::new(CellRef::new(i.end.row + 1, r.start.col), r.end)); + if events.is_empty() { + return vec![r]; } - if i.start.col > r.start.col { - out.push(RangeRef::new(CellRef::new(i.start.row, r.start.col), CellRef::new(i.end.row, i.start.col - 1))); - } - if i.end.col < r.end.col { - out.push(RangeRef::new(CellRef::new(i.start.row, i.end.col + 1), CellRef::new(i.end.row, r.end.col))); + events.entry(u64::from(r.end.row) + 1).or_default(); + + let mut active = std::collections::BTreeMap::<(u32, u32), usize>::new(); + let mut out: Vec = Vec::new(); + let mut last_by_columns = std::collections::BTreeMap::<(u32, u32), usize>::new(); + let mut previous_row = u64::from(r.start.row); + for (row, changes) in events { + if previous_row < row { + let start_row = previous_row as u32; + let end_row = (row - 1).min(u64::from(r.end.row)) as u32; + let mut covered = Vec::<(u32, u32)>::new(); + for &(start, end) in active.keys() { + match covered.last_mut() { + Some(last) if start <= last.1.saturating_add(1) => last.1 = last.1.max(end), + _ => covered.push((start, end)), + } + } + let mut next_col = u64::from(r.start.col); + let mut remaining = Vec::new(); + for (start, end) in covered { + if next_col < u64::from(start) { + remaining.push((next_col as u32, start - 1)); + } + next_col = u64::from(end) + 1; + } + if next_col <= u64::from(r.end.col) { + remaining.push((next_col as u32, r.end.col)); + } + for (start_col, end_col) in remaining { + let columns = (start_col, end_col); + if let Some(&index) = last_by_columns.get(&columns) + && out[index].end.row.saturating_add(1) == start_row + { + out[index].end.row = end_row; + } else { + let index = out.len(); + out.push(RangeRef::new(CellRef::new(start_row, start_col), CellRef::new(end_row, end_col))); + last_by_columns.insert(columns, index); + } + } + } + for (columns, add) in changes { + if add { + *active.entry(columns).or_default() += 1; + } else if let Some(count) = active.get_mut(&columns) { + *count -= 1; + if *count == 0 { + active.remove(&columns); + } + } + } + previous_row = row; } out } @@ -43,9 +93,7 @@ fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec { pub(in super::super) fn remove_validation_cells(validations: &mut Vec, cuts: &[RangeRef]) { for dv in validations.iter_mut() { let before = RangeRef::top_left(&dv.ranges); - for cut in cuts { - dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect(); - } + dv.ranges = dv.ranges.iter().flat_map(|r| subtract_ranges(*r, cuts)).collect(); if let (Some(from), Some(to)) = (before, RangeRef::top_left(&dv.ranges)) { dv.f1 = move_rule_formula(&dv.f1, from, to); dv.f2 = dv.f2.take().map(|f| move_rule_formula(&f, from, to)); diff --git a/crates/engine/src/cmd/edit/paste_special.rs b/crates/engine/src/cmd/edit/paste_special.rs index 352c8e201..ab662642a 100644 --- a/crates/engine/src/cmd/edit/paste_special.rs +++ b/crates/engine/src/cmd/edit/paste_special.rs @@ -4,74 +4,7 @@ use super::{ target_sheet, }; -fn tiled_cells(rows: u32, cols: u32, tiles_r: u32, tiles_c: u32) -> impl Iterator + Clone { - (0..tiles_r).flat_map(move |tr| (0..tiles_c).flat_map(move |tc| (0..rows).flat_map(move |r| (0..cols).map(move |c| (tr, tc, r, c))))) -} - -fn apply_operation(op: &str, old: &Cell, new: &mut Cell) { - let operand = new.value.clone(); - if let (Ok(x), Ok(y)) = (old.value.to_number(), operand.to_number()) - && (old.value.is_number() || old.value.is_empty() || old.value.is_text()) - && (operand.is_number() || operand.is_empty()) - { - new.value = match op { - "add" => Value::number(x + y), - "subtract" => Value::number(x - y), - "multiply" => Value::number(x * y), - "divide" if y == 0.0 => Value::Error(gridcraft_core::CellError::Div0), - "divide" => Value::number(x / y), - _ => operand.clone(), - }; - } else if (old.value.is_text() || old.value.is_error()) && (operand.is_number() || operand.is_empty()) { - new.value = old.value.clone(); - } - if let Some(formula) = &old.formula - && let Ok(y) = operand.to_number() - && (operand.is_number() || operand.is_empty()) - && let Some(symbol) = operation_symbol(op) - { - let text = format!("=({}){symbol}{}", formula.text, gridcraft_core::number_to_text(y)); - new.formula = Some(std::sync::Arc::new(Formula::new(&text))); - new.value = Value::Empty; - } -} - -fn operation_symbol(op: &str) -> Option { - match op { - "add" => Some('+'), - "subtract" => Some('-'), - "multiply" => Some('*'), - "divide" => Some('/'), - _ => None, - } -} - -fn copy_merges( - cx: &mut crate::cmd::Ctx, - dest_sheet: usize, - src_sheet: &gridcraft_model::Sheet, - src: RangeRef, - at: CellRef, - what: What, - transpose: bool, -) -> Result<()> { - if !matches!(what, What::All | What::Formats) || transpose { - return Ok(()); - } - let merges: Vec = src_sheet.merges.iter().filter(|merge| src.contains_range(merge)).copied().collect(); - for merge in merges { - let row_offset = at.row as i64 - src.start.row as i64; - let col_offset = at.col as i64 - src.start.col as i64; - if let (Some(start), Some(end)) = (merge.start.offset(row_offset, col_offset), merge.end.offset(row_offset, col_offset)) { - let moved = RangeRef::new(start, end); - let sheet = cx.sheet_mut(dest_sheet)?; - sheet.merges.retain(|existing| !existing.intersects(&moved)); - sheet.merges.push(moved); - } - } - Ok(()) -} - +#[allow(clippy::cognitive_complexity)] // The command applies the selected paste mode in one atomic edit. pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) }; let what = match str_param(p, "what").unwrap_or("all") { @@ -97,7 +30,11 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { let dest_sheet = target_sheet(s, p)?; let sel = s.doc()?.selection.current(); let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start); - let src = clip.range; + // Whole columns or rows paste whole (the copy keeps only their used part as its text), so + // what the destination columns hold further down is replaced too, as in Excel. The cells are + // visited sparsely below, so this costs what the columns hold, not 1,048,576 rows. + let whole = clip.areas.is_empty() && (clip.selected.is_full_cols() || clip.selected.is_full_rows()) && clip.selected.contains_range(&clip.range); + let src = if whole { clip.selected } else { clip.range }; let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) }; // Tile the copy over a larger destination that is a multiple of its size. let tile = sel.height() % h == 0 @@ -108,17 +45,25 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { && !sel.is_full_rows() && sel.count() <= 4_000_000; let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) }; + // Excel refuses a paste that would run off the sheet (whole columns pasted below row 1, whole + // rows right of column A, a whole column transposed) instead of pasting part of it. + if at.offset(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1).is_none() { + return Err(EngineError::Other("The copied cells don't fit here: paste them where the whole copied area fits on the sheet.".into())); + } + // Paste Link writes a formula into every cell, blank ones included, so it is the one paste + // whose work grows with the area rather than with what the source holds. + let link_src = clip.range; + if link && link_src.count().saturating_mul(u64::from(tiles_r) * u64::from(tiles_c)) > MAX_PASTE_LINK_CELLS { + return Err(EngineError::Other("Too many cells to paste as links at once.".into())); + } let same_doc = s.doc()?.uid == clip.doc_uid; let cut_move = clip.cut && same_doc && what == What::All; let src_wb = clip.wb.clone(); let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) }; let src_sheet_name = src_sheet.name.clone(); - if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() { - // Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing. - return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into())); - } edit(s, |cx| { // Styles from another workbook must be re-interned. + let dest_name = cx.wb.sheet(dest_sheet).map(|sh| sh.name.clone()).unwrap_or_default(); let mut style_map = std::collections::HashMap::new(); let mut map_style = |id: StyleId, wb: &mut gridcraft_model::Workbook| -> StyleId { if same_doc { @@ -126,7 +71,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { } *style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone())) }; - let mut moved_formulas = Vec::new(); + let mut moved_formulas = std::collections::BTreeMap::new(); let mut moved_pictures = std::collections::BTreeMap::new(); if cut_move { // Remove the source first (contents and formats), and fix references to it. @@ -143,190 +88,281 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { for (c, _) in &taken { cx.touch(clip.sheet, *c); } - moved_formulas = taken; + moved_formulas = taken.into_iter().collect(); } - let dest_name = cx.wb.sheet(dest_sheet).map(|sh| sh.name.clone()).unwrap_or_default(); - for (tr, tc, r, c) in tiled_cells(src.height(), src.width(), tiles_r, tiles_c) { - let sc = CellRef::new(src.start.row + r, src.start.col + c); - let (dr, dc) = if transpose { (c, r) } else { (r, c) }; - let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue }; - let src_cell = if cut_move { moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) } else { src_sheet.cell(sc).cloned() }; - let src_val = src_sheet.value(sc); - let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned(); - if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none() && x.py.is_none()) { - continue; - } - let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default(); - let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned(); - let mut new_picture = old_picture.clone(); - let mut new = old.clone(); - let shift = |cell: &Cell| -> Option> { - let f = cell.formula.as_ref()?; - let e = f.expr()?; - if cut_move { - // A moved formula still reads cells on its original sheet. Qualify - // those references before the workbook-wide move adjustment below. - let e = if dest_sheet != clip.sheet { - e.map(&mut |x| match x { - gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { - r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); - gridcraft_formula::Expr::Ref(r) - } - other => other, - }) - } else { - e - }; - let mut moved = Formula::from_expr(e); - // The array range moves with its formula. - let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); - moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?))); - moved.dynamic = f.dynamic; - return Some(std::sync::Arc::new(moved)); - } - let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); - Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) })) - }; - // A Python cell's code travels with it. Cut to another sheet, it keeps reading the - // cells its `xl()` references named on its original sheet, as a moved formula does: - // they are qualified with that sheet before the workbook-wide move adjustment below. - let moved_py = |cell: &Cell| -> Result>> { - let Some(py) = cell.py.clone() else { return Ok(None) }; - if !cut_move || dest_sheet == clip.sheet { - return Ok(Some(py)); - } - let qualified = gridcraft_model::python::rewrite_xl(&py.code, |reference| { - let e = gridcraft_formula::parse(reference).ok()?.map(&mut |x| match x { - gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { - r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); - gridcraft_formula::Expr::Ref(r) - } - other => other, - }); - Some(gridcraft_formula::print(&e)) + // The cells of the copied block that hold something (or that a link reads), as offsets + // inside it. Blank source cells matter only where the destination holds something. + let source_offsets: std::collections::BTreeSet<(u32, u32)> = if link { + (0..link_src.height()).flat_map(|r| (0..link_src.width()).map(move |c| (r, c))).collect() + } else { + let offset = |c: CellRef| (c.row - src.start.row, c.col - src.start.col); + let mut held: std::collections::BTreeSet<(u32, u32)> = src_sheet.cells.iter_range(src).map(|(c, _)| offset(c)).collect(); + let side = + src_sheet.spill.keys().chain(src_sheet.cell_pictures.keys()).chain(src_sheet.comments.keys()).chain(src_sheet.hyperlinks.keys()); + held.extend(side.chain(moved_formulas.keys()).chain(moved_pictures.keys()).filter(|c| src.contains(**c)).map(|c| offset(*c))); + held + }; + let skip_cells = matches!(what, What::Validation | What::ColumnWidths); + // A blank source cell clears (or reformats) a destination cell that holds something, so + // those cells join the visit. One scan of the whole pasted area sorts them by tile. + let mut dest_held: std::collections::BTreeMap<(u32, u32), Vec<(u32, u32)>> = Default::default(); + if !link && !skip_cells { + let pasted = RangeRef::new(at, at.offset_clamped(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1)); + let held = + cx.wb.sheet(dest_sheet).into_iter().flat_map(|sh| { + sh.cells.iter_range(pasted).map(|(c, _)| c).chain(sh.cell_pictures.keys().copied().filter(|c| pasted.contains(*c))) }); - Ok(Some(match qualified { - Some(code) => { - let moved = gridcraft_model::PyCode { code, ..(*py).clone() }; - check_python_fits(&moved, &dest_name, dest)?; - std::sync::Arc::new(moved) + for d in held.collect::>() { + let (dr, dc) = (d.row - at.row, d.col - at.col); + let local = (dr % h, dc % w); + dest_held.entry((dr / h, dc / w)).or_default().push(if transpose { (local.1, local.0) } else { local }); + } + } + let no_cells = std::collections::BTreeSet::new(); + for tr in 0..tiles_r { + for tc in 0..tiles_c { + let merged; + let offsets = match dest_held.get(&(tr, tc)) { + Some(extra) => { + merged = source_offsets.iter().chain(extra).copied().collect::>(); + &merged } - None => py, - })) - }; - if link { - let sheet_prefix = if dest_sheet == clip.sheet && same_doc { - String::new() - } else { - format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name)) + None if skip_cells => &no_cells, + None => &source_offsets, }; - new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1())))); - new.value = Value::Empty; - new.py = None; - new_picture = None; - } else { - let sc_cell = src_cell.clone().unwrap_or_default(); - match what { - What::All | What::AllExceptBorders => { - new_picture = src_picture.clone(); - new.formula = shift(&sc_cell); - new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; - new.py = moved_py(&sc_cell)?; - let st = map_style(sc_cell.style, &mut cx.wb); - new.style = if what == What::AllExceptBorders { - let borders = cx.wb.styles.get(old.style).borders; - cx.wb.styles.derive(st, |s| s.borders = borders) + for &(r, c) in offsets { + let sc = CellRef::new(src.start.row + r, src.start.col + c); + let (dr, dc) = if transpose { (c, r) } else { (r, c) }; + let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue }; + let src_cell = if cut_move { moved_formulas.get(&sc).cloned() } else { src_sheet.cell(sc).cloned() }; + let src_val = src_sheet.value(sc); + let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned(); + if skip_blanks + && src_picture.is_none() + && src_val.is_empty() + && src_cell.as_ref().is_none_or(|x| x.formula.is_none() && x.py.is_none()) + { + continue; + } + let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default(); + let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned(); + let mut new_picture = old_picture.clone(); + let mut new = old.clone(); + let shift = |cell: &Cell| -> Option> { + let f = cell.formula.as_ref()?; + let e = f.expr()?; + if cut_move { + // A moved formula still reads cells on its original sheet. Qualify + // those references before the workbook-wide move adjustment below. + let e = if dest_sheet != clip.sheet { + e.map(&mut |x| match x { + gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { + r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); + gridcraft_formula::Expr::Ref(r) + } + other => other, + }) + } else { + e + }; + let mut moved = Formula::from_expr(e); + // The array range moves with its formula. + let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); + moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?))); + moved.dynamic = f.dynamic; + return Some(std::sync::Arc::new(moved)); + } + let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); + Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) })) + }; + let moved_py = |cell: &Cell| -> Result>> { + let Some(py) = cell.py.clone() else { return Ok(None) }; + if !cut_move || dest_sheet == clip.sheet { + return Ok(Some(py)); + } + let qualified = gridcraft_model::python::rewrite_xl(&py.code, |reference| { + let e = gridcraft_formula::parse(reference).ok()?.map(&mut |x| match x { + gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { + r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); + gridcraft_formula::Expr::Ref(r) + } + other => other, + }); + Some(gridcraft_formula::print(&e)) + }); + Ok(Some(match qualified { + Some(code) => { + let moved = gridcraft_model::PyCode { code, ..(*py).clone() }; + check_python_fits(&moved, &dest_name, dest)?; + std::sync::Arc::new(moved) + } + None => py, + })) + }; + if link { + let sheet_prefix = if dest_sheet == clip.sheet && same_doc { + String::new() } else { - st + format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name)) }; - } - What::Formulas | What::FormulasAndNumberFormats => { - new_picture = src_picture.clone(); - new.formula = shift(&sc_cell); - new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; - new.py = moved_py(&sc_cell)?; - if what == What::FormulasAndNumberFormats { - let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); - new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); - } - } - What::Values | What::ValuesAndNumberFormats => { - new_picture = src_picture.clone(); - new.formula = None; + new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1())))); + new.value = Value::Empty; new.py = None; - new.value = src_val.clone(); - if what == What::ValuesAndNumberFormats { - let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); - new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); + new_picture = None; + } else { + let sc_cell = src_cell.clone().unwrap_or_default(); + match what { + What::All | What::AllExceptBorders => { + new_picture = src_picture.clone(); + new.formula = shift(&sc_cell); + new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; + new.py = moved_py(&sc_cell)?; + let st = map_style(sc_cell.style, &mut cx.wb); + new.style = if what == What::AllExceptBorders { + let borders = cx.wb.styles.get(old.style).borders; + cx.wb.styles.derive(st, |s| s.borders = borders) + } else { + st + }; + } + What::Formulas | What::FormulasAndNumberFormats => { + new_picture = src_picture.clone(); + new.formula = shift(&sc_cell); + new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; + new.py = moved_py(&sc_cell)?; + if what == What::FormulasAndNumberFormats { + let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); + new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); + } + } + What::Values | What::ValuesAndNumberFormats => { + new_picture = src_picture.clone(); + new.formula = None; + new.py = None; + new.value = src_val.clone(); + if what == What::ValuesAndNumberFormats { + let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); + new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); + } + } + What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb), + What::Comments | What::Validation | What::ColumnWidths => {} + } + // Operations combine numbers with what is already there. + if op != "none" + && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) + && new.formula.is_none() + && new.py.is_none() + { + if old_picture.is_some() || new_picture.is_some() { + new_picture = None; + new.value = Value::Error(gridcraft_core::CellError::Value); + } + let a = old.value.clone(); + let b = new.value.clone(); + // Numbers stored as text take part (multiplying by 1 turns them into + // numbers); other text and errors stay as they are. + if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number()) + && (a.is_number() || a.is_empty() || a.is_text()) + && (b.is_number() || b.is_empty()) + { + new.value = match op.as_str() { + "add" => Value::number(x + y), + "subtract" => Value::number(x - y), + "multiply" => Value::number(x * y), + "divide" => { + if y == 0.0 { + Value::Error(gridcraft_core::CellError::Div0) + } else { + Value::number(x / y) + } + } + _ => b.clone(), + }; + } else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) { + new.value = a; + } + // A formula already there is kept and combined with the pasted number. + if let Some(f) = &old.formula + && let Ok(y) = b.to_number() + && (b.is_number() || b.is_empty()) + && let Some(sym) = match op.as_str() { + "add" => Some('+'), + "subtract" => Some('-'), + "multiply" => Some('*'), + "divide" => Some('/'), + _ => None, + } + { + let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y)); + new.formula = Some(std::sync::Arc::new(Formula::new(&text))); + new.value = Value::Empty; + } } } - What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb), - What::Comments | What::Validation | What::ColumnWidths => {} - } - // Operations combine numbers with what is already there. - if op != "none" - && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) - && new.formula.is_none() - && new.py.is_none() - { - if old_picture.is_some() || new_picture.is_some() { - new_picture = None; - new.value = Value::Error(gridcraft_core::CellError::Value); + match what { + What::Comments => { + if let Some(cm) = src_sheet.comments.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); + } + } + What::All => { + if let Some(cm) = src_sheet.comments.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); + } + if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h); + } + cx.sheet_mut(dest_sheet)?.set_cell(dest, new); + } + _ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new), } - // Numbers stored as text participate; other text and errors stay as they are. - apply_operation(&op, &old, &mut new); + if let Some(picture) = new_picture { + cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture); + } + cx.touch(dest_sheet, dest); } } - // The copied cell's validation rule replaces whatever rule the destination had. - if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) { - let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned(); - let sh = cx.sheet_mut(dest_sheet)?; - crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]); - if let Some(mut dv) = rule { - // Relative references move with the rule, as pasted formulas do. - let first = RangeRef::top_left(&dv.ranges).unwrap_or(sc); - dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest); - dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest)); - dv.ranges = vec![RangeRef::new(dest, dest)]; - sh.validations.push(dv); + } + let pasted = RangeRef::new(at, at.offset_clamped(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1)); + if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) { + paste_validations(cx, src_sheet, src, dest_sheet, pasted, transpose, skip_blanks)?; + } + if what == What::ColumnWidths { + // Each pasted column takes the width of the column copied to it (transposed, the + // copied rows land in columns and all take the last copied column's width). + let cols = cx.sheet_mut(dest_sheet)?; + for dc in 0..pasted.width() { + let info = if transpose { + (0..src.width()).rev().find_map(|c| src_sheet.cols.get(&(src.start.col + c)).copied()) + } else { + src_sheet.cols.get(&(src.start.col + dc % w)).copied() + }; + if let Some(info) = info { + cols.cols.insert(pasted.start.col + dc, info); } } - match what { - What::Comments => { - if let Some(cm) = src_sheet.comments.get(&sc).cloned() { - cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); - } - } - What::ColumnWidths => { - if let Some(info) = src_sheet.cols.get(&sc.col).copied() { - cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info); - } - } - What::All => { - if let Some(cm) = src_sheet.comments.get(&sc).cloned() { - cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); - } - if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() { - cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h); - } - cx.sheet_mut(dest_sheet)?.set_cell(dest, new); - } - _ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new), - } - if let Some(picture) = new_picture { - cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture); - } - cx.touch(dest_sheet, dest); } // Merged areas inside the copied block travel with it. - copy_merges(cx, dest_sheet, src_sheet, src, at, what, transpose)?; + if matches!(what, What::All | What::Formats) && !transpose { + let merges: Vec = src_sheet.merges.iter().filter(|m| src.contains_range(m)).copied().collect(); + for m in merges { + let dr = at.row as i64 - src.start.row as i64; + let dc = at.col as i64 - src.start.col as i64; + if let (Some(a), Some(b)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) { + let nm = RangeRef::new(a, b); + let sh = cx.sheet_mut(dest_sheet)?; + sh.merges.retain(|x| !x.intersects(&nm)); + sh.merges.push(nm); + } + } + } if cut_move { // References elsewhere to the moved block now point to its new place. - let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default(); let e = if dest_sheet == clip.sheet { gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col } } else { - gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col } + gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name.clone(), to_row: at.row, to_col: at.col } }; rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e)?; cx.structural = true; @@ -351,6 +387,132 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { Ok(json!({"pasted": s.doc()?.selection.a1()})) } +/// Most cells one Paste Link writes (it writes a formula into every cell of the copied area, +/// blank ones included). +const MAX_PASTE_LINK_CELLS: u64 = 1_048_576; +/// Most validation ranges one paste adds (a rule covering part of a small block tiled over a huge +/// selection makes one range per tile). +const MAX_PASTE_VALIDATION_RANGES: usize = 100_000; + +/// Pasting validation works on ranges, as Excel stores it: the pasted rectangle is cut out of the +/// destination's rules (cells whose source had no rule end up with none), then each source rule +/// that covers part of the copied block `src` comes along as one rule over the translated parts. +/// `size` is one tile's (height, width) at the destination; `pasted` is all the tiles together. +fn paste_validations( + cx: &mut crate::cmd::Ctx<'_>, + src_sheet: &gridcraft_model::Sheet, + src: RangeRef, + dest_sheet: usize, + pasted: RangeRef, + transpose: bool, + skip_blanks: bool, +) -> Result<()> { + let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) }; + let (tiles_r, tiles_c) = (pasted.height() / h.max(1), pasted.width() / w.max(1)); + let mut rules = Vec::new(); + // Under Skip Blanks the destination keeps its rule below a blank source cell that has none, so + // only the destination of cells that hold something (and of the rules) is cleared. Nothing is + // collected when no destination rule is under the paste. + let mut cleared: Vec = Vec::new(); + let dest_rules = + cx.wb.sheet(dest_sheet).is_some_and(|sh| sh.validations.iter().any(|dv| dv.ranges.iter().any(|r| r.intersection(&pasted).is_some()))); + if skip_blanks && dest_rules { + let mut cells = Vec::new(); + for (sc, cell) in src_sheet.cells.iter_range(src) { + if cell.formula.is_none() && cell.py.is_none() && src_sheet.value(sc).is_empty() { + continue; + } + let (r, c) = (sc.row - src.start.row, sc.col - src.start.col); + let (r, c) = if transpose { (c, r) } else { (r, c) }; + for tr in 0..tiles_r { + for tc in 0..tiles_c { + if let Some(d) = pasted.start.offset(i64::from(tr * h + r), i64::from(tc * w + c)) { + cells.push((d.row, d.col)); + } + } + } + } + // Coalesce populated cells into horizontal runs, then join identical runs in adjacent + // rows. The range limit applies to the cuts, not the number of populated cells. + cells.sort_unstable(); + let mut runs: Vec = Vec::new(); + let mut i = 0; + while i < cells.len() { + let (row, start_col) = cells[i]; + let mut end_col = start_col; + i += 1; + while i < cells.len() && cells[i].0 == row && cells[i].1 <= end_col.saturating_add(1) { + end_col = end_col.max(cells[i].1); + i += 1; + } + runs.push(RangeRef::new(CellRef::new(row, start_col), CellRef::new(row, end_col))); + } + let mut last_run_by_columns = std::collections::BTreeMap::<(u32, u32), usize>::new(); + for run in runs { + let columns = (run.start.col, run.end.col); + if let Some(&index) = last_run_by_columns.get(&columns) + && cleared[index].end.row.saturating_add(1) == run.start.row + { + cleared[index].end.row = run.end.row; + } else { + let index = cleared.len(); + cleared.push(run); + last_run_by_columns.insert(columns, index); + } + } + } + let mut total = 0usize; + for dv in &src_sheet.validations { + let parts: Vec = dv.ranges.iter().filter_map(|r| r.intersection(&src)).collect(); + let Some(first) = dv.ranges.first().map(|r| r.start) else { continue }; + if parts.is_empty() { + continue; + } + let mut ranges = Vec::new(); + if parts.contains(&src) { + ranges.push(pasted); + } else { + total = total.saturating_add(parts.len().saturating_mul(tiles_r as usize).saturating_mul(tiles_c as usize)); + if total > MAX_PASTE_VALIDATION_RANGES { + return Err(EngineError::Other("Too many separate validation areas to paste at once.".into())); + } + for tr in 0..tiles_r { + for tc in 0..tiles_c { + let Some(origin) = pasted.start.offset(i64::from(tr * h), i64::from(tc * w)) else { continue }; + for p in &parts { + let (r0, c0) = (p.start.row - src.start.row, p.start.col - src.start.col); + let (r1, c1) = (p.end.row - src.start.row, p.end.col - src.start.col); + let ((r0, c0), (r1, c1)) = if transpose { ((c0, r0), (c1, r1)) } else { ((r0, c0), (r1, c1)) }; + let Some(a) = origin.offset(i64::from(r0), i64::from(c0)) else { continue }; + let b = origin.offset_clamped(i64::from(r1), i64::from(c1)); + ranges.push(RangeRef::new(a, b)); + } + } + } + } + let Some(anchor) = ranges.first().map(|r| r.start) else { continue }; + let mut rule = dv.clone(); + // Relative references move with the rule, as pasted formulas do: they are written for the + // rule's first cell. + rule.f1 = super::super::data::move_rule_formula(&dv.f1, first, anchor); + rule.f2 = dv.f2.as_ref().map(|f| super::super::data::move_rule_formula(f, first, anchor)); + rule.ranges = ranges; + rules.push(rule); + } + if cleared.len() > MAX_PASTE_VALIDATION_RANGES { + return Err(EngineError::Other("Too many separate validation areas to paste at once.".into())); + } + let sh = cx.sheet_mut(dest_sheet)?; + if skip_blanks { + cleared.extend(rules.iter().flat_map(|r| r.ranges.iter().copied())); + super::super::data::remove_validation_cells(&mut sh.validations, &cleared); + } else { + super::super::data::remove_validation_cells(&mut sh.validations, &[pasted]); + } + sh.validations.extend(rules); + Ok(()) +} + /// Applies a reference adjustment to every cell formula and defined name in the workbook. pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) -> Result<()> { use gridcraft_formula::adjust::Edit; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b83879c16..02c273e14 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4170,6 +4170,25 @@ fn a_cut_moves_the_sources_notes_hyperlinks_and_merges() { assert!(v(&s, "A1").is_empty() && v(&s, "B1").is_empty()); } +/// A cell that holds only a hyperlink (its content cleared) is still part of the block: a +/// cut must carry the link to the destination instead of dropping it with the source. +#[test] +fn a_cut_carries_a_link_only_cell() { + let mut s = s(); + s.execute("insert.link", json!({"cell": "A1", "target": "https://example.com"})).unwrap(); + let a1 = CellRef::parse("A1").unwrap(); + let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + sh.remove_cell(a1); + assert!(sh.cell(a1).is_none(), "A1 holds only the link"); + s.execute("edit.cut", json!({"range": "A1:B1"})).unwrap(); + s.execute("edit.paste", json!({"at": "D1"})).unwrap(); + + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert!(!sh.hyperlinks.contains_key(&CellRef::parse("A1").unwrap()), "the link left the source"); + assert!(sh.hyperlinks.contains_key(&CellRef::parse("D1").unwrap()), "the link arrived at the destination"); +} + /// Page Layout ▸ Print Titles: naming one axis must not clear the other. `print_titles` read /// both `rows` and `cols` into `Option`s and assigned both unconditionally, so /// `{"rows":"2:2"}` silently dropped the repeated columns from every printed page. @@ -5014,6 +5033,217 @@ fn csv_as_text_keeps_identifier_columns_as_written() { assert_eq!(crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(), csv.replace('\n', "\r\n").as_bytes()); } +fn validation_areas(s: &Session) -> Vec { + let sh = s.doc().unwrap().wb.active().unwrap(); + let mut out: Vec = sh.validations.iter().map(|dv| dv.ranges.iter().map(|r| r.a1()).collect::>().join(" ")).collect(); + out.sort(); + out +} + +/// ft-3ir.10.16: pasting whole columns walked every one of their 1,048,576 rows and added one +/// single-cell validation rule per pasted cell, re-cutting every rule each time (the fuzz run +/// aborted on a 34 GB allocation). Validation now pastes as ranges and only cells that hold +/// something are visited. +#[test] +fn whole_column_paste_with_validation_works_on_ranges() { + let mut s = s(); + let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true; + s.execute("cell.set", json!({"cell": "C1", "input": "1"})).unwrap(); + s.execute("cell.set", json!({"cell": "D1048576", "input": "2"})).unwrap(); + s.execute("cell.set", json!({"cell": "F100", "input": "old"})).unwrap(); + s.execute("data.validation", json!({"range": "C:C", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap(); + s.execute("data.validation", json!({"range": "F:H", "type": "list", "formula1": "\"a,b\""})).unwrap(); + s.execute("edit.copy", json!({"range": "C:D"})).unwrap(); + s.execute("edit.paste", json!({"at": "F1"})).unwrap(); + assert_eq!(v(&s, "F1"), Value::Number(1.0)); + assert_eq!(v(&s, "G1048576"), Value::Number(2.0)); + assert_eq!(v(&s, "F100"), Value::Empty, "the whole column is replaced, below the copied text too"); + // One rule per copied rule, over whole columns; G (D had no rule) has none; H keeps the list. + assert_eq!(validation_areas(&s), ["C:C", "F:F", "H:H"]); + assert!(!ok(&mut s, "F500", "-1")); + assert!(ok(&mut s, "G500", "anything")); + assert!(!ok(&mut s, "H500", "c")); + assert_eq!(s.doc().unwrap().selection.a1(), "F:G"); + // Undo gives the list back over F:H. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(validation_areas(&s), ["C:C", "F:H"]); + assert_eq!(v(&s, "F100"), Value::Text("old".into())); +} + +/// Paste Link of whole columns links the cells the columns hold, wherever they start. +#[test] +fn paste_link_of_whole_columns_links_the_used_rows() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "C5", "input": "1"})).unwrap(); + s.execute("cell.set", json!({"cell": "C6", "input": "2"})).unwrap(); + s.execute("edit.copy", json!({"range": "C:C"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "all", "link": true, "at": "F1"})).unwrap(); + assert_eq!(v(&s, "F1"), Value::Number(0.0), "a link to a blank cell reads 0"); + assert_eq!(v(&s, "F5"), Value::Number(1.0)); + assert_eq!(v(&s, "F6"), Value::Number(2.0)); + assert_eq!(s.execute("cell.get", json!({"cell": "F5"})).unwrap()["input"], json!("=C5")); +} + +/// A transposed paste clears the destination cells that line up with blank source cells. +#[test] +fn transposed_paste_clears_destination_cells_under_blank_source_cells() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "3"})).unwrap(); + s.execute("cell.set", json!({"cell": "B2", "input": "4"})).unwrap(); + s.execute("cell.set", json!({"cell": "E2", "input": "old"})).unwrap(); + s.execute("cell.set", json!({"cell": "F1", "input": "older"})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:B2"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "E1"})).unwrap(); + assert_eq!(v(&s, "E1"), Value::Number(1.0)); + assert_eq!(v(&s, "E2"), Value::Empty, "B1 was blank"); + assert_eq!(v(&s, "F1"), Value::Number(3.0)); + assert_eq!(v(&s, "F2"), Value::Number(4.0)); +} + +/// A copy tiled over a larger selection clears the non-blank destination cells under its blanks, +/// in every tile. +#[test] +fn tiled_paste_clears_destination_cells_under_blank_source_cells() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "x"})).unwrap(); + s.execute("cell.set", json!({"cell": "C4", "input": "y"})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:A2"})).unwrap(); + s.execute("selection.set", json!({"range": "C1:C4"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(1.0)); + assert_eq!(v(&s, "C2"), Value::Empty); + assert_eq!(v(&s, "C3"), Value::Number(1.0)); + assert_eq!(v(&s, "C4"), Value::Empty); +} + +/// Excel refuses a paste that would run off the sheet rather than pasting part of it: whole +/// columns below row 1, whole rows right of column A, a whole column transposed, or a block at +/// the last row or column. +#[test] +fn paste_that_does_not_fit_on_the_sheet_is_refused() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "C1", "values": [[1, 2], [3, 4]]})).unwrap(); + s.execute("cell.set", json!({"cell": "A15", "input": "x"})).unwrap(); + let before = s.doc().unwrap().wb.clone(); + s.execute("edit.copy", json!({"range": "C:D"})).unwrap(); + assert!(s.execute("edit.paste", json!({"at": "B8"})).is_err()); + assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "A1"})).is_err()); + s.execute("edit.copy", json!({"range": "15:15"})).unwrap(); + assert!(s.execute("edit.paste", json!({"at": "AN17"})).is_err()); + s.execute("edit.copy", json!({"range": "C1:D2"})).unwrap(); + assert!(s.execute("edit.paste", json!({"at": "XFD1"})).is_err()); + assert!(s.execute("edit.paste", json!({"at": "A1048576"})).is_err()); + s.execute("edit.cut", json!({"range": "C1:D2"})).unwrap(); + assert!(s.execute("edit.paste", json!({"at": "XFD1"})).is_err()); + let after = s.doc().unwrap().wb.clone(); + assert_eq!(after.active().unwrap().cells.len(), before.active().unwrap().cells.len()); + assert_eq!(v(&s, "C1"), Value::Number(1.0)); + // Whole rows still paste at column A, whole columns at row 1. + s.execute("edit.copy", json!({"range": "15:15"})).unwrap(); + s.execute("edit.paste", json!({"at": "A20"})).unwrap(); + assert_eq!(v(&s, "A20"), Value::Text("x".into())); +} + +/// Paste visits only cells that hold something on either side: a blank copied cell still clears +/// (or reformats) a destination cell that holds something, and blank onto blank stays blank, even +/// with an operation (Excel leaves it empty rather than writing 0). +#[test] +fn paste_blank_cells_clear_destination_and_blank_onto_blank_stays_blank() { + let mut s = s(); + let has_cell = |s: &Session, a: &str| s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(a).unwrap()).is_some(); + s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap(); + s.execute("cell.set", json!({"cell": "D2", "input": "7"})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:A3"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "values", "operation": "add", "at": "D1"})).unwrap(); + assert_eq!(v(&s, "D1"), Value::Number(5.0)); + assert_eq!(v(&s, "D2"), Value::Number(7.0)); + assert!(!has_cell(&s, "D3"), "blank + blank stays blank"); + s.execute("cell.set", json!({"cell": "F2", "input": "old"})).unwrap(); + s.execute("edit.paste", json!({"at": "F1"})).unwrap(); + assert_eq!(v(&s, "F1"), Value::Number(5.0)); + assert!(!has_cell(&s, "F2"), "a blank copied cell clears the destination"); + s.execute("home.bold", json!({"range": "H2"})).unwrap(); + assert!(has_cell(&s, "H2")); + s.execute("edit.pasteSpecial", json!({"what": "formats", "at": "H1"})).unwrap(); + assert!(!has_cell(&s, "H2"), "a blank copied cell's format replaces the bold"); +} + +/// Validation pastes over the whole pasted area (a blank copied cell's rule included, as Excel +/// does), and cuts only the pasted cells out of a rule the block lands inside. +#[test] +fn paste_validation_covers_the_pasted_area_as_ranges() { + let mut s = s(); + let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true; + s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": "6"})).unwrap(); + s.execute("data.validation", json!({"range": "A1:A3", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap(); + s.execute("data.validation", json!({"range": "D1:D3", "type": "list", "formula1": "\"x,y\""})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:A3"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "all", "skipBlanks": true, "at": "D1"})).unwrap(); + assert!(!ok(&mut s, "D2", "-1"), "the blank copied cell's rule is pasted too"); + assert!(ok(&mut s, "D2", "3")); + assert_eq!(validation_areas(&s), ["A1:A3", "D1:D3"]); + + // A block with a rule on its middle cells pasted inside a bigger list rule. + let mut s = self::s(); + s.execute("data.validation", json!({"range": "A2:A3", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap(); + s.execute("data.validation", json!({"range": "C1:E10", "type": "list", "formula1": "\"p,q\""})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:A4"})).unwrap(); + s.execute("edit.paste", json!({"at": "D4"})).unwrap(); + assert_eq!(validation_areas(&s), ["A2:A3", "C1:E3 C4:C7 E4:E7 C8:E10", "D5:D6"]); + assert!(ok(&mut s, "D4", "anything")); + assert!(!ok(&mut s, "D5", "-1")); + assert!(!ok(&mut s, "D3", "z")); + + // One copied cell tiled over a selection is one rule over the selection. + s.execute("edit.copy", json!({"range": "A2"})).unwrap(); + s.execute("selection.set", json!({"range": "G1:G4"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert!(validation_areas(&s).contains(&"G1:G4".to_string())); +} + +/// Paste Special > Validation, checked against Excel 16.0: A1 has a list rule, A2 is blank with a +/// whole-number rule, A3 is blank with no rule, over C1:C3 that hold a decimal rule. With Skip +/// Blanks a blank source cell that has a rule still pastes it, and one without keeps the +/// destination's rule; without it the destination's rule is cleared. +#[test] +fn paste_validation_skip_blanks_keeps_rules_under_blank_cells_without_one() { + let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true; + for skip in [true, false] { + let mut s = s(); + s.execute("data.validation", json!({"range": "A1", "type": "list", "formula1": "\"x,y\""})).unwrap(); + s.execute("data.validation", json!({"range": "A2", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap(); + s.execute("data.validation", json!({"range": "C1:C3", "type": "decimal", "operator": "greater", "formula1": "0"})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:A3"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "validation", "skipBlanks": skip, "at": "C1"})).unwrap(); + assert!(!ok(&mut s, "C1", "z"), "C1 gets A1's list rule (skip {skip})"); + assert!(!ok(&mut s, "C2", "1.5"), "C2 gets A2's whole-number rule (skip {skip})"); + assert_eq!(ok(&mut s, "C3", "-1"), !skip, "C3 keeps its decimal rule only when blanks are skipped"); + } +} + +/// A big populated Skip Blanks validation paste is not refused for its cell count: only +/// validation areas are limited, and with no rule under the paste nothing is cleared. +#[test] +fn paste_validation_skip_blanks_large_populated_paste() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1:A100002", "values": (0..100_002).map(|i| vec![i]).collect::>()})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:A100002"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "validation", "skipBlanks": true, "at": "C1"})).unwrap(); + assert!(validation_areas(&s).is_empty()); + + // A destination rule is cleared under every populated source cell. Count the resulting + // coalesced ranges, not the 100,002 cells that were considered for clearing. + let mut s = self::s(); + s.execute("range.setValues", json!({"range": "A1:A100002", "values": (0..100_002).map(|i| vec![i]).collect::>()})).unwrap(); + s.execute("data.validation", json!({"range": "C1:C100002", "type": "list", "formula1": "\"x,y\""})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:A100002"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "validation", "skipBlanks": true, "at": "C1"})).unwrap(); + assert!(validation_areas(&s).is_empty()); +} + /// Open a workbook by path, and `CELL("filename")` names the file it came from, as Excel does; /// Save As makes the new file the one it names. The sheet-name formula of #1489 depends on it. #[test]