Merge PR #1628: Add Advanced Filter dialog and fix copy-to data loss

crates/ui-egui/src/control.rs: kept #1477's unconditional build_params call
crates/ui-egui/src/dialogs.rs: build_params pub(crate) once, with #1628's advancedFilter arm
docs/ui-parity.md: status line combined, change-log rows both kept
This commit is contained in:
aiongg
2026-10-22 02:35:59 +00:00
9 changed files with 231 additions and 41 deletions
+84 -32
View File
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Json> {
(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<String> =
(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<Cond>> = 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<Json> {
}
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<Json> {
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<u32> = 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<CellRef> = shm.cells.iter_range(extract).map(|(c, _)| c).collect();
let old: Vec<CellRef> = 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;
+59 -1
View File
@@ -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),
@@ -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
+3 -3
View File
@@ -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)"
}
]
}
}
+5
View File
@@ -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);
+72
View File
@@ -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, &params) {
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());
}
+1 -1
View File
@@ -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]
+3 -2
View File
@@ -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) |
+3 -1
View File
@@ -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 |