fix(edit): paste, Ctrl+Enter, Fill and Copy skip rows a filter hides (#1101) (#1175)

* Paste, Ctrl+Enter and Fill skip rows hidden by a filter (#1101)

Repeating one cell or row down a filtered selection (paste of a single
cell or row, Ctrl+Enter, Fill Down/Up/Right/Left) now leaves the rows an
applied filter hides alone, as Excel does. A taller pasted block still
goes straight down, and rows hidden by hand are still written, also as
in Excel. Each case was measured in Excel for Microsoft 365.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2bJ3LxrZ6YnmppgrfwHwr

* Copying a filtered range takes only the rows showing (#1101)

A copied range with rows hidden by an applied filter is split into its
runs of showing rows and packed like a multi-area copy (formulas as
values), so pasting it inside GridCraft no longer brings the hidden rows
back. One run is copied as a plain range, keeping formulas. Cut still
moves every row. All as measured in Excel for Microsoft 365.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2bJ3LxrZ6YnmppgrfwHwr

* Find the rows a filter hides without rescanning every table per row

filtered_rows asks the sheet for its filter row spans once instead of
checking every table for each hidden row.

* Paste over filtered rows leaves their validation rules alone

When one row is tiled over filtered rows, paste_validations no longer
clears or copies rules on the rows the filter hides.

* Filtered copy packs links and validation with the rows showing

slide_together now moves hyperlinks and validation rules with their rows, and a
filtered whole-row copy records the packed rows as its selection so Insert
Copied Cells inserts only the rows copied.

* Group validation paste options and format the new tests

* Pack a filtered copy in one pass over notes, merges, links and spills

slide_together finds each entry's area by search instead of scanning every map
once per area, so a filter leaving thousands of separate runs no longer costs
runs times map size on every copy.

---------

Co-authored-by: Richard <richard1912@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: skylar <hi@skylar.cc>
This commit is contained in:
authored and GitHub committed 2026-11-07 19:30:06 -06:00
1 parent dea064b857
commit 640fbf22f6
8 files changed
+469 -30

No files matched your search

+6 -1
View File
@@ -239,7 +239,12 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
/// The rows of `range` an applied filter hides (rows hidden by hand are not among them).
fn filtered_rows(sh: &gridcraft_model::Sheet, range: RangeRef) -> std::collections::BTreeSet<u32> {
sh.rows.range(range.start.row..=range.end.row).filter(|(r, info)| info.hidden && sh.is_row_filtered(**r)).map(|(r, _)| *r).collect()
let spans = sh.filter_row_spans();
if spans.is_empty() {
return Default::default();
}
let filtered = |row: u32| spans.iter().any(|&(first, last)| row >= first && row <= last);
sh.rows.range(range.start.row..=range.end.row).filter(|(r, info)| info.hidden && filtered(**r)).map(|(r, _)| *r).collect()
}
/// The Clear commands (Delete is Clear Contents). As in Excel, the rows an applied filter hides
+4
View File
@@ -328,7 +328,11 @@ pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
if r.count() > 2_000_000 {
return Err(bad("range.fill", "range too large"));
}
let skip = cx.wb.sheet(sheet).map(|sh| super::filtered_rows(sh, *r)).unwrap_or_default();
for at in r.iter() {
if skip.contains(&at.row) {
continue;
}
let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
let base = match by_style.get(&style) {
Some(c) => c.clone(),
+126 -16
View File
@@ -1,8 +1,8 @@
use super::cell::input_to_cell;
use super::paste_special::paste_special;
use super::{
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, bad, bool_param, cell_param_on, edit, json, ok, str_param, target_ranges,
target_sheet,
Cell, CellRef, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, bad, bool_param, cell_param_on, edit, json, ok, str_param,
target_ranges, target_sheet,
};
// ---------------------------------------------------------------- clipboard
@@ -15,7 +15,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
return Err(multiple());
}
// The selection as made (untrimmed), for Insert Copied Cells.
let selected = ranges.first().copied().unwrap_or_default();
let mut selected = ranges.first().copied().unwrap_or_default();
let d = s.doc()?;
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let trim = |range: RangeRef| {
@@ -31,7 +31,27 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?;
(std::sync::Arc::new(wb), block, areas)
} else {
(d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new())
let range = trim(ranges.first().copied().unwrap_or_default());
// A copied range with rows a filter hides takes only the rows showing: they are copied
// like a multi-area selection stacked top to bottom (formulas as values), as in Excel.
// A cut still takes every row.
let hidden = if cut { Default::default() } else { super::filtered_rows(sh, range) };
let visible = if hidden.is_empty() { Vec::new() } else { visible_row_runs(range, &hidden) };
let any_visible = !visible.is_empty();
let packed = match visible.as_slice() {
[] => (d.wb.clone(), range, Vec::new()),
[only] => (d.wb.clone(), *only, Vec::new()),
_ => {
let (wb, block) = slide_together(&d.wb, sheet, &visible).ok_or_else(multiple)?;
(std::sync::Arc::new(wb), block, visible)
}
};
// Whole rows copied with some hidden by a filter come as the block of rows showing, so
// Insert Copied Cells makes room for those rows, not for the hidden ones too.
if !hidden.is_empty() && any_visible && selected.is_full_rows() {
selected = RangeRef::rows(packed.1.start.row, packed.1.end.row);
}
packed
};
let text = crate::display::range_text(&wb, sheet, range);
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
@@ -46,6 +66,56 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
Ok(result)
}
/// The runs of consecutive rows of `range` not in `hidden`, as ranges of `range`'s columns.
fn visible_row_runs(range: RangeRef, hidden: &std::collections::BTreeSet<u32>) -> Vec<RangeRef> {
let mut runs = Vec::new();
let mut start = None;
for row in range.start.row..=range.end.row {
match (hidden.contains(&row), start) {
(false, None) => start = Some(row),
(true, Some(first)) => {
runs.push(RangeRef::new(CellRef::new(first, range.start.col), CellRef::new(row - 1, range.end.col)));
start = None;
}
_ => {}
}
}
if let Some(first) = start {
runs.push(RangeRef::new(CellRef::new(first, range.start.col), CellRef::new(range.end.row, range.end.col)));
}
runs
}
/// The validation rules of `src` cut into the areas of a packed block: one rule per area a rule
/// touches, over that area's part moved by the area's shift. `sorted` areas run in order.
fn pack_validations(src: &gridcraft_model::Sheet, sorted: &[RangeRef], shifts: &[(i64, i64)], across: bool) -> Vec<gridcraft_model::Validation> {
let key = |c: CellRef| if across { c.col } else { c.row };
let mut packed_rules = Vec::new();
for dv in &src.validations {
let Some(from) = dv.ranges.first().map(|r| r.start) else { continue };
// The areas each of the rule's ranges touches lie side by side in the sorted list.
let end_key = |a: &RangeRef| if across { a.end.col } else { a.end.row };
let mut by_area: std::collections::BTreeMap<usize, Vec<RangeRef>> = Default::default();
for r in &dv.ranges {
let first = sorted.partition_point(|a| end_key(a) < key(r.start));
for (i, a) in sorted.iter().enumerate().skip(first).take_while(|(_, a)| key(a.start) <= end_key(r)) {
let (dr, dc) = shifts[i];
let moved = r.intersection(a).and_then(|x| Some(RangeRef::new(x.start.offset(dr, dc)?, x.end.offset(dr, dc)?)));
by_area.entry(i).or_default().extend(moved);
}
}
for ranges in by_area.into_values() {
let Some(to) = ranges.first().map(|r| r.start) else { continue };
let mut rule = dv.clone();
rule.f1 = super::super::data::move_rule_formula(&dv.f1, from, to);
rule.f2 = dv.f2.as_ref().map(|f| super::super::data::move_rule_formula(f, from, to));
rule.ranges = ranges;
packed_rules.push(rule);
}
}
packed_rules
}
/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they
/// slide together side by side, left to right) or all cover the same columns (stacked, top to
/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed
@@ -80,17 +150,35 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
dst.spill.retain(|c, _| !block.contains(*c));
dst.spill_ranges.retain(|c, _| !block.contains(*c));
dst.comments.retain(|c, _| !block.contains(*c));
dst.hyperlinks.retain(|c, _| !block.contains(*c));
super::super::data::remove_validation_cells(&mut dst.validations, &[block]);
dst.merges.retain(|m| !m.intersects(&block));
if across {
dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c));
} else {
dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r));
}
// Where each area lands: the shift of its cells, and its first row or column in the block.
let key = |c: CellRef| if across { c.col } else { c.row };
let mut next = if across { anchor.col } else { anchor.row };
let mut shifts: Vec<(i64, i64)> = Vec::with_capacity(sorted.len());
for a in &sorted {
let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) };
let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c)));
for c in cells {
let to = i64::from(next);
shifts.push(if across { (0, to - i64::from(a.start.col)) } else { (to - i64::from(a.start.row), 0) });
next = next.saturating_add(if across { a.width() } else { a.height() });
}
// The areas never overlap and run in order, so the area holding a cell is found by search:
// each map below is read once, whatever the number of areas.
let area_of = |c: CellRef| -> Option<usize> {
let i = sorted.partition_point(|a| key(a.start) <= key(c)).checked_sub(1)?;
sorted.get(i)?.contains(c).then_some(i)
};
let moved = |c: CellRef| -> Option<CellRef> {
let (dr, dc) = shifts[area_of(c)?];
c.offset(dr, dc)
};
for (a, &(dr, dc)) in sorted.iter().zip(&shifts) {
for (c, _) in src.cells.iter_range(*a) {
let Some(to) = c.offset(dr, dc) else { continue };
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None });
@@ -98,17 +186,38 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
dst.cell_pictures.insert(to, picture.clone());
}
}
for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) {
if let Some(to) = c.offset(dr, dc) {
dst.comments.insert(to, comment.clone());
}
}
for c in src.spill.keys().copied() {
let Some(to) = moved(c) else { continue };
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None });
if let Some(picture) = src.cell_pictures.get(&c) {
dst.cell_pictures.insert(to, picture.clone());
}
for m in src.merges.iter().filter(|m| a.contains_range(m)) {
if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
dst.merges.push(RangeRef::new(s, e));
}
}
// Links and validation rules follow their cells too, so a row the filter hides leaves its own
// behind.
for (c, link) in &src.hyperlinks {
if let Some(to) = moved(*c) {
dst.hyperlinks.insert(to, link.clone());
}
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
}
let packed_rules = pack_validations(src, &sorted, &shifts, across);
for (c, comment) in &src.comments {
if let Some(to) = moved(*c) {
dst.comments.insert(to, comment.clone());
}
}
for m in &src.merges {
let Some(i) = area_of(m.start).filter(|&i| sorted[i].contains_range(m)) else { continue };
let (dr, dc) = shifts[i];
if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
dst.merges.push(RangeRef::new(s, e));
}
}
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
let mut next = if across { anchor.col } else { anchor.row };
for a in &sorted {
if across {
for (c, info) in src.cols.range(a.start.col..=a.end.col) {
dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info);
@@ -120,6 +229,7 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
}
next = next.saturating_add(if across { a.width() } else { a.height() });
}
dst.validations.extend(packed_rules);
Some((out, block))
}
+2 -1
View File
@@ -31,8 +31,9 @@ pub(super) fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result<Js
} else {
RangeRef::new(CellRef::new(r.start.row, r.end.col), r.end)
};
let skip = s.doc()?.wb.sheet(sheet).map(|sh| super::filtered_rows(sh, r)).unwrap_or_default();
edit(s, |cx| {
crate::fill::fill(cx, sheet, src, r, crate::fill::FillMode::Copy)?;
crate::fill::fill_except(cx, sheet, src, r, crate::fill::FillMode::Copy, &skip)?;
Ok(Json::Null)
})
}
+43 -5
View File
@@ -45,6 +45,12 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
&& !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) };
// A single cell or row repeated down a filtered selection skips the rows the filter hides; a
// taller block is pasted straight down, hidden rows included, as in Excel.
let skip_rows = match s.doc()?.wb.sheet(dest_sheet) {
Some(sh) if tile && h == 1 => super::filtered_rows(sh, sel),
_ => Default::default(),
};
// 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() {
@@ -134,6 +140,9 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
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 };
if skip_rows.contains(&dest.row) {
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();
@@ -326,7 +335,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
}
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)?;
paste_validations(cx, src_sheet, src, dest_sheet, pasted, PasteMode { transpose, skip_blanks, skip_rows: &skip_rows })?;
}
if what == What::ColumnWidths {
// Each pasted column takes the width of the column copied to it (transposed, the
@@ -394,6 +403,31 @@ const MAX_PASTE_LINK_CELLS: u64 = 1_048_576;
/// selection makes one range per tile).
const MAX_PASTE_VALIDATION_RANGES: usize = 100_000;
/// How a validation paste lays the rules out: transposed or not, keeping the destination's rule under
/// blank source cells, and leaving the rows a filter hides as they are.
#[derive(Clone, Copy)]
struct PasteMode<'a> {
transpose: bool,
skip_blanks: bool,
skip_rows: &'a std::collections::BTreeSet<u32>,
}
/// `range` less the rows in `skip`: the runs of rows between them, as ranges of its columns.
fn without_rows(range: RangeRef, skip: &std::collections::BTreeSet<u32>) -> Vec<RangeRef> {
let mut out = Vec::new();
let mut next = range.start.row;
for &row in skip.range(range.start.row..=range.end.row) {
if row > next {
out.push(RangeRef::new(CellRef::new(next, range.start.col), CellRef::new(row - 1, range.end.col)));
}
next = row + 1;
}
if next <= range.end.row {
out.push(RangeRef::new(CellRef::new(next, range.start.col), CellRef::new(range.end.row, range.end.col)));
}
out
}
/// 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.
@@ -404,9 +438,9 @@ fn paste_validations(
src: RangeRef,
dest_sheet: usize,
pasted: RangeRef,
transpose: bool,
skip_blanks: bool,
mode: PasteMode<'_>,
) -> Result<()> {
let PasteMode { transpose, skip_blanks, skip_rows } = mode;
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();
@@ -426,7 +460,9 @@ fn paste_validations(
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)) {
if let Some(d) = pasted.start.offset(i64::from(tr * h + r), i64::from(tc * w + c))
&& !skip_rows.contains(&d.row)
{
cells.push((d.row, d.col));
}
}
@@ -490,6 +526,8 @@ fn paste_validations(
}
}
}
// The rows a filter hides keep what they have.
let ranges: Vec<RangeRef> = ranges.into_iter().flat_map(|r| without_rows(r, skip_rows)).collect();
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
@@ -507,7 +545,7 @@ fn paste_validations(
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]);
super::super::data::remove_validation_cells(&mut sh.validations, &without_rows(pasted, skip_rows));
}
sh.validations.extend(rules);
Ok(())
+14 -1
View File
@@ -87,6 +87,18 @@ pub fn holds_source_in_corner(src: RangeRef, target: RangeRef) -> bool {
/// Fills `target` (which contains `src` at one edge) from the pattern in `src`.
pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: FillMode) -> Result<()> {
fill_except(cx, sheet, src, target, mode, &std::collections::BTreeSet::new())
}
/// [`fill`], leaving the rows in `skip_rows` untouched (Fill Down over a filtered range).
pub fn fill_except(
cx: &mut Ctx,
sheet: usize,
src: RangeRef,
target: RangeRef,
mode: FillMode,
skip_rows: &std::collections::BTreeSet<u32>,
) -> Result<()> {
let custom = cx.wb.custom_lists.clone();
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
// Direction: down/up if target extends rows, else right/left.
@@ -119,7 +131,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
(c, sh.cell(c).cloned())
})
.collect();
let targets: Vec<CellRef> = if vertical {
let mut targets: Vec<CellRef> = if vertical {
let rows: Vec<u32> =
if forward { (src.end.row + 1..=target.end.row).collect() } else { (target.start.row..src.start.row).rev().collect() };
rows.into_iter().map(|r| CellRef::new(r, lane)).collect()
@@ -128,6 +140,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
if forward { (src.end.col + 1..=target.end.col).collect() } else { (target.start.col..src.start.col).rev().collect() };
cols.into_iter().map(|c| CellRef::new(lane, c)).collect()
};
targets.retain(|t| !skip_rows.contains(&t.row));
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
let pattern = if mode.extends_series() || mode == FillMode::Alternate {
let first_fmt = src_cells.first().map(fmt).unwrap_or_default();
+266
View File
@@ -4997,6 +4997,246 @@ fn precision_as_displayed_rounds_numbers_written_to_a_new_sheet() {
assert!(rounded > 0, "the histogram's formatted cells should be rounded");
}
/// #1101: repeating one cell or row down a filtered selection (paste, Ctrl+Enter, Fill Down) skips
/// the rows the filter hides, as in Excel. A taller pasted block goes straight down, and rows
/// hidden by hand are written, also as in Excel (each case measured in Excel for Microsoft 365).
#[test]
fn fills_and_single_row_pastes_skip_filtered_rows() {
let fresh = s;
let filtered = || {
let mut s = fresh();
let mut rows = vec![json!(["Val", "Tag"])];
for i in 2..=11 {
rows.push(json!([format!("v{i}"), if [3, 5, 7, 9].contains(&i) { "hide" } else { "keep" }]));
}
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
s.execute("range.setValues", json!({"range": "E1", "values": [["X", "W"], ["Y", ""]]})).unwrap();
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
s.execute("data.filter", json!({})).unwrap();
assert_eq!(s.execute("data.filterBy", json!({"column": "B", "values": ["keep"]})).unwrap()["hiddenRows"], 4);
s
};
let col = |s: &Session, c: &str| -> Vec<Value> { (2..=11).map(|r| v(s, &format!("{c}{r}"))).collect() };
let skipped =
|x: &str| -> Vec<Value> { (2..=11).map(|r| Value::from(if [3, 5, 7, 9].contains(&r) { format!("v{r}") } else { x.to_string() })).collect() };
// One cell pasted over the filtered rows.
let mut s = filtered();
s.execute("edit.copy", json!({"range": "E1"})).unwrap();
s.execute("selection.set", json!({"range": "A2:A11"})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(col(&s, "A"), skipped("X"));
// One row (two cells) pasted over the filtered rows.
let mut s = filtered();
s.execute("edit.copy", json!({"range": "E1:F1"})).unwrap();
s.execute("selection.set", json!({"range": "A2:B11"})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(col(&s, "A"), skipped("X"));
assert_eq!(v(&s, "B3"), Value::from("hide"));
assert_eq!(v(&s, "B4"), Value::from("W"));
// A two-row block is pasted straight down, hidden rows included.
let mut s = filtered();
s.execute("edit.copy", json!({"range": "E1:E2"})).unwrap();
s.execute("selection.set", json!({"range": "A2:A11"})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(col(&s, "A"), ["X", "Y", "X", "Y", "X", "Y", "X", "Y", "X", "Y"].map(Value::from));
// Ctrl+Enter.
let mut s = filtered();
s.execute("selection.set", json!({"range": "A2:A11"})).unwrap();
s.execute("range.fill", json!({"input": "Z"})).unwrap();
assert_eq!(col(&s, "A"), skipped("Z"));
// Fill Down, and a formula filled down still adjusts to the row it lands in.
let mut s = filtered();
s.execute("edit.fillDown", json!({"range": "A2:A11"})).unwrap();
assert_eq!(col(&s, "A"), skipped("v2"));
s.execute("cell.set", json!({"cell": "C2", "input": "=ROW()"})).unwrap();
s.execute("edit.fillDown", json!({"range": "C2:C11"})).unwrap();
assert_eq!(v(&s, "C4"), Value::Number(4.0));
assert!(v(&s, "C3").is_empty());
// Fill Up and Fill Right skip them too.
let mut s = filtered();
s.execute("edit.fillUp", json!({"range": "A2:A11"})).unwrap();
assert_eq!(col(&s, "A"), skipped("v11"));
let mut s = filtered();
s.execute("range.setValues", json!({"range": "C2", "values": [["c2"], ["c3"], ["c4"]]})).unwrap();
s.execute("edit.fillRight", json!({"range": "A2:C4"})).unwrap();
assert_eq!([v(&s, "C2"), v(&s, "C3"), v(&s, "C4")], [Value::from("v2"), Value::from("c3"), Value::from("v4")]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v(&s, "C4"), Value::from("c4"));
// Copying the filtered range takes only the rows showing, formulas as their values; a cut
// still moves every row.
let mut s = filtered();
s.execute("cell.set", json!({"cell": "C2", "input": "=ROW()"})).unwrap();
s.execute("edit.fillDown", json!({"range": "C2:C11"})).unwrap();
let r = s.execute("edit.copy", json!({"range": "A1:C11"})).unwrap();
assert_eq!(
r["text"],
"Val Tag
v2 keep 2
v4 keep 4
v6 keep 6
v8 keep 8
v10 keep 10
v11 keep 11
"
);
s.execute("edit.paste", json!({"at": "H20"})).unwrap();
let pasted: Vec<Value> = (20..=27).map(|r| v(&s, &format!("H{r}"))).collect();
assert_eq!(pasted, ["Val", "v2", "v4", "v6", "v8", "v10", "v11"].map(Value::from).into_iter().chain([Value::Empty]).collect::<Vec<_>>());
assert_eq!(v(&s, "J22"), Value::Number(4.0));
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("J22").unwrap()).unwrap().formula.is_none());
let mut s = filtered();
s.execute("edit.cut", json!({"range": "A1:B11"})).unwrap();
s.execute("edit.paste", json!({"at": "H20"})).unwrap();
assert_eq!(v(&s, "H22"), Value::from("v3"));
// Rows hidden by hand (no filter) are written, as in Excel.
let mut s = fresh();
s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"]]})).unwrap();
s.execute("home.hideRows", json!({"rows": "2:2"})).unwrap();
s.execute("selection.set", json!({"range": "A1:A3"})).unwrap();
s.execute("range.fill", json!({"input": "Z"})).unwrap();
assert_eq!(v(&s, "A2"), Value::from("Z"));
}
/// A table of ten rows (A1:B11) with a link on every value cell, four of its rows hidden by a
/// filter (3, 5, 7, 9), and a spare corner at E1 to copy from.
fn filtered_rows_with_links() -> Session {
let mut s = s();
let mut rows = vec![json!(["Val", "Tag"])];
for i in 2..=11 {
rows.push(json!([format!("v{i}"), if [3, 5, 7, 9].contains(&i) { "hide" } else { "keep" }]));
}
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
s.execute("range.setValues", json!({"range": "E1", "values": [["X"]]})).unwrap();
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
for r in 2..=11u32 {
sh.hyperlinks
.insert(CellRef::new(r - 1, 0), gridcraft_model::Hyperlink { target: format!("https://example.com/{r}"), tooltip: None, external: true });
}
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
s.execute("data.filter", json!({})).unwrap();
s.execute("data.filterBy", json!({"column": "B", "values": ["keep"]})).unwrap();
s
}
fn cell_link_target(s: &Session, cell: &str) -> Option<String> {
s.doc().unwrap().wb.active().unwrap().hyperlinks.get(&CellRef::parse(cell).unwrap()).map(|h| h.target.clone())
}
fn cell_validation(s: &Session, cell: &str) -> Option<String> {
let c = CellRef::parse(cell).unwrap();
s.doc().unwrap().wb.active().unwrap().validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(c))).map(|d| d.f1.clone())
}
/// Copying a filtered range packs the rows showing into one block; their links and validation
/// rules travel with them, and those of the hidden rows are left behind.
#[test]
fn filtered_copy_pastes_links_and_validation_of_the_rows_showing_only() {
let mut s = filtered_rows_with_links();
for (cell, source) in [("A3", "\"hidden\""), ("A4", "\"showing\""), ("A9", "\"hidden too\"")] {
s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap();
}
s.execute("edit.copy", json!({"range": "A1:B11"})).unwrap();
s.execute("edit.paste", json!({"at": "H20"})).unwrap();
let links: Vec<Option<String>> = (20..=26).map(|r| cell_link_target(&s, &format!("H{r}"))).collect();
let expected: Vec<Option<String>> =
[None, Some(2u32), Some(4), Some(6), Some(8), Some(10), Some(11)].iter().map(|n| n.map(|n| format!("https://example.com/{n}"))).collect();
assert_eq!(links, expected);
let rules: Vec<Option<String>> = (20..=26).map(|r| cell_validation(&s, &format!("H{r}"))).collect();
assert_eq!(rules, [None, None, Some("\"showing\"".to_string()), None, None, None, None]);
// The source keeps its own links and rules where they were.
assert_eq!(cell_link_target(&s, "A3").as_deref(), Some("https://example.com/3"));
assert_eq!(cell_validation(&s, "A3").as_deref(), Some("\"hidden\""));
}
/// One cell pasted over a filtered column leaves the rules of the rows the filter hides alone, and
/// does not give them the copied rule, with or without Skip Blanks.
#[test]
fn single_row_paste_over_filtered_rows_leaves_their_validation_alone() {
for skip_blanks in [false, true] {
let mut s = filtered_rows_with_links();
s.execute("data.validation", json!({"range": "E1", "type": "list", "formula1": "\"copied\""})).unwrap();
s.execute("data.validation", json!({"range": "A2:A5", "type": "list", "formula1": "\"old\""})).unwrap();
s.execute("edit.copy", json!({"range": "E1"})).unwrap();
s.execute("selection.set", json!({"range": "A2:A11"})).unwrap();
s.execute("edit.pasteSpecial", json!({"what": "all", "skipBlanks": skip_blanks})).unwrap();
let rules: Vec<Option<String>> = (2..=11).map(|r| cell_validation(&s, &format!("A{r}"))).collect();
let showing = Some("\"copied\"".to_string());
let old = Some("\"old\"".to_string());
assert_eq!(
rules,
[showing.clone(), old.clone(), showing.clone(), old, showing.clone(), None, showing.clone(), None, showing.clone(), showing],
"skip blanks: {skip_blanks}"
);
}
}
/// Insert Copied Cells after a filtered copy of whole rows makes room for the rows copied, not for
/// the rows that were selected.
#[test]
fn insert_copied_cells_after_a_filtered_row_copy_inserts_only_the_rows_showing() {
let mut s = filtered_rows_with_links();
s.execute("cell.set", json!({"cell": "A30", "input": "below"})).unwrap();
s.execute("edit.copy", json!({"range": "1:11"})).unwrap();
s.execute("selection.set", json!({"range": "A20"})).unwrap();
s.execute("home.insertCopiedCells", json!({})).unwrap();
// Seven rows were copied (the header and six rows showing).
assert_eq!(v(&s, "A37"), Value::from("below"));
assert_eq!(v(&s, "A20"), Value::from("Val"));
assert_eq!(v(&s, "A26"), Value::from("v11"));
assert_eq!(v(&s, "A27"), Value::Empty);
}
/// Copying a filtered range with many separate runs of rows showing moves each row's note, link and
/// merge with it, and leaves the hidden rows' behind.
#[test]
fn filtered_copy_with_many_runs_moves_notes_and_merges_with_their_rows() {
let mut s = s();
let mut rows = vec![json!(["Val", "Tag"])];
for i in 2..=2001 {
rows.push(json!([format!("v{i}"), if i % 2 == 1 { "hide" } else { "keep" }]));
}
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
for r in 2..=2001u32 {
let note = gridcraft_model::Comment {
author: "a".into(),
text: format!("note {r}"),
replies: vec![],
threaded: false,
resolved: false,
visible: false,
dates: vec![],
note_box: None,
};
sh.comments.insert(CellRef::new(r - 1, 0), note);
}
sh.merges.push(gridcraft_core::RangeRef::new(CellRef::new(3, 1), CellRef::new(3, 2)));
sh.merges.push(gridcraft_core::RangeRef::new(CellRef::new(2, 1), CellRef::new(2, 2)));
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
s.execute("data.filter", json!({})).unwrap();
s.execute("data.filterBy", json!({"column": "B", "values": ["keep"]})).unwrap();
s.execute("edit.copy", json!({"range": "A1:C2001"})).unwrap();
s.execute("edit.paste", json!({"at": "H1"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let note = |row: u32| sh.comments.get(&CellRef::new(row - 1, 7)).map(|c| c.text.clone());
assert_eq!(note(1), None);
assert_eq!(note(2).as_deref(), Some("note 2"));
assert_eq!(note(3).as_deref(), Some("note 4"));
assert_eq!(note(1001).as_deref(), Some("note 2000"));
assert_eq!(sh.comments.keys().filter(|c| c.col == 7).count(), 1000);
// Only the merge on a row showing (row 4) came along, packed to the row it landed in (row 3).
assert!(sh.merges.contains(&gridcraft_core::RangeRef::new(CellRef::new(2, 8), CellRef::new(2, 9))));
assert_eq!(sh.merges.iter().filter(|m| m.start.col >= 7).count(), 1);
}
#[test]
fn editing_quote_prefixed_text_keeps_it_text() {
let mut s = s();
@@ -5257,6 +5497,32 @@ fn paste_validation_skip_blanks_large_populated_paste() {
assert!(validation_areas(&s).is_empty());
}
/// A whole-column copy of a filtered range takes only the rows showing, also when they form one
/// block (the hidden rows are all at the end) and the selection covers the whole column.
#[test]
fn whole_column_copy_of_a_filtered_range_leaves_the_hidden_rows_out() {
for hidden_tail in [false, true] {
let mut s = s();
let mut rows = vec![json!(["Val", "Tag"])];
for i in 2..=11 {
let hide = if hidden_tail { i >= 8 } else { [3, 5, 7, 9].contains(&i) };
rows.push(json!([format!("v{i}"), if hide { "hide" } else { "keep" }]));
}
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
s.execute("data.filter", json!({})).unwrap();
s.execute("data.filterBy", json!({"column": "B", "values": ["keep"]})).unwrap();
s.execute("cell.set", json!({"cell": "H30", "input": "old"})).unwrap();
s.execute("edit.copy", json!({"range": "A:B"})).unwrap();
s.execute("edit.paste", json!({"at": "H1"})).unwrap();
let col: Vec<Value> = (1..=11).map(|r| v(&s, &format!("H{r}"))).collect();
let want: Vec<&str> =
if hidden_tail { vec!["Val", "v2", "v3", "v4", "v5", "v6", "v7"] } else { vec!["Val", "v2", "v4", "v6", "v8", "v10", "v11"] };
let want: Vec<Value> = want.into_iter().map(Value::from).chain(std::iter::repeat(Value::Empty)).take(11).collect();
assert_eq!(col, want, "hidden rows stay out of a whole-column copy (hidden tail: {hidden_tail})");
}
}
/// 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]
+8 -6
View File
@@ -491,12 +491,14 @@ impl Sheet {
/// sheet AutoFilter while it has criteria, or a hidden data row of a filtered table.
/// Other hidden rows were hidden by hand.
pub fn is_row_filtered(&self, row: u32) -> bool {
if !self.is_row_hidden(row) {
return false;
}
let in_sheet_filter =
self.autofilter.as_ref().is_some_and(|af| !af.criteria.is_empty() && row > af.range.start.row && row <= af.range.end.row);
in_sheet_filter || self.tables.iter().any(|t| !t.filter.is_empty() && t.data_range().is_some_and(|r| row >= r.start.row && row <= r.end.row))
self.is_row_hidden(row) && self.filter_row_spans().iter().any(|&(first, last)| row >= first && row <= last)
}
/// The row spans (first, last) an applied filter can hide: the data rows of the sheet
/// AutoFilter while it has criteria, and of each filtered table.
pub fn filter_row_spans(&self) -> Vec<(u32, u32)> {
let sheet = self.autofilter.as_ref().filter(|af| !af.criteria.is_empty()).map(|af| (af.range.start.row.saturating_add(1), af.range.end.row));
let tables = self.tables.iter().filter(|t| !t.filter.is_empty()).filter_map(|t| t.data_range()).map(|r| (r.start.row, r.end.row));
sheet.into_iter().chain(tables).collect()
}
pub fn is_col_hidden(&self, col: u32) -> bool {
self.cols.get(&col).is_some_and(|r| r.hidden)