Merge pull request #433 from derechopiano/fix/autofit-filter-button-spacing

Fix AutoFit width for table and AutoFilter dropdowns
This commit is contained in:
Brandon Thomas 2026-10-11 18:05:13 -04:00
commit e6773dac3c
6 changed files with 184 additions and 11 deletions

View File

@ -1019,7 +1019,9 @@ fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
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 {

View File

@ -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);
}

View File

@ -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,

View File

@ -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<gridcraft_engine::model::Anchor> {

View File

@ -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);
}
}
}

View File

@ -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 |