mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-08 03:22:59 +00:00
* 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:
8 files changed
+469
-30
No files matched your search
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user