Browse / github.com/storytold/gridcraft / patch
Fix hidden columns inflating frozen header rows
Fix hidden columns inflating frozen header rows ## Summary Refs #2724. A wrapped cell in a hidden or zero-width column was measured at a one-pixel width during automatic row sizing. Opening a synthetic A1:AU3968 workbook with freeze `[8,10]` and 130% zoom grew its automatic row-8 header to the 546-pixel limit, consuming the data viewport. Skip columns with no displayed width when fitting rows. ```diff automatic row height - measure every unmerged cell, including hidden columns + measure visible, unmerged cells ``` **Reproduction limit:** the plain freeze/zoom profile rendered correctly. The synthetic failure additionally needs a hidden
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- TheRenegadeDev (imported from pull request #2755, open)
- Base
89ac453efa7ea03a3ab0b0d24fd85ca51172f5cc- Size
- +115 −6 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
a17cb1377db7c98a8977b9a7
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/cmd.rs crates/engine/tests/it/wrap_row_height.rs crates/ui-egui/tests/it/freeze_zoom.rs crates/ui-egui/tests/it/main.rs docs/ui-parity.md
@@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-27 · **Change:** minor (hidden columns no longer inflate frozen header rows) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---|+| 2026-10-27 | Hidden and zero-width columns no longer inflate automatic row heights to the limit, keeping rows below a frozen header visible in the synthetic #2724 repro; tests cover import, AutoFit, zoom, freeze positions and scrolling | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |@@ -242,6 +243,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Progress log: hidden columns no longer inflate frozen header rows (#2724 synthetic repro) | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | | 2026-10-23 | minor | Progress log: accounting negatives in number text | | 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |@@ -559,8 +559,10 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) { let row_merges: Vec<gridcraft_core::RangeRef> = sh.merges.iter().copied().filter(|m| !m.is_single() && m.start.row <= row && row <= m.end.row).collect(); for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) {- // Merged cells never auto-fit their rows, like Excel (column autofit skips them too).- if row_merges.iter().any(|m| m.contains(CellRef::new(row, c))) {+ // Hidden columns have no displayed text to fit. Measuring their zero width would+ // wrap even a short header to the row-height limit and consume a frozen viewport.+ // Merged cells never auto-fit their rows (column autofit skips them too).+ if sh.col_width(c) == 0.0 || row_merges.iter().any(|m| m.contains(CellRef::new(row, c))) { continue; } let st = wb.styles.get(cell.style);@@ -2,7 +2,9 @@ //! estimate (words can't split, so each drawn line ends early and narrow columns //! need more lines than width/estimate suggests). -use gridcraft_engine::Session;+use gridcraft_engine::core::{CellRef, Value};+use gridcraft_engine::model::{StyleId, Workbook};+use gridcraft_engine::{DocState, Session, Trust}; use serde_json::json; fn session() -> Session {@@ -40,3 +42,35 @@ fn issue_repro_nasa_sentence() { // Excel shows this at ~10 lines in a 12-char column; the character estimate undercounts. assert!(h >= 9.0 * 19.0, "NASA sentence in a 12-char column needs ~10 lines, got height {h}"); }++#[test]+fn hidden_columns_do_not_inflate_automatic_row_heights() {+ for width in [0.0, 64.0] {+ let mut wb = Workbook::new();+ let wrapped = wb.styles.derive(StyleId::DEFAULT, |s| {+ s.align.wrap = true;+ s.font.size = 24.0;+ });+ let sh = wb.sheet_mut(0).unwrap();+ sh.set_value(CellRef::new(0, 0), Value::text("Visible heading"));+ sh.set_value(CellRef::new(0, 1), Value::text("Hidden heading that must not set the visible row height"));+ sh.set_style(CellRef::new(0, 1), wrapped);+ let col = sh.cols.entry(1).or_default();+ col.size = Some(width);+ col.hidden = width != 0.0;+ sh.rows.entry(0).or_default().size = Some(70.0);+ let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();+ let (opened, _) = gridcraft_engine::io::open_bytes("hidden-header.xlsx", &bytes).unwrap();+ assert_eq!(opened.active().unwrap().row_height(0), 70.0, "open keeps the saved height, width={width}");++ let mut s = Session::new();+ s.add_document(DocState::new(opened, None, "Hidden header".into(), Trust::Local));+ s.execute("home.autofitRowHeight", json!({"rows": "1:1"})).unwrap();+ let sh = s.active().unwrap().wb.active().unwrap();+ assert_eq!(sh.row_height(0), sh.default_row_height, "AutoFit measures only the visible heading, width={width}");+ assert_eq!(sh.value(CellRef::new(0, 1)), Value::text("Hidden heading that must not set the visible row height"));+ s.execute("home.unhideColumns", json!({"cols": "B:B"})).unwrap();+ s.execute("home.autofitRowHeight", json!({"rows": "1:1"})).unwrap();+ assert!(s.active().unwrap().wb.active().unwrap().row_height(0) > 70.0, "the heading fits once its column is visible");+ }+}new file mode 100644@@ -0,0 +1,69 @@+//! A hidden wrapped header must not push data below the frozen rows out of view (#2724).++use egui::Shape;+use gridcraft_engine::core::{CellRef, RangeRef, Value};+use gridcraft_engine::model::Workbook;+use gridcraft_engine::{DocState, Session, Trust};+use gridcraft_ui_egui::grid::Geo;+use serde_json::json;++#[test]+fn imported_hidden_header_keeps_data_visible_below_freeze_at_different_zooms() {+ let mut wb = Workbook::new();+ let wrapped = wb.styles.derive(Default::default(), |s| s.align.wrap = true);+ let sh = wb.sheet_mut(0).unwrap();+ for row in 0..40 {+ for col in 0..47 {+ sh.set_value(CellRef::new(row, col), Value::text(format!("R{}C{}", row + 1, col + 1)));+ }+ }+ // Preserve the issue's 47-column A1:AU3968 used range without a binary fixture.+ sh.set_value(CellRef::new(3967, 46), Value::text("last cell"));+ sh.set_value(CellRef::new(7, 46), Value::text("Hidden wrapped budget heading"));+ sh.set_style(CellRef::new(7, 46), wrapped);+ sh.cols.entry(46).or_default().hidden = true;+ sh.rows.entry(7).or_default().size = Some(70.0);+ sh.freeze = Some((8, 10));+ sh.zoom = 130;+ sh.view_active = CellRef::new(7, 0);+ sh.view_top_left = CellRef::new(8, 10);+ let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();+ let (opened, _) = gridcraft_engine::io::open_bytes("freeze-zoom.xlsx", &bytes).unwrap();+ let mut session = Session::new();+ session.add_document(DocState::new(opened, None, "Freeze zoom".into(), Trust::FromFile));+ let mut h = crate::common::harness_with(crate::common::builder().with_size(egui::vec2(1896.0, 1014.0)), crate::common::app(session), 4);+ for zoom in [50, 75, 100, 110, 125, 130, 150, 175, 200] {+ for freeze in ["A1", "A9", "K1", "K9", "C4"] {+ h.state_mut().session.execute("view.freezePanes", json!({"cell": freeze})).unwrap();+ h.state_mut().session.execute("view.zoom", json!({"percent": zoom})).unwrap();+ for scroll in [egui::Vec2::ZERO, egui::vec2(37.5, 205.25)] {+ h.state_mut().view_mut().unwrap().scroll = scroll;+ h.run_steps(4);+ let app = h.state();+ let sh = app.session.active().unwrap().wb.active().unwrap();+ assert_eq!(sh.row_height(7), 70.0, "the frozen header keeps its saved height");+ let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll);+ let mut checked = 0;+ for row in 8..40 {+ for col in [0, 9, 10, 11] {+ let cell = CellRef::new(row, col);+ let rect = geo.cell_rect(sh, cell);+ if !geo.cells.contains_rect(rect) || !geo.range_panes(RangeRef::cell(cell)).iter().any(|pane| pane.contains_rect(rect)) {+ continue;+ }+ let text = format!("R{}C{}", row + 1, col + 1);+ let painted = h.output().shapes.iter().any(|s| match &s.shape {+ Shape::Text(t) if t.galley.job.text == text => {+ s.clip_rect.intersect(t.galley.rect.translate(t.pos.to_vec2())).intersects(rect.shrink(1.0))+ }+ _ => false,+ });+ assert!(painted, "{} must paint at zoom {zoom}, freeze {freeze}, scroll {scroll:?}", cell.a1());+ checked += 1;+ }+ }+ assert!(checked > 0, "data rows are visible at zoom {zoom}, freeze {freeze}, scroll {scroll:?}");+ }+ }+ }+}@@ -46,6 +46,7 @@ mod formula_locale; mod formula_point_headers; mod formula_popup; mod freeze_handles;+mod freeze_zoom; mod go_to_dialog; mod header_autofit; mod header_column_drag;@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (hidden columns no longer inflate frozen header rows) · **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@@ -63,7 +63,7 @@ this logic, with one undo step. | 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; Data › Outline › Settings (`data.outlineSettings`) puts summary rows above and summary columns left of their detail, saved as `outlinePr` (the dialog's Automatic styles check box and Apply Styles button are missing), View › Show Outline Symbols (Ctrl+8) hides the bands and is saved with the sheet, 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 |+| Freeze panes, split panes | freeze done; hidden wrapped columns do not inflate automatic header heights and hide the scrolling rows (#2724 synthetic repro); split to verify | | A Table's header names and filter buttons in the column headings once its header row scrolls away | done (#1423): with the active cell in the Table, as in Excel; the button opens the column's filter menu, the rest of the heading still selects the column. Plain AutoFilter ranges keep their letters (Excel does the same) | | Copy as HTML table for other apps | done (#36, 10-10) | | Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Show Details on a PivotTable's value, subtotal or grand total cell (`pivot.showDetails`). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup |@@ -187,6 +187,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Automatic row sizing ignores hidden and zero-width columns, preventing a hidden wrapped header from growing a frozen row to the height limit. Synthetic A1:AU3968 UI regression covers nine zoom levels, five freeze positions and fractional scroll offsets (#2724); the reported workbook and a live Excel comparison were unavailable | | 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty | | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified | | 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list |