diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index be09ba2..f95830c 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -1019,7 +1019,9 @@ fn autofit_cols(s: &mut Session, p: &Json) -> Result { let st = cx.wb.styles.get(sh.style_id(cell)); let text = crate::display::cell_text(&cx.wb, sh, cell); for line in text.lines() { - max = max.max(approx_text_width(line, st.font.size, st.font.bold) + 6.0 + st.align.indent as f32 * 9.0); + max = max.max( + approx_text_width(line, st.font.size, st.font.bold) + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell), + ); } } if max > 0.0 { diff --git a/crates/engine/tests/autofit_filter_buttons.rs b/crates/engine/tests/autofit_filter_buttons.rs new file mode 100644 index 0000000..307bb99 --- /dev/null +++ b/crates/engine/tests/autofit_filter_buttons.rs @@ -0,0 +1,102 @@ +use gridcraft_core::CellRef; +use gridcraft_engine::Session; +use serde_json::json; + +fn table() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range":"A1", "values":[["Quarterly Revenue"], ["1"]]})).unwrap(); + s.execute("insert.table", json!({"range":"A1:A2", "header":true})).unwrap(); + s +} + +fn fit(s: &mut Session) -> f32 { + s.execute("home.autofitColumnWidth", json!({"cols":"A:A"})).unwrap(); + s.doc().unwrap().wb.active().unwrap().col_width(0) +} + +#[test] +fn autofit_reserves_room_for_table_filter_button() { + let mut s = table(); + s.execute("selection.set", json!({"cell":"A1"})).unwrap(); + s.execute("data.filter", json!({})).unwrap(); + let plain = fit(&mut s); + s.execute("data.filter", json!({})).unwrap(); + let filtered = fit(&mut s); + assert!(filtered >= plain + 16.0, "filter button obscures heading: plain={plain}, filtered={filtered}"); + assert_eq!(s.doc().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)).display(), "Quarterly Revenue"); +} + +fn sheet_mut(s: &mut Session) -> &mut gridcraft_engine::model::Sheet { + std::sync::Arc::make_mut(&mut s.active_mut().unwrap().wb).sheet_mut(0).unwrap() +} + +#[test] +fn autofit_skips_disabled_buttons_and_hidden_table_headers() { + let mut s = table(); + sheet_mut(&mut s).tables[0].filter_button = false; + let plain = fit(&mut s); + sheet_mut(&mut s).tables[0].filter_button = true; + sheet_mut(&mut s).tables[0].header_row = false; + assert_eq!(fit(&mut s), plain); + sheet_mut(&mut s).tables[0].header_row = true; + assert!(fit(&mut s) > plain); +} + +#[test] +fn autofit_matches_excel_filter_allowance_across_zoom_and_row_heights() { + for (zoom, allowance) in [(50, 12.5 * 96.0 / 72.0 * 2.0), (100, 12.5 * 96.0 / 72.0), (200, 12.5 * 96.0 / 72.0)] { + for height in [12.0_f32, 20.0, 60.0] { + let mut s = table(); + s.execute("view.zoom", json!({"percent":zoom})).unwrap(); + s.execute("home.rowHeight", json!({"rows":"1:1", "height":height})).unwrap(); + sheet_mut(&mut s).tables[0].filter_button = false; + let plain = fit(&mut s); + sheet_mut(&mut s).tables[0].filter_button = true; + let filtered = fit(&mut s); + assert!((filtered - plain - allowance).abs() < 1.0, "zoom={zoom}, height={height}, plain={plain}, filtered={filtered}"); + } + } +} + +#[test] +fn autofit_supports_ordinary_autofilters_without_double_counting_table_buttons() { + let mut s = table(); + let table_width = fit(&mut s); + sheet_mut(&mut s).autofilter = + Some(gridcraft_engine::model::AutoFilter { range: gridcraft_engine::core::RangeRef::parse("A1:A2").unwrap(), criteria: vec![] }); + assert_eq!(fit(&mut s), table_width, "overlapping filters occupy one button slot"); + sheet_mut(&mut s).tables.clear(); + assert_eq!(fit(&mut s), table_width, "ordinary filter headers reserve the same space"); + sheet_mut(&mut s).autofilter = None; + assert!(fit(&mut s) < table_width); +} + +#[test] +fn autofit_leaves_longer_body_cells_and_unfiltered_columns_unchanged() { + let mut s = table(); + s.execute("cell.set", json!({"cell":"A2", "input":"A body value much longer than the short table header above"})).unwrap(); + let filtered = fit(&mut s); + sheet_mut(&mut s).tables[0].filter_button = false; + assert_eq!(fit(&mut s), filtered); + s.execute("cell.set", json!({"cell":"B1", "input":"Quarterly Revenue"})).unwrap(); + s.execute("home.autofitColumnWidth", json!({"cols":"B:B"})).unwrap(); + let plain = s.doc().unwrap().wb.active().unwrap().col_width(1); + sheet_mut(&mut s).tables[0].filter_button = true; + s.execute("home.autofitColumnWidth", json!({"cols":"B:B"})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(1), plain); +} + +#[test] +fn autofit_filter_width_is_undoable_without_changing_selection() { + let mut s = table(); + s.execute("home.columnWidth", json!({"cols":"A:A", "width":80})).unwrap(); + s.execute("selection.set", json!({"range":"A:A"})).unwrap(); + let selection = s.doc().unwrap().selection.clone(); + s.active_mut().unwrap().undo.clear(); + fit(&mut s); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.doc().unwrap().undo.len(), 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(0), 80.0); +} diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index 696c9ef..f905c23 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -221,6 +221,20 @@ impl Sheet { // ---- geometry + /// Whether this cell has a table or ordinary AutoFilter dropdown. + pub fn has_filter_button(&self, c: CellRef) -> bool { + self.autofilter.as_ref().is_some_and(|af| af.range.start.row == c.row && af.range.contains(c)) + || self.tables.iter().any(|t| t.filter_button && t.header_row && t.range.start.row == c.row && t.range.contains(c)) + } + + /// Horizontal header space in unzoomed grid units (96 units per inch). + /// Observed in Windows Excel 16.0 build 20326: AutoFit reserves 12.5 pt at + /// 100-200% zoom, 25 pt at 50%, regardless of header row height. Below 100% + /// the dropdown keeps its screen width. This is geometry, not font padding. + pub fn filter_button_space(&self, c: CellRef) -> f32 { + if self.has_filter_button(c) { (12.5 * 96.0 / 72.0) / (self.zoom as f32 / 100.0).clamp(0.1, 1.0) } else { 0.0 } + } + pub fn col_width(&self, col: u32) -> f32 { match self.cols.get(&col) { Some(i) if i.hidden => 0.0, diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 0e5517a..d0eef85 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -955,6 +955,10 @@ pub(crate) fn paint_quadrant( if cfl.as_ref().is_some_and(|l| l.hide_value) || v.is_empty() { continue; } + // Align and clip header text inside the content area, away from the dropdown. + let filter_space = sh.filter_button_space(c) * z; + let text_rect = Rect::from_min_max(rect.min, pos2((rect.right() - filter_space).max(rect.left()), rect.bottom())); + let rect = &text_rect; let st = &lk.style; let size = st.font.size * 96.0 / 72.0 * z; let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic); @@ -1033,7 +1037,13 @@ pub(crate) fn paint_quadrant( } // Overflow into empty neighbours for text (not upright text, whose width is its line height). let mut clip = *rect; - if !numeric && !matches!(v, Value::Number(_)) && st.align.rotation.abs() != 90 && w > rect.width() - 2.0 * pad && in_merge(c).is_none() { + if filter_space == 0.0 + && !numeric + && !matches!(v, Value::Number(_)) + && st.align.rotation.abs() != 90 + && w > rect.width() - 2.0 * pad + && in_merge(c).is_none() + { let mut right = rect.right(); let mut left = rect.left(); if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) { @@ -1185,8 +1195,10 @@ pub(crate) fn paint_quadrant( pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect { let rect = geo.cell_rect(sh, c); - let s = (rect.height() - 4.0).clamp(10.0, 18.0); - Rect::from_min_size(pos2(rect.right() - s - 2.0, rect.bottom() - s - 2.0), vec2(s, s)) + // Keep the button within the same header slot that AutoFit reserves. + let pad = 2.0 * geo.z; + let s = (rect.height() - 4.0).clamp(10.0, 18.0).min((sh.filter_button_space(c) * geo.z - pad).max(0.0)); + Rect::from_min_size(pos2(rect.right() - s - pad, rect.bottom() - s - pad), vec2(s, s)) } /// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the @@ -2241,12 +2253,7 @@ fn row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<(u32, f32)> { fn is_filter_button(sh: &Sheet, geo: &Geo, p: Pos2) -> bool { let c = geo.cell_at(sh, p); - let header = sh.autofilter.as_ref().is_some_and(|af| af.range.start.row == c.row && c.col >= af.range.start.col && c.col <= af.range.end.col) - || sh - .tables - .iter() - .any(|t| t.filter_button && t.header_row && t.range.start.row == c.row && c.col >= t.range.start.col && c.col <= t.range.end.col); - header && filter_button_rect(geo, sh, c).contains(p) + sh.has_filter_button(c) && filter_button_rect(geo, sh, c).contains(p) } fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option { diff --git a/crates/ui-egui/tests/header_autofit.rs b/crates/ui-egui/tests/header_autofit.rs index 7d1bbbc..5e12380 100644 --- a/crates/ui-egui/tests/header_autofit.rs +++ b/crates/ui-egui/tests/header_autofit.rs @@ -152,3 +152,42 @@ fn dragging_a_boundary_inside_a_multi_area_selection_resizes_every_selected_line assert_eq!(sizes(&h, rows), before, "selection {selection}: undo restores every line"); } } + +#[test] +fn table_filter_autofit_works_through_command_and_header_double_click() { + for zoom in [50, 100, 200] { + for double_click in [false, true] { + let mut h = harness("A:A"); + h.state_mut().session.execute("range.setValues", json!({"range":"A1", "values":[["Quarterly Revenue"], ["1"], ["2"]]})).unwrap(); + h.state_mut().session.execute("insert.table", json!({"range":"A1:A3", "header":true})).unwrap(); + h.state_mut().session.execute("home.rowHeight", json!({"rows":"1:1", "height":20})).unwrap(); + h.state_mut().session.execute("view.zoom", json!({"percent":zoom})).unwrap(); + h.run_steps(3); + h.state_mut().session.active_mut().unwrap().undo.clear(); + h.state_mut().session.journal.clear(); + if double_click { + let (pos, _) = edge(&h, false, 0); + button(&mut h, pos, true); + button(&mut h, pos, false); + h.step(); + button(&mut h, pos, true); + button(&mut h, pos, false); + } else { + h.state_mut().run("home.autofitColumnWidth", json!({"cols":"A:A"})).unwrap(); + } + h.run_steps(2); + 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); + let cell = gridcraft_engine::core::CellRef::new(0, 0); + let rect = geo.cell_rect(sheet, cell); + let button = gridcraft_ui_egui::grid::filter_button_rect(&geo, sheet, cell); + let style = app.session.active().unwrap().wb.styles.get(sheet.style_id(cell)); + let text_width = gridcraft_engine::cmd::format::approx_text_width("Quarterly Revenue", style.font.size, style.font.bold) * geo.z; + assert!(rect.left() + text_width + 2.5 * geo.z < button.left(), "heading overlaps filter: zoom={zoom}, double_click={double_click}"); + assert_eq!(app.session.active().unwrap().undo.len(), 1); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(sizes(&h, false)[0], 100.0); + } + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 697c2e1..fd9c74d 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-11 · **Change:** minor (outline band with − / + and level buttons for grouped rows and columns, #373) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (AutoFit reserves filter dropdown space, observed against Windows Excel) · **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 @@ -33,6 +33,14 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes ## Grid interaction +AutoFit column width includes dropdown space only for filter header cells (tables and ordinary +AutoFilters), then takes the maximum against body cells. Windows Excel 16.0 build 20326, +observed using synthetic workbooks via its public automation API on 2026-10-11, reserves +12.5 points at 100-200% zoom and 25 points at 50%, independently of row height. GridCraft +uses that allowance in its grid units; its existing approximate font measurement remains. +Header text aligns and clips before the button. Command and header-double-click paths share +this logic, with one undo step. + | Behaviour | Status | |---|---| | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | @@ -151,6 +159,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area | | 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) | | 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) | | 2026-10-11 | minor | Selected whole columns can be reordered by dragging their headers to an insertion point |