diff --git a/ROADMAP.md b/ROADMAP.md index 59b93757b..aff7f6374 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -151,6 +151,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-13 | Charts: a huge trendline forecast no longer collapses the chart: on a category axis it adds at most four times the data in empty slots (at least 32), the line is sampled over the plot only, and forecasts read from XLSX or set by command are clamped, NaN and infinity becoming 0 (#1148) | 2026-10-13 | XLSX export writes a picture used on several sheets once, as one `xl/media` part every drawing points to (it wrote a copy per drawing: a 16-sheet workbook with one 112 KB logo grew from 0.4 to 1.9 MB) (#985) | | 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) | +| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | @@ -221,6 +222,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-13 | trivial | Progress log: More Colors… and Recent Colors in the colour menus (#928) | | 2026-10-13 | minor | Multi-level category axes (read, drawn, written); progress log row | | 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) | +| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index eac6c1354..4856b565d 100644 --- a/apps/gridcraft-web/Cargo.toml +++ b/apps/gridcraft-web/Cargo.toml @@ -26,4 +26,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions"] } diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index c0fcb22da..215fc7f98 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -20,6 +20,17 @@ pub fn start() { return; }; let mut options = eframe::WebOptions::default(); + if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_options.wgpu_setup { + let default_descriptor = std::sync::Arc::clone(&setup.device_descriptor); + + setup.device_descriptor = std::sync::Arc::new(move |adapter| { + let mut descriptor = default_descriptor(adapter); + + descriptor.required_limits = descriptor.required_limits.or_worse_values_from(&adapter.limits()); + + descriptor + }); + } if query().contains("webgl") && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { @@ -224,15 +235,20 @@ fn print_pdf(bytes: &[u8]) -> Result<(), String> { let window = web_sys::window().ok_or("no window")?; let document = window.document().ok_or("no document")?; + let iframe: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(js)?.dyn_into().map_err(|_| "not an iframe")?; + iframe.style().set_property("display", "none").map_err(js)?; + let body = document.body().ok_or("no body")?; + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); let opts = web_sys::BlobPropertyBag::new(); opts.set_type("application/pdf"); let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?; let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?; - let iframe: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(js)?.dyn_into().map_err(|_| "not an iframe")?; - iframe.style().set_property("display", "none").map_err(js)?; - document.body().ok_or("no body")?.append_child(&iframe).map_err(js)?; + if let Err(e) = body.append_child(&iframe) { + cleanup_print(&iframe, &url); + return Err(js(e)); + } // `onload` fires once the PDF has actually rendered inside the iframe; // printing before that would show a blank page. Set the handler BEFORE @@ -240,22 +256,44 @@ fn print_pdf(bytes: &[u8]) -> Result<(), String> { let iframe_for_load = iframe.clone(); let url_for_cleanup = url.clone(); let on_load = wasm_bindgen::closure::Closure::once_into_js(move || { - let Some(content_window) = iframe_for_load.content_window() else { return }; - let _ = content_window.print(); - // `afterprint` fires once the print dialog (or print preview) closes, whether the user - // printed or cancelled — the right moment to release the blob URL and drop the iframe. - let iframe_for_cleanup = iframe_for_load.clone(); - let on_after_print = wasm_bindgen::closure::Closure::once_into_js(move || { - web_sys::Url::revoke_object_url(&url_for_cleanup).ok(); - iframe_for_cleanup.remove(); - }); - let _ = content_window.add_event_listener_with_callback("afterprint", on_after_print.unchecked_ref()); + iframe_for_load.set_onload(None); + if let Err(e) = print_loaded_pdf(&iframe_for_load, &url_for_cleanup) { + cleanup_print(&iframe_for_load, &url_for_cleanup); + log::error!("print failed: {e:?}"); + } }); iframe.set_onload(Some(on_load.unchecked_ref())); iframe.set_src(&url); Ok(()) } +fn cleanup_print(iframe: &web_sys::HtmlIFrameElement, url: &str) { + // Both operations are idempotent: print can fail after dispatching `afterprint`. + web_sys::Url::revoke_object_url(url).ok(); + iframe.remove(); +} + +fn print_loaded_pdf(iframe: &web_sys::HtmlIFrameElement, url: &str) -> Result<(), JsValue> { + let content_window = iframe.content_window().ok_or_else(|| JsValue::from_str("no print content window"))?; + let iframe_for_cleanup = iframe.clone(); + let url_for_cleanup = url.to_owned(); + let on_after_print = wasm_bindgen::closure::Closure::once(move || { + cleanup_print(&iframe_for_cleanup, &url_for_cleanup); + }); + // Listen before `print()`, which can dispatch `afterprint` before it returns. + // A one-shot listener also releases the callback when the dialog closes or is cancelled. + let options = web_sys::AddEventListenerOptions::new(); + options.set_once(true); + let callback = on_after_print.as_ref().unchecked_ref(); + content_window.add_event_listener_with_callback_and_add_event_listener_options("afterprint", callback, &options)?; + if let Err(e) = content_window.print() { + let _ = content_window.remove_event_listener_with_callback("afterprint", callback); + return Err(e); + } + let _ = on_after_print.into_js_value(); + Ok(()) +} + fn mime_for(name: &str) -> &'static str { match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() { Some("xlsx") => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", @@ -274,7 +312,6 @@ fn mime_for(name: &str) -> &'static str { } /// Trigger a browser save dialog or download of `bytes` named after `name`. - fn download(name: &str, bytes: &[u8]) -> Result<(), String> { let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); let window = web_sys::window().ok_or("no window")?; diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 30426ee2e..c44094cdd 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -831,6 +831,9 @@ fn table_resize(s: &mut Session, p: &Json) -> Result { edit(s, |cx| { let sheet = cx.sheet_mut(si)?; let headers: Vec = (r.start.col..=r.end.col).map(|c| sheet.value(CellRef::new(r.start.row, c)).display()).collect(); + if sheet.tables.iter().enumerate().any(|(i, o)| i != ti && o.range.intersects(&r)) { + return Err(EngineError::Other("A table can't overlap another table.".into())); + } let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?; if r.start.row != t.range.start.row || !r.intersects(&t.range) { return Err(EngineError::Other( diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 52049528f..5334784fa 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -248,7 +248,7 @@ fn valid_name(n: &str) -> bool { /// Looks like an R1C1 reference (`R`, `C`, `RC`, `R2`, `C3`, `R1C1`), which Excel doesn't /// accept as a name. -fn is_r1c1(n: &str) -> bool { +pub(crate) fn is_r1c1(n: &str) -> bool { let digits = |s: &str| s.bytes().take_while(u8::is_ascii_digit).count(); let mut rest = n; if let Some(r) = rest.strip_prefix(['R', 'r']) { diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 34eee12dd..3856fe66f 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -2,9 +2,11 @@ //! conditional formatting (Home › Styles) and symbols. use gridcraft_core::{CellRef, MAX_ROWS, RangeRef}; +use gridcraft_formula::adjust::{Axis, Edit}; use gridcraft_model::*; use serde_json::{Value as Json, json}; +use super::sheet::{rewrite, shift_cell_features}; use super::*; use crate::selection::current_region; @@ -276,13 +278,13 @@ fn insert_table(s: &mut Session, p: &Json) -> Result { return Err(EngineError::Other("To prevent possible loss of data, cells can't be pushed off the end of the worksheet.".into())); } sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1); - sh.cell_pictures = std::mem::take(&mut sh.cell_pictures) - .into_iter() - .filter_map(|(c, v)| { - let dest = if c.col >= r.start.col && c.col <= r.end.col && c.row >= r.start.row { c.offset(1, 0) } else { Some(c) }; - dest.map(|c| (c, v)) - }) - .collect(); + // Like Insert Cells > Shift down: move merges, conditional formats, notes, links and pictures, and re-point every formula + // reference (on any sheet) at the cells that moved. + let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) }; + shift_cell_features(sh, &e); + let name = sh.name.clone(); + rewrite(&mut cx.wb, &name, &e); + let sh = cx.sheet_mut(sheet)?; for (i, c) in (r.start.col..=r.end.col).enumerate() { sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1))); } @@ -472,6 +474,7 @@ fn valid_table_name(name: &str) -> bool { name.chars().count() <= 255 && name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\') && CellRef::parse(name).is_none() + && !super::formulas::is_r1c1(name) && name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\')) } @@ -481,7 +484,8 @@ fn table_rename(s: &mut Session, p: &Json) -> Result { if !valid_table_name(&name) { return Err(EngineError::Other("The name that you entered is not valid.".into())); } - if s.doc()?.wb.table(&name).is_some_and(|x| x != (si, ti)) { + let wb = &s.doc()?.wb; + if wb.table(&name).is_some_and(|x| x != (si, ti)) || wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) { return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into())); } edit(s, |cx| { @@ -1554,4 +1558,50 @@ mod tests { let rules = s.execute("home.manageRules", json!({})).unwrap(); assert_eq!(rules["rules"].as_array().map(Vec::len), Some(0)); } + + #[test] + fn table_without_header_keeps_outside_references() { + // #1030: the data moved down for the new header row, but formulas outside kept the old addresses. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.insertSheet", json!({"name": "Two"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "10"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "20"})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A1:A2)"})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=A2"})).unwrap(); + s.execute("cell.set", json!({"sheet": "Two", "cell": "A1", "input": "=Sheet1!A2"})).unwrap(); + s.execute("insert.table", json!({"range": "A1:A2", "header": false})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "A1")["value"], "Column1"); + assert_eq!(get(&mut s, "A2")["value"], json!(10.0)); + assert_eq!(get(&mut s, "B1")["formula"], "=SUM(A2:A3)"); + assert_eq!(get(&mut s, "B1")["value"], json!(30.0)); + assert_eq!(get(&mut s, "C1")["formula"], "=A3"); + assert_eq!(get(&mut s, "C1")["value"], json!(20.0)); + let other = s.execute("cell.get", json!({"sheet": "Two", "cell": "A1"})).unwrap(); + assert_eq!(other["formula"], "=Sheet1!A3"); + assert_eq!(other["value"], json!(20.0)); + } + + #[test] + fn table_names_refuse_defined_names_and_r1c1() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:A2", "values": [["h"], ["1"]]})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=0.2"})).unwrap(); + s.execute("insert.table", json!({"range": "A1:A2"})).unwrap(); + let e = s.execute("table.rename", json!({"table": "Table1", "name": "Rate"})).unwrap_err(); + assert!(e.to_string().contains("already exists"), "{e}"); + let e = s.execute("table.rename", json!({"table": "Table1", "name": "rate"})).unwrap_err(); + assert!(e.to_string().contains("already exists"), "{e}"); + for n in ["R2C3", "c", "r", "RC", "C5"] { + let e = s.execute("table.rename", json!({"table": "Table1", "name": n})).unwrap_err(); + assert!(e.to_string().contains("not valid"), "{n}: {e}"); + let e = s.execute("insert.table", json!({"range": "C1:C2", "name": n})).unwrap_err(); + assert!(e.to_string().contains("not valid"), "{n}: {e}"); + } + s.execute("table.rename", json!({"table": "Table1", "name": "Table1"})).unwrap(); + s.execute("table.rename", json!({"table": "Table1", "name": "Rates"})).unwrap(); + } } diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 6205a510a..43c3aa4b5 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -2314,4 +2314,53 @@ mod tests { // 64 Sub items under the collapsed "g" show as one column group: (1 + grand total) x 256 columns fit. s.execute("pivot.addField", json!({"field": "Sub", "area": "columns"})).unwrap(); } + + #[test] + fn inserting_or_deleting_lines_moves_the_pivot_with_its_cells() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!H10", "rows": ["Region"], "values": ["Sales"]})) + .unwrap(); + assert_eq!(pt(&s).last_range.unwrap().a1(), "H10:I14"); + // Rows above the report (and below the source) move the report only. + s.execute("home.insertRows", json!({"rows": "16:17"})).unwrap(); + s.execute("home.insertRows", json!({"rows": "6:7"})).unwrap(); + assert_eq!(text(&s, "H12"), "Row Labels"); + let l = s.execute("pivot.list", json!({})).unwrap(); + assert_eq!(l[0]["anchor"], "H12"); + assert_eq!(l[0]["range"], "H12:I16"); + assert_eq!(l[0]["source"], "Sheet1!A1:E15"); + // A row above the source moves both. + s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap(); + let p = pt(&s); + assert_eq!(p.source, "Sheet1!A2:E16"); + assert_eq!(p.anchor.a1(), "H13"); + s.execute("pivot.refresh", json!({})).unwrap(); + assert_eq!(pt(&s).last_range.unwrap().a1(), "H13:I17"); + assert_eq!(text(&s, "H13"), "Row Labels"); + assert_eq!(val(&s, "I17"), Value::Number(TOTAL)); + // Deleting a column left of the report moves it left. + s.execute("home.deleteColumns", json!({"cols": "F:F"})).unwrap(); + let p = pt(&s); + assert_eq!(p.anchor.a1(), "G13"); + assert_eq!(p.last_range.unwrap().a1(), "G13:H17"); + // Deleting rows above moves it up. + s.execute("home.deleteRows", json!({"rows": "1:1"})).unwrap(); + assert_eq!(pt(&s).source, "Sheet1!A1:E15"); + assert_eq!(pt(&s).anchor.a1(), "G12"); + s.execute("pivot.refresh", json!({})).unwrap(); + assert_eq!(text(&s, "G12"), "Row Labels"); + assert_eq!(val(&s, "H16"), Value::Number(TOTAL)); + } + + #[test] + fn inserting_rows_on_the_source_sheet_moves_another_sheets_pivot_source() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "rows": ["Region"], "values": ["Sales"]})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + s.execute("home.insertRows", json!({"rows": "1:2"})).unwrap(); + let l = s.execute("pivot.list", json!({})).unwrap(); + assert_eq!(l[0]["source"], "Sheet1!A3:E15"); + assert_eq!(l[0]["anchor"], "A3"); + s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap(); + } } diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 28f73e304..a3ef08a49 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -211,10 +211,19 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: if let Some(pa) = sh.print.print_area { sh.print.print_area = map_range(pa); } + // PivotTables follow their report cells. A deleted anchor line collapses onto the line that + // takes its place; a report deleted entirely has no last area until the next refresh. + for pt in sh.pivots.iter_mut() { + pt.anchor = map_cell(pt.anchor).unwrap_or_else(|| match axis { + Axis::Rows => CellRef::new(at, pt.anchor.col), + Axis::Cols => CellRef::new(pt.anchor.row, at), + }); + pt.last_range = pt.last_range.and_then(map_range); + } } /// Rewrites every formula reference (and chart series) for a structural edit on `target`. -fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) { +pub(super) fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) { super::edit::rewrite_all_formulas(wb, target, e); for si in 0..wb.sheets.len() { let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default(); @@ -235,6 +244,18 @@ fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) { for sp in sh.sparklines.iter_mut() { sp.source = fix(&sp.source); } + // PivotTable sources (a range, table or defined name) are only rewritten when the edit + // moves them, so table and name sources keep their text. + for pt in sh.pivots.iter_mut() { + let body = pt.source.trim().trim_start_matches('='); + if let Ok(expr) = gridcraft_formula::parse(body) { + let before = gridcraft_formula::print(&expr); + let after = gridcraft_formula::print(&gridcraft_formula::adjust::adjust(expr, &host, target, e)); + if after != before { + pt.source = after; + } + } + } } } @@ -469,6 +490,15 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; if let Some(sh) = s.doc()?.wb.sheet(sheet) { check_table_shift(sh, &e)?; + // Refuse to push data off the sheet: cells in the last `width`/`height` positions would be dropped. + let doomed = if shift == "right" { + RangeRef::new(CellRef::new(r.start.row, r.start.col.max(MAX_COLS.saturating_sub(r.width()))), CellRef::new(r.end.row, MAX_COLS - 1)) + } else { + RangeRef::new(CellRef::new(r.start.row.max(MAX_ROWS.saturating_sub(r.height())), r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col)) + }; + if sh.cells.iter_range(doomed).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) { + return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into())); + } } let right = shift == "right"; let out = edit(s, |cx| { @@ -944,17 +974,18 @@ fn activate(s: &mut Session, p: &Json) -> Result { Ok(json!({"sheet": i, "name": d.wb.active().map(|s| s.name.clone())})) } -fn step_sheet(s: &mut Session, dir: i64) -> Result { +fn step_sheet(s: &mut Session, dir: isize) -> Result { let d = s.doc()?; - let n = d.wb.sheets.len() as i64; - let mut i = d.wb.active_sheet as i64; - for _ in 0..n { - i = (i + dir).clamp(0, n - 1); - if d.wb.sheet(i as usize).is_some_and(|s| s.visibility == Visibility::Visible) { - break; + let mut i = d.wb.active_sheet; + while let Some(next) = i.checked_add_signed(dir) { + let Some(sheet) = d.wb.sheet(next) else { break }; + if sheet.visibility == Visibility::Visible { + s.doc_mut()?.wb_switch_sheet(next); + return ok(); } + i = next; } - s.doc_mut()?.wb_switch_sheet(i as usize); + // No visible sheet in this direction: keep the current sheet and its selection. ok() } @@ -975,7 +1006,7 @@ pub(crate) fn map_range(r: RangeRef, e: &Edit) -> Option { /// Shifts what Insert/Delete Cells moves along with the cells: merged areas, conditional /// format, validation, table, filter and print-area ranges, notes, links and sparklines. -fn shift_cell_features(sh: &mut Sheet, e: &Edit) { +pub(super) fn shift_cell_features(sh: &mut Sheet, e: &Edit) { let map_cell = |c: CellRef| map_range(RangeRef::cell(c), e).map(|r| r.start); sh.merges = sh.merges.iter().filter_map(|m| map_range(*m, e)).filter(|m| !m.is_single()).collect(); for cf in sh.cond_formats.iter_mut() { diff --git a/crates/engine/src/cmd/slicer.rs b/crates/engine/src/cmd/slicer.rs index 00342a5a9..cb72ded71 100644 --- a/crates/engine/src/cmd/slicer.rs +++ b/crates/engine/src/cmd/slicer.rs @@ -97,11 +97,21 @@ fn reapply_slicer(wb: &mut Workbook, sheet: usize, id: u32) { } fn insert_slicer(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; + // A table named by `table` is found on any sheet, as the other table commands find it, and the slicer goes on that + // table's sheet. Looked up on the active sheet only, a named table on another sheet gave "Select a cell inside a + // table first, or pass `table`" although `table` was passed. + let named = match str_param(p, "table") { + Some(name) => Some(s.doc()?.wb.table(name).ok_or_else(|| bad("insert.slicer", format!("no table named `{name}`")))?), + None => None, + }; + let sheet = match named { + Some((si, _)) => si, + None => target_sheet(s, p)?, + }; let d = s.doc()?; let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - let t = match str_param(p, "table") { - Some(name) => sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(name)), + let t = match named { + Some((_, ti)) => sh.tables.get(ti), None => sh.table_at(d.selection.active), } .ok_or_else(|| EngineError::Other("Select a cell inside a table first, or pass `table`.".into()))?; @@ -119,7 +129,7 @@ fn insert_slicer(s: &mut Session, p: &Json) -> Result { } }; let table = t.name.clone(); - let at = cell_param(s, p, "at")? + let at = cell_param_on(s, p, "at", sheet)? .unwrap_or_else(|| CellRef::new(t.range.start.row, t.range.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1))); let columns = u32_param(p, "columns").unwrap_or(1).clamp(1, 10); let width = f64_param(p, "width").unwrap_or(168.0).clamp(60.0, 10000.0) as f32; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5a22bd6d0..eb7452577 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1438,6 +1438,35 @@ fn table_names_are_limited_to_255_characters() { assert!(s.doc().unwrap().wb.table(&"ع".repeat(200)).is_some()); } +#[test] +fn slicer_finds_a_named_table_on_another_sheet() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 2], ["West", 5]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:B3", "name": "Sales"})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Report"})).unwrap(); + let report = s.doc().unwrap().wb.sheet_index("Report").unwrap(); + assert_eq!(s.doc().unwrap().wb.active_sheet, report); + + // With another sheet active, `table` still names the table, and the slicer goes on the table's sheet. + let r = s.execute("insert.slicer", json!({"table": "Sales", "column": "Region", "at": "D2"})).unwrap(); + let id = r["slicer"].as_u64().unwrap(); + let home = s.doc().unwrap().wb.sheet_index("Sheet1").unwrap(); + assert_eq!(s.doc().unwrap().wb.sheet(home).unwrap().slicers.len(), 1); + assert!(s.doc().unwrap().wb.sheet(report).unwrap().slicers.is_empty()); + // It filters the table: "East" leaves the East row. + s.execute("selection.set", json!({"range": "Sheet1!A1"})).unwrap(); + s.execute("slicer.toggle", json!({"slicer": id, "value": "East"})).unwrap(); + let sh = s.doc().unwrap().wb.sheet(home).unwrap(); + assert!(!sh.is_row_hidden(1) && sh.is_row_hidden(2)); + + // `at` may name the table's sheet, and a table that isn't there is named in the error. + s.execute("home.insertSheet", json!({"name": "Notes"})).unwrap(); + s.execute("table.insertSlicer", json!({"table": "sales", "column": "Qty", "at": "Sheet1!G2"})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheet(home).unwrap().slicers.len(), 2); + let e = s.execute("insert.slicer", json!({"table": "Missing", "column": "Region"})).unwrap_err(); + assert!(e.to_string().contains("Missing"), "{e}"); +} + #[test] fn slicer_filters_table() { let mut s = s(); @@ -1541,6 +1570,24 @@ fn a_whole_column_table_without_headers_stays_on_the_sheet() { assert!(e.to_string().contains("pushed off the end"), "{e}"); } +#[test] +fn inserting_cells_refuses_to_push_values_off_the_sheet() { + for (shift, edge, last_fit, last_ok) in [("right", "XFD1", "XFC1", "XFD1"), ("down", "A1048576", "A1048575", "A1048576")] { + let mut sess = s(); + sess.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + sess.execute("cell.set", json!({"cell": edge, "input": "2"})).unwrap(); + let e = sess.execute("home.insertCells", json!({"range": "A1", "shift": shift})).unwrap_err(); + assert!(e.to_string().contains("loss of data"), "{e}"); + assert_eq!(v(&sess, "A1"), Value::Number(1.0)); + assert_eq!(v(&sess, edge), Value::Number(2.0)); + // A value that still fits moves to the last valid cell. + let mut sess = s(); + sess.execute("cell.set", json!({"cell": last_fit, "input": "2"})).unwrap(); + sess.execute("home.insertCells", json!({"range": "A1", "shift": shift})).unwrap(); + assert_eq!(v(&sess, last_ok), Value::Number(2.0)); + } +} + #[test] fn autosum_over_whole_rows_is_one_undo_step_within_the_data() { // Found by the command-sequence fuzzer: AutoSum over rows `3:5` ran one `cell.set` per column diff --git a/crates/engine/tests/hidden_sheet_navigation.rs b/crates/engine/tests/hidden_sheet_navigation.rs new file mode 100644 index 000000000..f457f4bdb --- /dev/null +++ b/crates/engine/tests/hidden_sheet_navigation.rs @@ -0,0 +1,78 @@ +//! Sheet navigation must never activate hidden sheets when it reaches a workbook boundary. + +use std::sync::Arc; + +use gridcraft_engine::{Session, model::Visibility}; +use serde_json::json; + +fn workbook(hidden: &[usize], active: usize) -> Session { + let mut session = Session::new(); + session.new_workbook(); + for name in ["Second", "Third", "Fourth"] { + session.execute("home.insertSheet", json!({"name": name})).unwrap(); + } + for sheet in hidden { + session.execute("sheet.hide", json!({"sheet": sheet})).unwrap(); + } + session.execute("sheet.activate", json!({"sheet": active})).unwrap(); + session.execute("selection.set", json!({"cell": "C7"})).unwrap(); + session +} + +#[test] +fn next_stays_on_the_last_visible_sheet() { + let mut session = workbook(&[2, 3], 1); + for _ in 0..2 { + session.execute("sheet.next", json!({})).unwrap(); + let doc = session.active().unwrap(); + assert_eq!(doc.wb.active_sheet, 1); + assert_eq!(doc.selection.active.a1(), "C7"); + } +} + +#[test] +fn previous_stays_on_the_first_visible_sheet() { + let mut session = workbook(&[0, 1], 2); + for _ in 0..2 { + session.execute("sheet.previous", json!({})).unwrap(); + let doc = session.active().unwrap(); + assert_eq!(doc.wb.active_sheet, 2); + assert_eq!(doc.selection.active.a1(), "C7"); + } +} + +#[test] +fn navigation_skips_hidden_sheets_in_both_directions() { + let mut session = workbook(&[1, 2], 0); + session.execute("sheet.next", json!({})).unwrap(); + assert_eq!(session.active().unwrap().wb.active_sheet, 3); + session.execute("sheet.previous", json!({})).unwrap(); + let doc = session.active().unwrap(); + assert_eq!(doc.wb.active_sheet, 0); + assert_eq!(doc.selection.active.a1(), "C7"); +} + +#[test] +fn navigation_with_one_visible_sheet_is_a_noop() { + let mut session = workbook(&[0, 2, 3], 1); + for command in ["sheet.previous", "sheet.next"] { + session.execute(command, json!({})).unwrap(); + let doc = session.active().unwrap(); + assert_eq!(doc.wb.active_sheet, 1); + assert_eq!(doc.selection.active.a1(), "C7"); + } +} + +#[test] +fn very_hidden_sheets_are_skipped_at_interior_and_boundary_positions() { + let mut session = workbook(&[1, 3], 0); + // VeryHidden is an imported XLSX state; sheet.hide only creates Hidden sheets. + let wb = Arc::make_mut(&mut session.active_mut().unwrap().wb); + for index in [1, 3] { + Arc::make_mut(&mut wb.sheets[index]).visibility = Visibility::VeryHidden; + } + for (command, expected) in [("sheet.next", 2), ("sheet.next", 2), ("sheet.previous", 0)] { + session.execute(command, json!({})).unwrap(); + assert_eq!(session.active().unwrap().wb.active_sheet, expected); + } +} diff --git a/crates/engine/tests/table_resize_overlap.rs b/crates/engine/tests/table_resize_overlap.rs index f8442cc01..436c941a8 100644 --- a/crates/engine/tests/table_resize_overlap.rs +++ b/crates/engine/tests/table_resize_overlap.rs @@ -27,3 +27,30 @@ fn resize_to_overlapping_range_still_works() { s.execute("table.resize", json!({"table":"T", "range":"B1:C2"})).unwrap(); assert_eq!(table_range(&s), "B1:C2"); } + +fn two_tables() -> Session { + let mut s = table(); + s.execute("range.setValues", json!({"range":"D1", "values":[["P", "Q"], ["y", "8"]]})).unwrap(); + s.execute("insert.table", json!({"range":"D1:E2", "header":true, "name":"U"})).unwrap(); + s +} + +fn ranges(s: &Session) -> Vec { + s.doc().unwrap().wb.active().unwrap().tables.iter().map(|t| t.range.a1()).collect() +} + +#[test] +fn resize_over_another_table_is_rejected() { + let mut s = two_tables(); + assert!(s.execute("table.resize", json!({"table":"T", "range":"A1:E2"})).is_err()); + assert_eq!(ranges(&s), ["A1:B2", "D1:E2"]); + assert!(s.execute("table.resize", json!({"table":"U", "range":"A1:E2"})).is_err()); + assert_eq!(ranges(&s), ["A1:B2", "D1:E2"]); +} + +#[test] +fn resize_next_to_another_table_still_works() { + let mut s = two_tables(); + s.execute("table.resize", json!({"table":"T", "range":"A1:C3"})).unwrap(); + assert_eq!(ranges(&s), ["A1:C3", "D1:E2"]); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5c74b87e8..04f2127b8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -616,6 +616,24 @@ impl Dialog { defaults, ) } + "tableResize" => { + let (table, range) = app + .session + .active() + .and_then(|d| { + let sh = d.wb.active()?; + let t = sh.table_at(d.selection.active)?; + Some((t.name.clone(), t.range.a1())) + }) + .unwrap_or_default(); + Dialog::form( + "tableResize", + "Resize Table", + "table.resize", + vec![Text { key: "range", label: "New range for the table:" }], + json!({"table": table, "range": range}), + ) + } "dataValidation" => Dialog::form( "dataValidation", "Data Validation", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8e60b170c..bf115de62 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1429,17 +1429,8 @@ pub fn clean_error(e: &str) -> String { e.to_string() } -#[allow(clippy::disallowed_methods)] // the clock is read only off wasm pub fn now_ms() -> f64 { - #[cfg(not(target_arch = "wasm32"))] - { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) - } - #[cfg(target_arch = "wasm32")] - { - 0.0 - } + gridcraft_engine::core::date::unix_now_ms().unwrap_or(0.0) } pub fn cell(r: u32, c: u32) -> CellRef { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 69f11b62a..6e00c5993 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2344,10 +2344,10 @@ fn automate(app: &mut SheetApp, ui: &mut Ui) { } fn table_design(app: &mut SheetApp, ui: &mut Ui) { - let Some((tname, header, totals, banded, banded_c, first, last, filter)) = app.session.active().and_then(|d| { + let Some((tname, header, totals, banded, banded_c, first, last, filter, style)) = app.session.active().and_then(|d| { let sh = d.wb.active()?; let t = sh.table_at(d.selection.active)?; - Some((t.name.clone(), t.header_row, t.totals_row, t.banded_rows, t.banded_cols, t.first_col, t.last_col, t.filter_button)) + Some((t.name.clone(), t.header_row, t.totals_row, t.banded_rows, t.banded_cols, t.first_col, t.last_col, t.filter_button, t.style.clone())) }) else { return; }; @@ -2360,6 +2360,11 @@ fn table_design(app: &mut SheetApp, ui: &mut Ui) { act(app, "table.rename", json!({"table": tname, "name": name})); } }); + let __k = big_button(ui, Icon::Table, "Resize\nTable", "Resize Table", false); + control_keytip(app, ui, &__k, "dialog:tableResize"); + if __k.clicked() { + app.open_dialog("tableResize", json!({})); + } sep(ui); let __k = big_button(ui, Icon::Duplicates, "Remove\nDuplicates", "Remove Duplicates", false); control_keytip(app, ui, &__k, "data.removeDuplicates"); @@ -2401,9 +2406,46 @@ fn table_design(app: &mut SheetApp, ui: &mut Ui) { } }); sep(ui); - let st = big_button(ui, Icon::FormatTable, "Table\nStyles", "Table Styles", true); - anchor_keytip(app, ui, &st, &["table.style"]); - egui::Popup::menu(&st).show(|ui| table_gallery(app, ui, "table.style")); + table_style_strip(app, ui, &tname, &style); +} + +/// The first page of Excel's Table Styles gallery, drawn in the ribbon. The More arrow opens the +/// full gallery (Light, Medium, Dark) as a popup. +const RIBBON_TABLE_STYLES: [&str; 7] = + ["TableStyleLight1", "TableStyleLight2", "TableStyleLight3", "TableStyleLight4", "TableStyleLight5", "TableStyleLight6", "TableStyleLight7"]; + +fn table_style_strip(app: &mut SheetApp, ui: &mut Ui, tname: &str, current: &str) { + let t = Tokens::get(ui.ctx()); + ui.add_space(12.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + // Borrow the workbook only while painting; the session must be free for `act` afterwards. + let clicked = { + let Some(d) = app.session.active() else { return }; + let wb = &d.wb; + let mut clicked = None; + for name in RIBBON_TABLE_STYLES { + let (r, resp) = ui.allocate_exact_size(vec2(52.0, 40.0), Sense::click()); + paint_table_swatch(ui, r, wb, name); + if name == current { + ui.painter().rect_stroke(r.expand(2.5), 2.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); + } + if resp.hovered() { + ui.painter().rect_stroke(r.expand(1.0), 2.0, Stroke::new(2.0, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside); + } + if resp.on_hover_text(name).clicked() { + clicked = Some(name); + } + } + clicked + }; + if let Some(name) = clicked { + act(app, "table.style", json!({"table": tname, "style": name})); + } + let more = icon_button(ui, Icon::Chevron, t.text_dim, "More Table Styles", vec2(18.0, 40.0)); + anchor_keytip(app, ui, &more, &["table.style"]); + egui::Popup::menu(&more).show(|ui| table_gallery(app, ui, "table.style")); + }); } fn chart_design(app: &mut SheetApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 6c6545638..bc6fcc1d8 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -89,7 +89,8 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { .map(|(i, s)| (i, s.name.clone(), s.tab_color.and_then(|c| c.resolve(&d.wb.theme)), i == d.wb.active_sheet)) .collect(); // Keep worksheet tabs in their own horizontally scrollable viewport. As in Excel, the - // tabs get 60% of the bar and the grid's horizontal scrollbar the rest. + // tabs get up to 60% of the bar and the grid's horizontal scrollbar the rest; a strip + // that needs less shrinks to its tabs, so New sheet (+) sits right after the last one. let tabs_width = (ui.available_width() * 0.6).max(100.0); // Reveal the selected sheet after navigation or layout changes, but // allow browsing other tabs without snapping back on every frame. @@ -114,7 +115,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { egui::ScrollArea::horizontal() .id_salt("worksheet_tabs_horizontal") - .auto_shrink([false, false]) + .auto_shrink([true, false]) .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) .show(ui, |ui| { if wheel_scroll.abs() > 0.0 { diff --git a/crates/ui-egui/tests/sheet_tabs.rs b/crates/ui-egui/tests/sheet_tabs.rs index 34d24c854..c3e5d13fb 100644 --- a/crates/ui-egui/tests/sheet_tabs.rs +++ b/crates/ui-egui/tests/sheet_tabs.rs @@ -7,10 +7,14 @@ use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; fn harness(width: f32, active: usize) -> Harness { + harness_with(width, 30, active) +} + +fn harness_with(width: f32, sheets: usize, active: usize) -> Harness { let mut s = Session::new(); s.new_workbook(); s.execute("sheet.rename", json!({"sheet":0,"name":"Tab00"})).unwrap(); - for i in 1..30 { + for i in 1..sheets { s.execute("home.insertSheet", json!({"name":format!("Tab{i:02}")})).unwrap(); } s.execute("sheet.activate", json!({"sheet":active})).unwrap(); @@ -194,3 +198,43 @@ fn right_clicking_a_tab_activates_its_sheet_so_the_menu_acts_on_it() { .collect(); assert!(texts.iter().any(|t| t == "Unprotect Sheet…"), "menu should offer Unprotect Sheet…, painted: {texts:?}"); } + +/// The New sheet button's left edge. The scrollbar track starts 8 px after the 26 px button. +fn new_sheet_left(h: &Harness) -> f32 { + h.state().grid.hscroll.expect("the tab bar has a horizontal scrollbar").left() - 8.0 - 26.0 +} + +/// The painted background of the active tab: the smallest fill enclosing its name (the bar's own fill encloses it too). +fn active_tab_rect(h: &Harness, name: &str) -> Rect { + let (text, _) = tab(h, name).unwrap(); + h.output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::Rect(r) if r.rect.contains_rect(text) => Some(r.rect), + _ => None, + }) + .min_by(|a, b| a.area().total_cmp(&b.area())) + .unwrap_or_else(|| panic!("{name} has no painted tab background")) +} + +#[test] +fn new_sheet_button_follows_the_last_tab() { + for width in [700.0, 1200.0] { + let h = harness_with(width, 2, 1); + let last = active_tab_rect(&h, "Tab01"); + let plus = new_sheet_left(&h); + assert!(plus >= last.right() && plus - last.right() <= 12.0, "{width}: + at {plus}, last tab ends at {}", last.right()); + } +} + +#[test] +fn overflowing_tabs_keep_their_share_and_the_new_sheet_button_after_it() { + for width in [700.0, 1200.0] { + let h = harness(width, 0); + let (_, viewport) = tab(&h, "Tab00").unwrap(); + assert!(viewport.width() <= width * 0.6, "{width}: the tab strip grew past its share: {viewport:?}"); + let plus = new_sheet_left(&h); + assert!(plus >= viewport.right() && plus - viewport.right() <= 12.0, "{width}: + at {plus}, strip ends at {}", viewport.right()); + } +} diff --git a/crates/ui-egui/tests/ui_clock.rs b/crates/ui-egui/tests/ui_clock.rs new file mode 100644 index 000000000..6eb0dcc00 --- /dev/null +++ b/crates/ui-egui/tests/ui_clock.rs @@ -0,0 +1,10 @@ +use gridcraft_engine::core::date::unix_now_ms; + +#[test] +fn ui_clock_returns_current_unix_milliseconds() { + let before = unix_now_ms().unwrap(); + let now = gridcraft_ui_egui::now_ms(); + let after = unix_now_ms().unwrap(); + assert!(now.is_finite() && now > 0.0); + assert!((before.min(after)..=before.max(after)).contains(&now)); +} diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 1338fce6d..6f486e2b0 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -76,10 +76,34 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( let nstyles = wb.styles.len() as u32; let sid = |s: gridcraft_model::StyleId| if s.0 < nstyles { s.0 } else { 0 }; + // A table's dropdown filter is kept in `sheet.autofilter`, not in `Table::filter` (the + // engine's only live filter state, which the reader also fills). Where the two coincide, + // fold the sheet filter's criteria into the table so they reach the file: the stored + // `colId` offsets are relative to the range's first column, and both ranges start at the + // same column, so they carry over verbatim. A filter that only partially covers a table + // has nowhere valid to go, so it keeps being suppressed below. + let folded: Vec = match &sheet.autofilter { + Some(af) if !af.criteria.is_empty() => sheet + .tables + .iter() + .cloned() + .map(|mut t| { + if t.header_row + && t.filter_button + && t.filter.is_empty() + && t.range.contains_range(&af.range) + && t.range.start.col == af.range.start.col + { + t.filter = af.criteria.clone(); + } + t + }) + .collect(), + _ => sheet.tables.clone(), + }; // Table header cells must hold the column names. let mut overrides: BTreeMap = BTreeMap::new(); - let tables: Vec<(&Table, Vec)> = sheet - .tables + let tables: Vec<(&Table, Vec)> = folded .iter() .filter(|t| t.range.height() > t.header_row as u32 + t.totals_row as u32 || (t.header_row && t.range.height() > t.totals_row as u32)) .map(|t| (t, table_columns(t))) diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 85a0b175d..6860aab6d 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1283,3 +1283,87 @@ fn japanese_paper_sizes_round_trip() { let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); assert_eq!(back.sheet(0).unwrap().print.paper, "B5"); } + +/// A table's dropdown filter lives in `sheet.autofilter` (the engine's only live filter state), +/// not in `Table::filter`. Writing used to suppress the sheet `` because a table +/// intersects it and write the table part from `Table::filter`, which was empty: the criteria +/// were lost on every save, leaving the rows hidden with no filter to clear on reopen. +#[test] +fn a_table_filter_criteria_survive_a_round_trip() { + let mut wb = Workbook::new(); + let s = wb.sheet_mut(0).unwrap(); + for (r, v) in [(0u32, "Region"), (1, "East"), (2, "West"), (3, "East")] { + s.set_value(at(&format!("A{}", r + 1)), Value::text(v)); + } + s.tables.push(Table { + id: 1, + name: "Sales".into(), + range: rr("A1:A4"), + header_row: true, + totals_row: false, + columns: vec![TableColumn { name: "Region".into(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None }], + style: "TableStyleMedium2".into(), + banded_rows: true, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: true, + filter: vec![], + table_type: None, + connection_id: None, + }); + // The dropdown of a table's header cell installs the sheet AutoFilter over the table's + // range, and `data.filterBy` stores its criteria there. + s.autofilter = + Some(AutoFilter { range: rr("A1:A4"), criteria: vec![(0, FilterCriterion::Values { values: vec!["East".into()], blanks: false })] }); + s.rows.entry(1).or_default().hidden = true; + + let (back, rep) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert!(rep.warnings.is_empty(), "{:?}", rep.warnings); + let b = back.sheet(0).unwrap(); + let t = b.tables.first().expect("the table survived"); + let (_, crit) = t.filter.first().expect("the table's filter criteria were kept on save"); + assert!(matches!(crit, FilterCriterion::Values { values, .. } if values == &["East".to_string()]), "the criteria were dropped: {:?}", t.filter); + assert!(b.is_row_hidden(1), "the row the filter hid is still hidden"); + assert!(b.autofilter.is_none(), "a table's filter belongs to the table part, not the sheet"); +} + +/// Stored `colId` offsets are relative to the filter range's first column, so criteria of a filter +/// that starts right of the table's first column must not be folded into the table part, where +/// they would apply to the wrong column. +#[test] +fn a_partial_sheet_filter_is_not_folded_into_a_table() { + let mut wb = Workbook::new(); + let s = wb.sheet_mut(0).unwrap(); + for (a, v) in [("A1", "Id"), ("B1", "Region"), ("A2", "1"), ("B2", "East"), ("A3", "2"), ("B3", "West")] { + s.set_value(at(a), Value::text(v)); + } + let column = + |name: &str| TableColumn { name: name.into(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None }; + s.tables.push(Table { + id: 1, + name: "Sales".into(), + range: rr("A1:B3"), + header_row: true, + totals_row: false, + columns: vec![column("Id"), column("Region")], + style: "TableStyleMedium2".into(), + banded_rows: true, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: true, + filter: vec![], + table_type: None, + connection_id: None, + }); + s.autofilter = + Some(AutoFilter { range: rr("B1:B3"), criteria: vec![(0, FilterCriterion::Values { values: vec!["East".into()], blanks: false })] }); + + let (back, rep) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert!(rep.warnings.is_empty(), "{:?}", rep.warnings); + let b = back.sheet(0).unwrap(); + let t = b.tables.first().expect("the table survived"); + assert!(t.filter.is_empty(), "the criteria landed on the wrong table column: {:?}", t.filter); + assert!(b.autofilter.is_none(), "a sheet filter inside a table would be invalid"); +} diff --git a/docs/gaps.md b/docs/gaps.md index d6e520602..66d4147d6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -85,6 +85,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Estimate:** 20–30 h for beta (25–40 h full). ### 9. Keyboard-first users: key tips, shortcuts, context menus (U) +- Next/previous sheet navigation now skips Hidden and VeryHidden worksheets without activating a + hidden sheet at the workbook boundary. Other keyboard gaps below remain. - **Evidence:** #33, #49 (key tips: initial Home-tab support landed in #43 on 10-10, the rest of the ribbon not yet), #110, #28 (selection), 59 command shortcuts vs Excel's 200+. - **Estimate:** 12–20 h. [ui-parity.md](ui-parity.md) @@ -172,6 +174,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- |---|---|---| | 2026-10-20 | minor | §11: the Japanese catalog covers the dialogs, messages and menus — 943 keys (~43%), matching the pt-BR coverage; a test keeps the two catalogs in step | | 2026-10-13 | trivial | §4: multi-level category axes landed | +| 2026-10-12 | minor | §9: next/previous sheet commands retain the current visible sheet and selection when there is no visible destination | | 2026-10-12 | minor | rows auto-fit oversized fonts on open and whole-row/column formatting (#833) | | 2026-10-12 | minor | Page Layout view: dragging margins on the rulers (#792) removed from the §14 remainder | | 2026-10-12 | minor | §13: Forecast Sheet Forecast Start (hindcast) landed (#785) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a083df63a..1b7492966 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -25,6 +25,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | +| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); New sheet (+) right after the last tab, the strip capped at 60% of the bar (#943); manual wheel/trackpad browsing preserved; next/previous skips Hidden and VeryHidden sheets and retains the current sheet and selection when no visible destination exists; rename focus bug on macOS (#145) | | Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial | | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing | @@ -188,12 +189,16 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-10-16 | minor | Ctrl+Shift+A (Control+Shift+A on a Mac) after a function name, or after its `(`, inserts the argument names and the closing parenthesis, as in Excel; one Undo in the editor takes them back | | 2026-10-16 | minor | Advanced Filter ribbon dialog, optional criteria for unique-only extraction (#1622); safe copy-to output (#1031) | | 2026-10-16 | minor | Remove Duplicates shows an editable Range field initialized from the selection and passes it to the engine on OK (#1388). Live range-picking outside the dialog remains unsupported | +| 2026-10-16 | minor | Web printing registers one-shot `afterprint` cleanup before opening the dialog; missing content windows and setup, listener or print failures also release the hidden iframe and PDF blob URL (#1054) | +| 2026-10-16 | minor | The shared UI clock delegates to the core Unix-millisecond clock, which reads Date.now() on wasm, instead of returning zero there (#1026). Native clock contract tested; browser timing remains unverified | | 2026-10-15 | minor | Number-format named colors use their RGB values; indexed `[Color1]`–`[Color56]` resolve through the shared legacy palette in grid and PDF (#1452) | | 2026-10-14 | minor | Spill-child formula bar uses the anchor formula with disabled theme text and blocks formula-bar edit entry; selection and recalculation restore ordinary behavior | | 2026-10-13 | minor | Dragging a sheet tab with Ctrl held (Cmd or Option on macOS) drops a copy of the sheet where the tab is released, with the copy cursor (#971) | | 2026-10-13 | trivial | More Colors… and Recent Colors (#928) | | 2026-10-13 | minor | Grid drags keep their own cursor over selection borders, fill handles and outside the grid; hover resumes on release (#936) | | 2026-10-13 | minor | AutoFill month patterns retain the workbook's date system, preventing shifted dates in 1904 workbooks when filling forward or backward | +| 2026-10-13 | minor | Sheet tab bar: the tab strip shrinks to its tabs, up to 60% of the bar, so New sheet (+) sits right after the last tab and the horizontal scrollbar takes the rest (part of #943) | +| 2026-10-12 | minor | Next/previous sheet navigation no longer activates hidden worksheets at either workbook boundary; command tests cover Hidden, VeryHidden and selection retention | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 2026-10-13 | minor | Formula-entry Command/Ctrl-click adds arguments and Shift-click extends references; ordinary replacement and dragging retain their behavior. Readiness estimates unchanged. |