diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 43c3aa4b5..80f8d0205 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -179,6 +179,15 @@ pub fn specs() -> Vec { has_doc, change_source ), + cmd!( + "pivot.showDetails", + "Show Details", + [], + None, + "{cell?: \"B5\" (a value, subtotal or grand total cell of a PivotTable on the active sheet; default: the active cell)} puts the source records behind that value on a new sheet before the current one → {sheet, records}", + has_doc, + show_details + ), cmd!( "pivot.delete", "Delete PivotTable", @@ -654,16 +663,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result { let (si, anchor) = match target { Some(t) => t, None => { - let at = cx.wb.active_sheet.min(cx.wb.sheets.len()); - let nm = cx.wb.next_sheet_name(); - cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm))); - for n in cx.wb.names.iter_mut() { - if let Some(sc) = n.scope.as_mut() - && *sc >= at - { - *sc += 1; - } - } + let at = insert_sheet_before_active(cx); new_sheet_at = Some(at); (at, CellRef::new(2, 0)) } @@ -726,6 +726,104 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result { Ok(res) } +/// Inserts a new, empty sheet before the active one (Excel's place for a new PivotTable or a +/// Show Details sheet); returns its index. +fn insert_sheet_before_active(cx: &mut Ctx) -> usize { + let at = cx.wb.active_sheet.min(cx.wb.sheets.len()); + let nm = cx.wb.next_sheet_name(); + cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm))); + for n in cx.wb.names.iter_mut() { + if let Some(sc) = n.scope.as_mut() + && *sc >= at + { + *sc += 1; + } + } + at +} + +/// Show Details (double-click a value cell): the source records behind it, as values with their +/// column's number format, under the source headers on a new sheet. +fn show_details(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); + let active = d.wb.active_sheet; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let pt = sh + .pivots + .iter() + .find(|pt| pt.last_range.is_some_and(|r| r.contains(at))) + .ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), "select a value cell in a PivotTable".into()))?; + let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); + let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)).map_err(other)?; + let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), "select a value cell in a PivotTable".into()))?; + if d.wb.protected_structure { + return Err(other("The workbook is protected and cannot be changed.")); + } + let count = records.len(); + let mut new_sheet_at: Option = None; + let res = edit(s, |cx| { + let si = insert_sheet_before_active(cx); + new_sheet_at = Some(si); + let base = cx.wb.styles.get(StyleId::default()).clone(); + let fmts: Vec = src + .formats + .iter() + .map(|f| { + let mut st = base.clone(); + st.num_fmt = NumFmt::new(f); + cx.wb.styles.intern(st) + }) + .collect(); + // Columns wide enough for their text, as the PivotTable report's are (dates would show + // as ####); the first rows are enough to judge a column. + let font = base.font.clone(); + let widths: Vec = src + .headers + .iter() + .enumerate() + .map(|(c, h)| { + let fmt = src.formats.get(c).map(String::as_str).unwrap_or("General"); + let texts = records.iter().take(1_000).filter_map(|i| src.rows.get(*i).and_then(|r| r.get(c))); + let widest = texts + .map(|v| crate::cmd::format::approx_text_width(&crate::display::format(v, fmt, &cx.wb).text, font.size, false)) + .fold(crate::cmd::format::approx_text_width(h, font.size, false), f32::max); + (widest + 14.0).min(320.0) + }) + .collect(); + let sh = cx.sheet_mut(si)?; + for (c, w) in widths.into_iter().enumerate() { + if w > sh.col_width(c as u32) { + sh.cols.entry(c as u32).or_default().size = Some(w); + } + } + for (c, h) in src.headers.iter().enumerate() { + sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() }); + } + for (r, i) in records.iter().enumerate() { + let Some(rec) = src.rows.get(*i) else { continue }; + for (c, v) in rec.iter().enumerate() { + let style = fmts.get(c).copied().unwrap_or_default(); + if v.is_empty() && style == StyleId::default() { + continue; + } + sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style }); + } + } + let name = sh.name.clone(); + cx.wb.active_sheet = si; + *cx.sel = crate::Selection::at(CellRef::new(0, 0)); + cx.structural = true; + Ok(json!({"sheet": name, "records": count})) + })?; + if let Some(at) = new_sheet_at + && let Ok(d) = s.doc_mut() + { + d.sheet_selections.insert(at.min(d.sheet_selections.len()), crate::Selection::default()); + } + Ok(res) +} + fn summarize_table(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let name = match str_param(p, "table") { @@ -2236,6 +2334,7 @@ mod tests { "data.refreshAll", "pivot.changeSource", "pivot.delete", + "pivot.showDetails", ]; let hostile = [ json!({}), @@ -2363,4 +2462,92 @@ mod tests { assert_eq!(l[0]["anchor"], "A3"); s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap(); } + + /// The active sheet's first five columns as shown, down to the first blank row. + fn sheet_rows(s: &mut Session) -> Vec> { + let mut out = Vec::new(); + for r in 0..30u32 { + let row: Vec = (0..5) + .map(|c| s.execute("cell.get", json!({"cell": CellRef::new(r, c).a1()})).unwrap()["text"].as_str().unwrap_or("").to_string()) + .collect(); + if row.iter().all(String::is_empty) { + break; + } + out.push(row); + } + out + } + + #[test] + fn show_details_lists_a_value_cells_records_on_a_new_sheet() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + // B5 is East × Apples (100 + 60); the active cell is the default. + s.execute("selection.set", json!({"range": "B5"})).unwrap(); + let r = s.execute("pivot.showDetails", json!({})).unwrap(); + assert_eq!(r["records"], 2); + let d = s.doc().unwrap(); + assert_eq!(d.wb.sheets.len(), 3); + assert_eq!(d.wb.active_sheet, 0, "the new sheet goes before the PivotTable's"); + assert_eq!(d.wb.active().unwrap().name, "Sheet3"); + assert_eq!( + sheet_rows(&mut s), + vec![ + vec!["Region", "Product", "Date", "Units", "Sales"], + vec!["East", "Apples", "1/15/2026", "10", "100"], + vec!["East", "Apples", "9/9/2026", "6", "60"], + ] + ); + // The dates fit their column instead of showing ####. + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert!(sh.col_width(2) > sh.col_width(5), "{} vs {}", sh.col_width(2), sh.col_width(5)); + // One undo step takes the sheet away. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + assert_eq!(val(&s, "B5"), Value::Number(160.0)); + } + + #[test] + fn show_details_on_a_total_takes_every_record_it_adds_up() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + let records = |s: &mut Session, cell: &str| { + let n = s.execute("pivot.showDetails", json!({"cell": cell})).unwrap()["records"].as_u64().unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + n + }; + assert_eq!(records(&mut s, "E5"), 5, "East's row total"); + assert_eq!(records(&mut s, "C8"), 4, "Pears' column total"); + assert_eq!(records(&mut s, "E8"), 12, "the grand total"); + // A subtotal at the top of its group, in a two-level row axis. + s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap(); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(records(&mut s, "B4"), 5); + assert_eq!(text(&s, "A6"), "Pears"); + assert_eq!(records(&mut s, "B6"), 2); + } + + #[test] + fn show_details_leaves_out_the_records_the_report_filters_out() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap(); + s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap(); + s.execute("pivot.filter", json!({"field": "Product", "selected": ["Apples"]})).unwrap(); + let gt = find_row(&s, "Grand Total").unwrap(); + let r = s.execute("pivot.showDetails", json!({"cell": CellRef::new(gt, 1).a1()})).unwrap(); + assert_eq!(r["records"], 2); + let rows = sheet_rows(&mut s); + assert_eq!(rows.len(), 3); + assert!(rows.iter().skip(1).all(|row| row[0] == "East" && row[1] == "Apples"), "{rows:?}"); + } + + #[test] + fn show_details_needs_a_value_cell() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + for cell in ["A3", "B3", "A4", "B4", "A5", "A8", "H20"] { + assert!(s.execute("pivot.showDetails", json!({"cell": cell})).is_err(), "{cell}"); + } + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + } } diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 08ec15dfb..43a77c11a 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -803,6 +803,30 @@ fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) - /// Computes a PivotTable report from the workbook. pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result { let src = read_source(wb, &pt.source, pivot_sheet)?; + compute_from(wb, pt, &src, None) +} + +/// Show Details: the source list and the indices of its records behind the value cell at +/// (`row`, `col`) of the report grid (relative to its top-left), in source order. `None` when +/// that cell holds no value (a label, a header, a blank). +pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result)>, String> { + let src = read_source(wb, &pt.source, pivot_sheet)?; + let mut probe = Probe { at: (row as usize, col as usize), hit: None, paths: Vec::new() }; + compute_from(wb, pt, &src, Some(&mut probe))?; + let Some((rp, cp)) = probe.hit else { return Ok(None) }; + let records = probe.paths.into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect(); + Ok(Some((src, records))) +} + +/// What [`detail_records`] asks of a report computation: the row and column item paths of the +/// value cell at `at`, and the paths of every record the filters let through. +struct Probe { + at: (usize, usize), + hit: Option<(Vec, Vec)>, + paths: Vec<(usize, Vec, Vec)>, +} + +fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result { let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into()); let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); @@ -914,6 +938,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result Result raw, }, }; + if let Some(pr) = probe.as_deref_mut() + && pr.at == (r, l + j) + { + pr.hit = Some((rp.to_vec(), cp.to_vec())); + } g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten()); } } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 40ef2c8e9..1b72576e5 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2386,6 +2386,13 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & && !mods.shift { app.grid.last_click = None; + // A PivotTable's value cell shows its source records on a new sheet (Excel's Show + // Details); its labels and headers still edit. + if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c))) + && app.run("pivot.showDetails", json!({"cell": c.a1()})).is_ok() + { + return; + } // Following a hyperlink requires a single click in Excel; double-click edits. app.begin_edit(None, false); return; diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index b89253bb9..9b04d3869 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -59,6 +59,7 @@ mod page_layout_view; mod page_setup_margins; mod paste_special_dialog; mod pivot_pane; +mod pivot_show_details; mod popup_dismissal; mod protect_password; mod right_to_left; diff --git a/crates/ui-egui/tests/it/pivot_show_details.rs b/crates/ui-egui/tests/it/pivot_show_details.rs new file mode 100644 index 000000000..e78ca53ec --- /dev/null +++ b/crates/ui-egui/tests/it/pivot_show_details.rs @@ -0,0 +1,76 @@ +//! Double-clicking a PivotTable's value cell shows its source records on a new sheet (Excel's +//! Show Details); a label of the report still opens the cell for editing. + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 100], ["West", 80], ["East", 30]]})).unwrap(); + // Report at D3: D4 East, E4 its Sum of Sales. + session + .execute("insert.pivotTable", json!({"source": "Sheet1!A1:B4", "destination": "Sheet1!D3", "rows": ["Region"], "values": ["Sales"]})) + .unwrap(); + session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn center(h: &egui_kittest::Harness<'_, SheetApp>, cell: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center() +} + +fn double_click(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2) { + for _ in 0..2 { + h.input_mut().events.extend([ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(2); + } +} + +#[test] +fn double_click_on_a_value_cell_shows_its_records_on_a_new_sheet() { + let mut h = harness(); + let pos = center(&h, "E4"); + double_click(&mut h, pos); + + let app = h.state(); + assert!(app.editor.is_none(), "the value cell must not open for editing"); + let wb = &app.session.active().unwrap().wb; + assert_eq!(wb.sheets.len(), 2); + let sheet = wb.active().unwrap(); + assert_eq!(sheet.name, "Sheet2"); + let text = |a: &str| sheet.value(CellRef::parse(a).unwrap()).display(); + assert_eq!( + [text("A1"), text("B1"), text("A2"), text("B2"), text("A3"), text("B3"), text("A4")], + ["Region", "Sales", "East", "100", "East", "30", ""] + ); +} + +#[test] +fn double_click_on_a_row_label_still_edits() { + let mut h = harness(); + let pos = center(&h, "D4"); + double_click(&mut h, pos); + + let app = h.state(); + assert!(app.editor.is_some()); + assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1); +} diff --git a/docs/gaps.md b/docs/gaps.md index 7e2e40dc0..b07727991 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-20 · **Change:** minor (the Japanese catalog covers the dialogs, messages and menus) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (§8: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -84,7 +84,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F) - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, `insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields - and values on rows done). + and values on rows done). Show Details (double-click a value, subtotal or grand total cell) lists + its source records on a new sheet; the "Enable show details" option isn't modelled yet. - **Impact:** pivots are the analyst's main tool. - **Estimate:** 20–30 h for beta (25–40 h full). @@ -178,6 +179,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | §8: PivotTable Show Details: double-clicking a value cell lists its source records on a new sheet (#2364) | | 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-17 | minor | §6: the five reports it tracked are closed and their PRs merged (#57, #133, #139, #140, #134; PRs #148, #155, #156, #157); the entry stays open for §15's oracle suite | | 2026-10-15 | minor | §13: Solver prepares reports before committing the solution, model and report sheets together; report refusal preserves the workbook and Undo/Redo (#1050) |