From 24ee1e4db7e6a840617e9311daaa9af07b301a09 Mon Sep 17 00:00:00 2001 From: bfenty <1398236+bfenty@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:36:12 +0000 Subject: [PATCH] fix(engine): count word-wrapped lines for automatic row height Closes #1969 Co-Authored-By: Claude --- crates/engine/src/cmd/mod.rs | 37 ++++++++++++++++++++--- crates/engine/tests/wrap_row_height.rs | 42 ++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 crates/engine/tests/wrap_row_height.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 50865a2b7..646afedb5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -453,6 +453,38 @@ fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::C } } +/// Lines a single text line needs when word-wrapped into `width` points. The grid wraps whole +/// words (egui's LayoutJob), so dividing the total width by the column width undercounts: each +/// drawn line ends early at the last word that fits. A word wider than the column breaks +/// mid-word, the way egui does. (#1969) +fn wrapped_line_count(line: &str, width: f32, size: f32, bold: bool) -> f32 { + let word_w = |w: &str| crate::cmd::format::approx_text_width(w, size, bold); + let space_w = word_w(" "); + let mut lines = 1.0f32; + let mut used = 0.0f32; + for word in line.split_whitespace() { + let w = word_w(word); + if w > width { + // wider than the column: egui fills the current line, then breaks it across lines + if used > 0.0 { + lines += 1.0; + } + let chunks = (w / width).ceil(); + lines += chunks - 1.0; + used = w - (chunks - 1.0) * width; + continue; + } + let gap = if used > 0.0 { space_w } else { 0.0 }; + if used + gap + w > width { + lines += 1.0; + used = w; + } else { + used += gap + w; + } + } + lines +} + /// 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. pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) { @@ -491,10 +523,7 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) { let explicit = text.lines().count().max(1) as f32; lines = if st.align.wrap { let w = sh.col_width(c).max(1.0) - 6.0; - 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) + text.lines().map(|l| wrapped_line_count(l, w.max(1.0), size, st.font.bold)).sum::().max(1.0) } else { explicit }; diff --git a/crates/engine/tests/wrap_row_height.rs b/crates/engine/tests/wrap_row_height.rs new file mode 100644 index 000000000..12246182b --- /dev/null +++ b/crates/engine/tests/wrap_row_height.rs @@ -0,0 +1,42 @@ +//! #1969: a wrapped cell's row must fit WORD-wrapped lines, not the character-fit +//! 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 serde_json::json; + +fn session() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s +} + +fn row_height(s: &mut Session, row: u32) -> f32 { + s.doc().unwrap().wb.active().unwrap().row_height(row) +} + +#[test] +fn wrapped_row_fits_word_wrapped_lines() { + let mut s = session(); + // Column fits ~10 characters; six-character words mean only one word per line. + s.execute("home.columnWidth", json!({"cols": "A:A", "chars": 10})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "aaaaaa bbbbbb cccccc dddddd"})).unwrap(); + let plain = row_height(&mut s, 0); + s.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap(); + let wrapped = row_height(&mut s, 0); + // Word wrap: 4 lines (one word each). The old estimate: ceil(28/10) = 3 lines. + // Line height at 11pt is ~19.8, so 4 lines need ~79 vs ~59 for 3. + assert!(wrapped >= plain + 3.0 * 19.0, "4 word-wrapped lines must grow the row by ~4 line heights: plain={plain}, wrapped={wrapped}"); +} + +#[test] +fn issue_repro_nasa_sentence() { + let mut s = session(); + s.execute("home.columnWidth", json!({"cols": "A:A", "chars": 12})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "Since the 1960s, NASA has left hardware across the solar system\u{2013}on the Moon, on Mars, and in deep space."})) + .unwrap(); + s.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap(); + let h = row_height(&mut s, 0); + // 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}"); +}