From 640fbf22f64e8726188ff604480a2c6f3e6aad42 Mon Sep 17 00:00:00 2001 From: richard1912 <59070720+richard1912@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:30:06 +1100 Subject: [PATCH] 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 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 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 Co-authored-by: Claude Opus 5.5 Co-authored-by: skylar --- crates/engine/src/cmd/edit.rs | 7 +- crates/engine/src/cmd/edit/cell.rs | 4 + crates/engine/src/cmd/edit/clipboard.rs | 142 +++++++++-- crates/engine/src/cmd/edit/fill.rs | 3 +- crates/engine/src/cmd/edit/paste_special.rs | 48 +++- crates/engine/src/fill.rs | 15 +- crates/engine/src/tests.rs | 266 ++++++++++++++++++++ crates/model/src/sheet.rs | 14 +- 8 files changed, 469 insertions(+), 30 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 83133c01c..8c7072f52 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -239,7 +239,12 @@ fn redo(s: &mut Session, p: &Json) -> Result { /// 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 { - 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 diff --git a/crates/engine/src/cmd/edit/cell.rs b/crates/engine/src/cmd/edit/cell.rs index 610135682..c3fe44385 100644 --- a/crates/engine/src/cmd/edit/cell.rs +++ b/crates/engine/src/cmd/edit/cell.rs @@ -328,7 +328,11 @@ pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result { 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(), diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index da34c017a..9feb380a0 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -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 { 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 { 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 { 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) -> Vec { + 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 { + 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> = 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 { + 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 { + 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)) } diff --git a/crates/engine/src/cmd/edit/fill.rs b/crates/engine/src/cmd/edit/fill.rs index 8cd1e3d88..489eca2a4 100644 --- a/crates/engine/src/cmd/edit/fill.rs +++ b/crates/engine/src/cmd/edit/fill.rs @@ -31,8 +31,9 @@ pub(super) fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result 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) }; + // 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 { 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 { } 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, +} + +/// `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) -> Vec { + 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 = 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(()) diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index efa09310a..25e137878 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -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, +) -> 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 = if vertical { + let mut targets: Vec = if vertical { let rows: Vec = 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)| 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(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 445ccf545..f97e1bc10 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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 { (2..=11).map(|r| v(s, &format!("{c}{r}"))).collect() }; + let skipped = + |x: &str| -> Vec { (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 = (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::>()); + 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 { + 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 { + 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> = (20..=26).map(|r| cell_link_target(&s, &format!("H{r}"))).collect(); + let expected: Vec> = + [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> = (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> = (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 = (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 = 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] diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index 67ca1f13e..440647d42 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -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)