diff --git a/crates/engine/src/cmd/whatif.rs b/crates/engine/src/cmd/whatif.rs index 09c716cab..318d0cb72 100644 --- a/crates/engine/src/cmd/whatif.rs +++ b/crates/engine/src/cmd/whatif.rs @@ -70,7 +70,7 @@ pub fn specs() -> Vec { "Advanced…", ["Data", "Sort & Filter"], None, - "{range? (list with headers; default current region), criteria: \"H1:I3\" (header row of field names; rows OR'ed, columns AND'ed; \">100\", \"=abc\", \"a*\", \"<>\"; blank-headed formula cells are computed criteria for the first data row), copyTo?: \"K1\" (else filter in place by hiding rows), unique?: bool}", + "{range? (list with headers; default current region), criteria?: \"H1:I3\" (omitted/blank matches all rows; header row of field names; rows OR'ed, columns AND'ed; \">100\", \"=abc\", \"a*\", \"<>\"; blank-headed formula cells are computed criteria for the first data row), copyTo?: \"K1\" (else filter in place by hiding rows), unique?: bool}", has_doc, advanced_filter ), @@ -795,41 +795,45 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result { (d.wb.active_sheet, crate::selection::current_region(sh, d.selection.active)) } }; - let (csi, crit) = range_arg(s, p, "criteria", CMD)?; + let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?; if list.height() < 1 || list.count() > 50_000_000 { return Err(bad(CMD, "the list range is too large")); } - if crit.width() > 1024 || crit.height() > 10_000 { + if let Some((_, crit)) = criteria + && (crit.width() > 1024 || crit.height() > 10_000) + { return Err(bad(CMD, "the criteria range is too large")); } let wb = &d.wb; let sh = wb.sheet(si).ok_or(EngineError::NoDocument)?; - let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?; let headers: Vec = (list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(list.start.row, c)).trim().to_lowercase()).collect(); let first_data = list.start.row + 1; // Criteria rows → conditions. let mut rows: Vec> = Vec::new(); - for cr in crit.start.row + 1..=crit.end.row { - let mut conds = Vec::new(); - for cc in crit.start.col..=crit.end.col { - let at = CellRef::new(cr, cc); - let cell = csh.cell(at); - let v = csh.value(at); - let formula = cell.and_then(|c| c.formula.as_ref()); - if v.is_empty() && formula.is_none() { - continue; - } - let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase(); - match headers.iter().position(|h| !head.is_empty() && *h == head) { - Some(off) => conds.push(Cond::Compare(off as u32, v)), - None => match formula.and_then(|f| f.expr()) { - Some(e) => conds.push(Cond::Computed(e, at)), - None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))), - }, + if let Some((csi, crit)) = criteria { + let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?; + for cr in crit.start.row + 1..=crit.end.row { + let mut conds = Vec::new(); + for cc in crit.start.col..=crit.end.col { + let at = CellRef::new(cr, cc); + let cell = csh.cell(at); + let v = csh.value(at); + let formula = cell.and_then(|c| c.formula.as_ref()); + if v.is_empty() && formula.is_none() { + continue; + } + let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase(); + match headers.iter().position(|h| !head.is_empty() && *h == head) { + Some(off) => conds.push(Cond::Compare(off as u32, v)), + None => match formula.and_then(|f| f.expr()) { + Some(e) => conds.push(Cond::Computed(e, at)), + None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))), + }, + } } + rows.push(conds); } - rows.push(conds); } let matches_row = |row: u32| -> bool { if rows.is_empty() { @@ -845,7 +849,7 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result { } Cond::Computed(e, at) => { let shifted = gridcraft_formula::adjust::shift_relative(e.clone(), row as i64 - first_data as i64, 0); - let v = gridcraft_calc::recalc::evaluate_expr(wb, csi, *at, &shifted); + let v = gridcraft_calc::recalc::evaluate_expr(wb, criteria.map(|(si, _)| si).unwrap_or(si), *at, &shifted); matches!(v.scalar().to_bool(), Ok(true)) } }) @@ -867,28 +871,30 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result { if let Some(t) = str_param(p, "copyTo") { let (dsi, dest) = parse_range(wb, t, CMD)?; let dest = dest.start; - let extract = RangeRef::new(dest, dest.offset_clamped(list.height() as i64 - 1, list.width() as i64 - 1)); - if dsi == si && extract.intersects(&list) { - return Err(EngineError::Other("The extract range can't overlap the list range.".into())); - } let mut rows_out: Vec = vec![list.start.row]; rows_out.extend(keep.iter().filter(|(_, k)| *k).map(|(r, _)| *r)); + let end = + dest.offset(rows_out.len() as i64 - 1, list.width() as i64 - 1).ok_or_else(|| bad(CMD, "the extract range exceeds the worksheet"))?; + let out_range = RangeRef::new(dest, end); + if dsi == si && out_range.intersects(&list) { + return Err(EngineError::Other("The extract range can't overlap the list range.".into())); + } let cells: Vec<(CellRef, Cell)> = rows_out .iter() .enumerate() .flat_map(|(i, row)| { - (list.start.col..=list.end.col).filter_map(move |c| { + (list.start.col..=list.end.col).map(move |c| { let src = CellRef::new(*row, c); - let to = dest.offset(i as i64, (c - list.start.col) as i64)?; + // The full output footprint was checked above. + let to = CellRef::new(dest.row + i as u32, dest.col + c - list.start.col); let style = sh.style_id(src); - Some((to, Cell { value: sh.value(src), formula: None, style })) + (to, Cell { value: sh.value(src), formula: None, style }) }) }) .collect(); - let out_range = RangeRef::new(dest, dest.offset_clamped(rows_out.len() as i64 - 1, list.width() as i64 - 1)); edit(s, |cx| { let shm = cx.sheet_mut(dsi)?; - let old: Vec = shm.cells.iter_range(extract).map(|(c, _)| c).collect(); + let old: Vec = shm.cells.iter_range(out_range).map(|(c, _)| c).collect(); for c in &old { shm.remove_cell(*c); } @@ -1317,6 +1323,52 @@ mod tests { assert!(s.execute("data.advancedFilter", json!({"range": "A1:B6", "criteria": "H1:I3", "copyTo": "B2"})).is_err()); } + #[test] + fn advanced_filter_copy_preserves_cells_beyond_result() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap(); + s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["B"]]})).unwrap(); + s.execute("cell.set", json!({"cell": "K4", "input": "Keep me"})).unwrap(); + let r = s.execute("data.advancedFilter", json!({"range": "A1:B4", "criteria": "H1:H2", "copyTo": "K1"})).unwrap(); + assert_eq!(r["range"], "K1:L2"); + assert_eq!(v(&s, "K2"), Value::from("B")); + assert_eq!(v(&s, "K4"), Value::from("Keep me")); + } + + #[test] + fn advanced_filter_copy_rejects_overflow_without_mutation() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1]]})).unwrap(); + s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap(); + for dest in ["XFD1", "K1048576"] { + s.execute("cell.set", json!({"cell": dest, "input": "Keep me"})).unwrap(); + assert!(s.execute("data.advancedFilter", json!({"range": "A1:B2", "criteria": "H1:H2", "copyTo": dest})).is_err()); + assert_eq!(v(&s, dest), Value::from("Keep me")); + } + } + + #[test] + fn advanced_filter_copy_allows_output_above_source_when_only_old_clear_area_overlaps() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "C4", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap(); + s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap(); + let r = s.execute("data.advancedFilter", json!({"range": "C4:D7", "criteria": "H1:H2", "copyTo": "B1"})).unwrap(); + assert_eq!(r["range"], "B1:C2"); + assert_eq!(v(&s, "C4"), Value::from("Name")); + assert_eq!(v(&s, "C2"), Value::Number(1.0)); + } + + #[test] + fn advanced_filter_unique_without_criteria() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"], ["A"], ["B"]]})).unwrap(); + let r = s.execute("data.advancedFilter", json!({"range": "A1:A4", "copyTo": "C1", "unique": true})).unwrap(); + assert_eq!(r["matched"], 2); + assert_eq!(r["range"], "C1:C3"); + assert_eq!(v(&s, "C2"), Value::from("A")); + assert_eq!(v(&s, "C3"), Value::from("B")); + } + #[test] fn criteria_semantics() { use super::criterion_matches as m; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d241e540d..85221f680 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -456,6 +456,26 @@ impl Dialog { vec![Check { key: "header", label: "My data has headers" }, Text { key: "columnsText", label: "Columns (blank = all, e.g. A,C):" }], json!({"header": true}), ), + "advancedFilter" => { + let list = app + .session + .active() + .and_then(|d| d.wb.active().map(|sh| gridcraft_engine::selection::current_region(sh, d.selection.active).a1())) + .unwrap_or(sel.clone()); + let mut d = Dialog::form( + "advancedFilter", + "Advanced Filter", + "data.advancedFilter", + vec![], + json!({"action": "inPlace", "range": list, "criteria": "", "copyTo": "", "unique": false}), + ); + for key in ["action", "range", "criteria", "copyTo", "unique"] { + if let Some(value) = params.get(key) { + d.set_value(key, value.clone()); + } + } + d + } "goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})), "forecastSheet" => Dialog::form( "forecastSheet", @@ -956,11 +976,44 @@ fn data_validation_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, o ok_cancel(ui, confirm, open); } +fn advanced_filter_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + use Field::*; + let copy = d.values.get("action").and_then(Json::as_str) == Some("copy"); + d.fields = vec![ + Choice { + key: "action", + label: "Action", + options: vec![("inPlace", "Show matching rows in place"), ("copy", "Copy matching rows elsewhere")], + }, + Text { key: "range", label: "List range:" }, + Text { key: "criteria", label: "Criteria range (optional):" }, + ]; + if copy { + d.fields.push(Text { key: "copyTo", label: "Copy to:" }); + } + d.fields.push(Check { key: "unique", label: "Unique rows only" }); + form_fields(ui, d); + // Constrain the button row: the shared right-to-left layout otherwise + // consumes the window's remaining height instead of fitting the buttons. + ui.allocate_ui(vec2(ui.available_width(), ui.spacing().interact_size.y + 8.0), |ui| ok_cancel(ui, confirm, open)); +} + /// Builds the command params from form values for dialogs that need translation. /// The parameters OK sends to the dialog's command: its field values, reshaped per dialog. pub(crate) fn build_params(d: &Dialog) -> Json { let mut v = Json::Object(d.values.clone()); match d.name.as_str() { + "advancedFilter" => { + if let Some(m) = v.as_object_mut() { + m.remove("action"); + if d.values.get("action").and_then(Json::as_str) != Some("copy") { + m.remove("copyTo"); + } + if m.get("criteria").and_then(Json::as_str).is_some_and(|s| s.trim().is_empty()) { + m.remove("criteria"); + } + } + } "dataValidation" => { let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); @@ -1057,12 +1110,17 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { .pivot(egui::Align2::CENTER_CENTER) .default_pos(ctx.content_rect().center() + vec2(0.0, -40.0)) .open(&mut win_open); - let window = if d.name == "insertPicture" { window.default_height(240.0) } else { window }; + let window = match d.name.as_str() { + "insertPicture" => window.default_height(240.0), + "advancedFilter" => window.default_height(0.0), + _ => window, + }; window.show(ctx, |ui| { ui.set_min_width(width - 20.0); match d.name.as_str() { "formatCells" => format_cells(app, ui, &mut d, &mut confirm), "dataValidation" => data_validation_form(ui, &mut d, &mut confirm, &mut open), + "advancedFilter" => advanced_filter_form(ui, &mut d, &mut confirm, &mut open), "insertFunction" => insert_function(app, ui, &mut d, &mut confirm), "find" => find(app, ui, &mut d), "goTo" => go_to(app, ui, &mut d, &mut confirm), diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 79920b5b8..17f0ff0cd 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -291,7 +291,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("APR", Entry::Finish(Action::Pending("data.recentSources"))), // pending; Excel: Recent Sources; gap #12; reserved until the command lands ("APS", Entry::Finish(Action::Command("data.queriesConnections"))), // Excel: Show Queries ("APT", Entry::Finish(Action::Pending("data.fromTable"))), // pending; Excel: From Table (query); gap #12; reserved until the command lands - ("AQ", Entry::Finish(Action::Command("data.advancedFilter"))), // Excel: Advanced Filter + ("AQ", Entry::Finish(Action::Dialog("advancedFilter"))), // Excel: Advanced Filter ("AR", Entry::Continue), // group: group: Queries & Connections ("ARA", Entry::Finish(Action::Command("data.refreshAll"))), // Excel: Refresh All ("ARC", Entry::Finish(Action::Pending("data.cancelRefresh"))), // pending; Excel: Cancel Refresh; gap #12; reserved until the command lands diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 59aeadcfb..efe5c938b 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -1965,8 +1965,8 @@ }, { "seq": "AQ", - "kind": "command", - "target": "data.advancedFilter", + "kind": "dialog", + "target": "advancedFilter", "excel": "Advanced Filter", "note": "" }, @@ -2523,4 +2523,4 @@ "note": "Alt+Q search box does not exist yet (issue #49)" } ] -} \ No newline at end of file +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index a0ab5a339..45b694a68 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1927,6 +1927,11 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { act(app, "data.reapply", json!({})); } + let __k = small_button(ui, Icon::Filter, "Advanced", "Advanced Filter", false); + control_keytip(app, ui, &__k, "dialog:advancedFilter"); + if __k.clicked() { + app.open_dialog("advancedFilter", json!({})); + } }); sep(ui); let __k = big_button(ui, Icon::TextColumns, "Text to\nColumns", "Text to Columns", false); diff --git a/crates/ui-egui/tests/advanced_filter.rs b/crates/ui-egui/tests/advanced_filter.rs new file mode 100644 index 000000000..449c6a47e --- /dev/null +++ b/crates/ui-egui/tests/advanced_filter.rs @@ -0,0 +1,72 @@ +//! Advanced Filter's dialog must route both actions to the same engine command. + +use egui::vec2; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::control::{Outcome, handle}; +use serde_json::{Value as Json, json}; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(6); + h +} + +fn control(h: &mut egui_kittest::Harness<'static, SheetApp>, method: &str, params: Json) -> Json { + let ctx = h.ctx.clone(); + match handle(h.state_mut(), &ctx, method, ¶ms) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => json!(null), + } +} + +#[test] +fn dialog_offers_copy_action_and_runs_unique_extract() { + let mut h = harness(); + h.state_mut().run("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"], ["A"], ["B"]]})).unwrap(); + h.state_mut().open_dialog("advancedFilter", json!({})); + h.run_steps(6); + h.get_by_label("Copy matching rows elsewhere").click(); + h.run_steps(6); + let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("dialog").with("advancedFilter"))).unwrap(); + assert!(rect.width() < 500.0 && rect.height() < 400.0, "dialog must fit its controls: {rect:?}"); + assert!( + h.state() + .dialog + .as_ref() + .is_some_and(|d| d.fields.iter().any(|f| matches!(f, gridcraft_ui_egui::dialogs::Field::Text { key: "copyTo", .. }))) + ); + assert_eq!(h.state().dialog.as_ref().unwrap().values["range"], "A1:A4"); + control(&mut h, "ui.dialog.set", json!({"field": "copyTo", "value": "C1"})); + control(&mut h, "ui.dialog.set", json!({"field": "unique", "value": true})); + h.run_steps(6); + h.get_by_label(" OK ").click(); + h.run_steps(6); + assert!(h.state().dialog.is_none()); + assert!(h.state().message.is_none()); + assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().value(gridcraft_engine::core::CellRef::parse("C3").unwrap()).display(), "B"); +} + +#[test] +fn in_place_action_does_not_use_stale_copy_destination() { + let mut h = harness(); + h.state_mut().run("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"], ["B"]]})).unwrap(); + h.state_mut().run("range.setValues", json!({"range": "H1", "values": [["Name"], ["B"]]})).unwrap(); + control(&mut h, "ui.dialog", json!({"name": "advancedFilter"})); + control(&mut h, "ui.dialog.set", json!({"field": "range", "value": "A1:A3"})); + control(&mut h, "ui.dialog.set", json!({"field": "criteria", "value": "H1:H2"})); + control(&mut h, "ui.dialog.set", json!({"field": "copyTo", "value": "K1"})); + let r = control(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.state().session.active().unwrap().wb.active().unwrap().is_row_hidden(1)); + assert!(h.state().session.active().unwrap().wb.active().unwrap().value(gridcraft_engine::core::CellRef::parse("K1").unwrap()).is_empty()); +} diff --git a/crates/ui-egui/tests/keytips_generated.rs b/crates/ui-egui/tests/keytips_generated.rs index 653554857..5d649c56d 100644 --- a/crates/ui-egui/tests/keytips_generated.rs +++ b/crates/ui-egui/tests/keytips_generated.rs @@ -2149,7 +2149,7 @@ fn keytip_apt_() { fn keytip_aq_() { let mut h = harness(blank()); sequence(&mut h, "AQ", false); - assert!(h.state().message.is_some() || h.state().toast.is_some(), "AQ must invoke data.advancedFilter and surface its missing-params error"); + assert!(h.state().dialog.is_some(), "AQ must open the advancedFilter dialog"); } #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b623124d9..a10e34690 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and row formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-16 · **Change:** minor (Advanced Filter copy-to safety, #1031) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -83,7 +83,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Worksheet functions | 12 | 98.1% (measured) | ~82% | 16–26 | 513 / 523 names, 504 working. Missing: GETPIVOTDATA, IMAGE plus cloud functions. No conformance suite against Excel-computed oracles; edge-case bugs keep surfacing (#137, #138). [function-parity.md](function-parity.md) | | Editing, navigation, clipboard, fill | 12 | ~90% | ~70% | 15–25 | Point mode, F4, AutoComplete, Paste Special, AutoFill, Flash Fill, Find/Replace, Go To Special. Bugs: rectangular drag-select (#28), monthly fill (#132), cut across sheets (#128), Insert Copied Cells missing (#21), key tips only on part of the Home tab (#43; #33, #49). [ui-parity.md](ui-parity.md) | | Cell formatting, styles, conditional formatting | 10 | ~95% | ~80% | 8–14 | Full number-format language, borders, 47 cell styles, 60 table styles, all CF rule kinds including data bars, colour scales and icon sets. Gaps: font weights beyond regular/bold (#168), system fonts list (#4), themes | -| Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables | +| Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Advanced Filter copy-to preserves cells outside its result and rejects worksheet overflow (#1031). Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables | | PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, values on rows or columns, date grouping, show-values-as, refresh, calculated fields, XLSX round-trip. Missing: slicers, timelines, calculated items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel | | Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) | | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | @@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Advanced Filter copy-to clears only its actual output and rejects overflow without changing cells (#1031) | | 2026-10-12 | minor | Essentials AutoFit evidence: rows auto-fit oversized fonts on open and row formatting (#833) | | 2026-10-12 | minor | Sheet tabs: reveal the selected tab on navigation and resize while preserving manual scrolling (#681); no score change | | 2026-10-12 | minor | PivotTables: values on rows (Σ Values in Rows at any position, all three layouts; `pivot.moveField` and the field pane move it; `dataOnRows` round-trips) (no score change) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index bcb0520d7..4be6cb385 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; dialogs move by their title bar, #1250) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; dialogs move by their title bar, #1250; Advanced Filter dialog, #1622) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -52,6 +52,7 @@ this logic, with one undo step. | Point mode (click/drag/arrow references), reference colouring, F4 | done | | AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) | | Header resize and double-click AutoFit | done (#42, #88 for header dividers) | +| Data › Advanced Filter | dialog offers in-place or copy-to filtering, list and optional criteria ranges, and unique records; Alt+A,Q opens it (#1622). The engine's copy-to path clears only the result footprint and rejects worksheet overflow (#1031). | | Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), no "Show outline symbols" option yet, and a block of cells still groups rows without the Rows / Columns dialog | | Reorder selected whole columns by dragging their headers | done (10-11) | | Freeze panes, split panes | freeze done; split to verify | @@ -165,6 +166,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. |---|---|---| | 2026-10-19 | minor | Ctrl+Enter (`range.fill`) gives each cell what typing the input there gives: a General cell gets the automatic number format (`5%`, `$5`, `3/4/2026`, `=TODAY()`) even when it already held a value, and a cell with its own format, a formatted row or column, or Text keeps it. Cells that held a value used to keep General (`5%` showed 0.05), and empty cells took the active cell's format | | 2026-10-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) | +| 2026-10-16 | minor | Advanced Filter ribbon dialog, optional criteria for unique-only extraction (#1622); safe copy-to output (#1031) | | 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-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |