From 4faa3ff09833ceeb0f06686341743777ddf3cf65 Mon Sep 17 00:00:00 2001 From: RedBearAK <64876997+RedBearAK@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:45:22 -0800 Subject: [PATCH] Row auto-fit: count line breaks only in cells that wrap auto_row_height grew a row for every line break in a cell's text whether or not the cell wrapped. Excel counts breaks only when Wrap Text is on: with it off a multi-line value displays on one line and AutoFit gives a one-line row. A file whose unwrapped cells hold pasted multi-line text (the shape generating libraries write) opened with tall rows and nothing visibly tall in them. The fast path no longer treats a line break as a reason to measure, and the measuring path lays text out over lines only for a wrapping cell. Typing a break still turns wrap on (the Alt+Enter rule in cell entry) and grows the row, as before. Test: an unwrapped multi-line cell from a file keeps the default height on open; wrap on grows the row; wrap off returns it; a typed break wraps and grows. The open-time fit tests from #833 are unchanged. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 ++- crates/engine/src/cmd/mod.rs | 29 +++++++++++++++++------------ crates/engine/src/tests.rs | 31 +++++++++++++++++++++++++++++++ docs/target-app-parity.md | 3 ++- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..0add4eab0 100644 --- a/ROADMAP.md +++ b/ROADMAP.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-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (row auto-fit counts line breaks only in wrapping cells) · **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. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 50865a2b7..386138fd5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -453,18 +453,23 @@ fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::C } } -/// Recomputes an automatic row height from its content: the largest font, and wrapped or -/// multi-line text. Rows with a user-set height keep it. +/// Recomputes an automatic row height from its content: the largest font, and wrapped text. +/// Rows with a user-set height keep it. +/// +/// Line breaks in a cell's text count only when the cell wraps, as in Excel: with Wrap Text off +/// a multi-line value shows on one line and AutoFit gives a one-line row. (Until 2026-10-15 a +/// line break grew the row whether or not the cell wrapped, so a sheet whose notes column held +/// pasted multi-line text had tall rows with nothing visibly tall in them.) pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) { let Some(sh) = wb.sheet(si) else { return }; if sh.rows.get(&row).is_some_and(|i| i.custom) { return; } let default = sh.default_row_height; - // Fast path: rows of default-size, unwrapped, single-line content keep the default height. + // Fast path: rows of default-size, unwrapped content keep the default height. let plain = sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1).all(|(_, cell)| { let st = wb.styles.get(cell.style); - st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE && !st.align.wrap && !cell.value.as_text().is_some_and(|t| t.contains('\n')) + st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE && !st.align.wrap }); if plain { if sh.rows.get(&row).is_some_and(|i| i.size.is_some()) @@ -487,17 +492,17 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) { let line = (size * 1.8).round().max(default * size / gridcraft_model::DEFAULT_FONT_SIZE); let mut lines = 1.0f32; if !cell.value.is_empty() || cell.formula.is_some() { - let text = crate::display::cell_text(wb, sh, CellRef::new(row, c)); - let explicit = text.lines().count().max(1) as f32; - lines = if st.align.wrap { + // Only a wrapping cell is laid out over several lines: each explicit line break + // starts one, and each line folds at the column width. + if st.align.wrap { + let text = crate::display::cell_text(wb, sh, CellRef::new(row, c)); let w = sh.col_width(c).max(1.0) - 6.0; - text.lines() + lines = text + .lines() .map(|l| (crate::cmd::format::approx_text_width(l, size, st.font.bold) / w.max(1.0)).ceil().max(1.0)) .sum::() - .max(1.0) - } else { - explicit - }; + .max(1.0); + } } else if st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE { continue; } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2ac290654..568955c93 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1119,6 +1119,37 @@ fn open_grows_default_height_rows_to_their_font() { assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(0), default_h, "11pt row stays default"); } +#[test] +fn line_breaks_grow_a_row_only_when_the_cell_wraps() { + // Excel: a cell holding "a\nb" with Wrap Text off displays on one line and AutoFit gives a + // one-line row; with Wrap Text on it gets one line per break. Until 2026-10-15 the break grew + // the row either way, so a file whose notes column held pasted multi-line text (wrap off, as + // generating libraries write it) opened with tall rows and nothing visibly tall in them. + // Typing a break (Alt+Enter) turns wrap on, as in Excel, so the unwrapped case comes from a + // file, built with the model API and exported without row heights. + let text = "first line\n\n\nfourth line"; + let bytes = { + let mut wb = gridcraft_model::Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(1, 1), Value::text(text)); + gridcraft_xlsx::write_xlsx(&wb).unwrap() + }; + let default_h = gridcraft_model::Workbook::new().active().unwrap().row_height(1); + let mut s = Session::new(); + s.execute("file.open", json!({"name": "notes.xlsx", "base64": crate::io::base64_encode(&bytes)})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(1), default_h, "an unwrapped multi-line cell keeps the default row height on open"); + // Turning wrap on lays the text out over its lines and the row grows to hold them. + s.execute("home.wrapText", json!({"range": "B2", "on": true})).unwrap(); + let wrapped_h = s.doc().unwrap().wb.active().unwrap().row_height(1); + assert!(wrapped_h > default_h * 2.5, "wrapped four-line text grows the row, got {wrapped_h} vs default {default_h}"); + // Turning it off again returns the row to one line. + s.execute("home.wrapText", json!({"range": "B2", "on": false})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(1), default_h, "wrap off: back to a one-line row"); + // Entering a break by hand wraps the cell (Alt+Enter), so that row does grow. + s.execute("cell.set", json!({"cell": "D4", "input": text})).unwrap(); + assert!(s.doc().unwrap().wb.active().unwrap().row_height(3) > default_h * 2.5, "typed line breaks turn wrap on and grow the row"); +} + #[test] // B — Review Focus 1 fn open_keeps_custom_height_rows() { let mut s = Session::new(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b623124d9..a900c4fdc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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-15 · **Change:** minor (row auto-fit counts line breaks only in wrapping cells) · **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: @@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Row auto-fit: line breaks count only in wrapping cells (Excel: wrap off shows one line, AutoFit gives one line); unwrapped multi-line cells from files no longer grow rows on open (no score change) | | 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) |