Merge pull request #433 from derechopiano/fix/autofit-filter-button-spacing
Fix AutoFit width for table and AutoFilter dropdowns
This commit is contained in:
commit
e6773dac3c
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue