From 02ceedee173b6b1e6c063e8e4a7f5ec0924e2d83 Mon Sep 17 00:00:00 2001 From: Camilo Agudelo Date: Mon, 19 Oct 2026 10:12:17 -0500 Subject: [PATCH 1/3] In-cell editor: a Wrap Text cell edits wrapped at the column width The in-cell editor ignored Wrap Text: it widened to the text's one-line width, up to the grid's right edge, so a long wrapped cell became one unmanageable line while edited. With Wrap Text on it now keeps the column's width, wraps, and grows down to show every line, as Excel's does. The multiline field is one row tall at least, not four, so a short edit doesn't reach over the rows below. --- crates/ui-egui/src/formula_bar.rs | 8 ++- crates/ui-egui/src/grid.rs | 16 ++++-- crates/ui-egui/tests/in_cell_editor_wrap.rs | 59 +++++++++++++++++++++ 3 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/tests/in_cell_editor_wrap.rs diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 7f1ad87fc..848ba5458 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -279,7 +279,13 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: job.wrap.max_width = if multiline { wrap_width } else { f32::INFINITY }; ui.fonts_mut(|f| f.layout_job(job)) }; - let edit = if multiline || ed.text.contains('\n') { TextEdit::multiline(&mut ed.text) } else { TextEdit::singleline(&mut ed.text) }; + // The in-cell editor is as tall as its lines (a multiline field reserves four rows). + let in_cell = id == egui::Id::new("gridcraft.cell_editor"); + let edit = if multiline || ed.text.contains('\n') { + TextEdit::multiline(&mut ed.text).desired_rows(if in_cell { 1 } else { 4 }) + } else { + TextEdit::singleline(&mut ed.text) + }; let mut output = edit .id(id) .frame(egui::Frame::NONE) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 90a39cb06..6cd49691c 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2707,10 +2707,18 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, let st = wb.styles.get(sh.style_id(cell)).clone(); let size = st.font.size * 96.0 / 72.0 * geo.z; let font = FontId::new(size, theme::cell_family(&st.font.name, st.font.bold, st.font.italic)); - let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 }; - let w = (text_w + 14.0).max(rect.width()).min(geo.cells.right() - rect.left()); - let h = (size * 1.3 * lines + 4.0).max(rect.height()); + // Wrap Text: the editor keeps the column's width and wraps, growing down as Excel's does. + // Otherwise it widens to the text's one-line width. + let wrap = st.align.wrap; + let (w, h) = if wrap { + // The field's own margins (4 px a side) and the 2 px inset leave this much for the text. + let wrapped = ui.painter().layout(ed.text.clone(), font.clone(), Color32::BLACK, (rect.width() - 12.0).max(1.0)); + (rect.width(), (wrapped.size().y.max(size * 1.3 * lines) + 4.0).max(rect.height())) + } else { + let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; + ((text_w + 14.0).max(rect.width()).min(geo.cells.right() - rect.left()), (size * 1.3 * lines + 4.0).max(rect.height())) + }; // Grows to the left on a right-to-left sheet. let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h))); let fill = match st.fill.pattern { @@ -2729,7 +2737,7 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, let id = egui::Id::new("gridcraft.cell_editor"); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0)))); child.set_clip_rect(clip); - crate::formula_bar::editor_widget(app, &mut child, id, font, false, erect.width() - 4.0); + crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0); } /// Test the popup itself, not whether the pointer is over any part of the app. diff --git a/crates/ui-egui/tests/in_cell_editor_wrap.rs b/crates/ui-egui/tests/in_cell_editor_wrap.rs new file mode 100644 index 000000000..7ebbf79ea --- /dev/null +++ b/crates/ui-egui/tests/in_cell_editor_wrap.rs @@ -0,0 +1,59 @@ +//! The in-cell editor of a Wrap Text cell keeps the column's width and wraps, growing down, as +//! Excel's does; it used to widen to one long line. +use egui::{Context, Id, RawInput, Rect, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +const TEXT: &str = "Since the 1960s, NASA has left hardware across the solar system, on the Moon, on Mars, and in deep space."; + +/// Runs a few frames of the app in a 1200 × 800 window. +fn frames(ctx: &Context, app: &mut SheetApp) { + for _ in 0..4 { + let mut output = + ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() }, |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }); + output.textures_delta.clear(); + } +} + +/// The editor's text field rect while A1 (narrow, wrap on or off) is edited with [`TEXT`]. +fn editor_rect(wrap: bool) -> Rect { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + session.execute("home.columnWidth", json!({"cols": "A:A", "chars": 12})).unwrap(); + session.execute("home.wrapText", json!({"range": "A1", "on": wrap})).unwrap(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some(TEXT.into()), false); + frames(&ctx, &mut app); + ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect +} + +#[test] +fn a_wrap_text_cell_edits_wrapped_at_the_column_width() { + let wrapped = editor_rect(true); + let one_line = editor_rect(false); + assert!(one_line.width() > 400.0, "without Wrap Text the editor widens to the text: {one_line:?}"); + assert!(wrapped.width() < 120.0, "with Wrap Text the editor keeps the column's width: {wrapped:?}"); + assert!(wrapped.height() > 4.0 * one_line.height(), "and grows down to show every line: {wrapped:?} vs {one_line:?}"); +} + +/// A short Wrap Text edit stays one line tall: the field doesn't reserve rows over the cells below. +#[test] +fn a_short_wrap_text_edit_stays_one_line_tall() { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + session.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some("short".into()), false); + frames(&ctx, &mut app); + let field = ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect; + assert!(field.height() < 30.0, "a one-line edit doesn't reach over the rows below: {field:?}"); +} From 3df5e05f0f8844b64afef0298d31308e03149530 Mon Sep 17 00:00:00 2001 From: Camilo Agudelo Date: Mon, 19 Oct 2026 11:40:32 -0500 Subject: [PATCH 2/3] In-cell editor: measure wrapped text at the field's own wrap width (#1970) With Frame::NONE the text field takes no text margin off its width, so it wraps at the editor's width less the 2 px inset a side. Measuring 8 px narrower counted rows the field doesn't have and made the editor reach over the cells below. A test pins the field's wrap width. Review feedback on #1974. --- crates/ui-egui/src/grid.rs | 5 ++-- crates/ui-egui/tests/in_cell_editor_wrap.rs | 27 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 6cd49691c..85aaa54b3 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2712,8 +2712,9 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, // Otherwise it widens to the text's one-line width. let wrap = st.align.wrap; let (w, h) = if wrap { - // The field's own margins (4 px a side) and the 2 px inset leave this much for the text. - let wrapped = ui.painter().layout(ed.text.clone(), font.clone(), Color32::BLACK, (rect.width() - 12.0).max(1.0)); + // The field wraps at the width it is given (the editor's less its 2 px inset a side): its + // frame is `Frame::NONE`, so no text margin is taken off. + let wrapped = ui.painter().layout(ed.text.clone(), font.clone(), Color32::BLACK, (rect.width() - 4.0).max(1.0)); (rect.width(), (wrapped.size().y.max(size * 1.3 * lines) + 4.0).max(rect.height())) } else { let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; diff --git a/crates/ui-egui/tests/in_cell_editor_wrap.rs b/crates/ui-egui/tests/in_cell_editor_wrap.rs index 7ebbf79ea..a60d88fd8 100644 --- a/crates/ui-egui/tests/in_cell_editor_wrap.rs +++ b/crates/ui-egui/tests/in_cell_editor_wrap.rs @@ -57,3 +57,30 @@ fn a_short_wrap_text_edit_stays_one_line_tall() { let field = ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect; assert!(field.height() < 30.0, "a one-line edit doesn't reach over the rows below: {field:?}"); } + +/// The editor is sized from the text wrapped where the field wraps it: the editor's width less its +/// 2 px inset a side, with no text margin (the field has no frame). Measured narrower, the editor +/// would count rows the field doesn't have and reach over the cells below. +#[test] +fn the_editor_measures_the_text_at_the_fields_wrap_width() { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + session.execute("home.columnWidth", json!({"cols": "A:A", "chars": 12})).unwrap(); + session.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some(TEXT.into()), false); + frames(&ctx, &mut app); + let field = ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect; + let sheet = app.session.active().unwrap().wb.active().unwrap().clone(); + let st = app.session.active().unwrap().wb.styles.get(sheet.style_id(gridcraft_engine::cell(0, 0))).clone(); + // The grid draws at its zoom: the cell is the field plus the 2 px inset a side, the font scales. + let z = (field.width() + 4.0) / sheet.col_width(0); + let font = egui::FontId::new(st.font.size * 96.0 / 72.0 * z, gridcraft_ui_egui::theme::cell_family(&st.font.name, st.font.bold, st.font.italic)); + let at = + |w: f32| ctx.fonts_mut(|f| f.layout_job(egui::text::LayoutJob::simple(TEXT.into(), font.clone(), egui::Color32::PLACEHOLDER, w)).size().y); + let (own, with_margin) = (at(field.width()), at(field.width() - 8.0)); + assert!(own != with_margin, "the test text must wrap differently 8 px narrower"); + assert!((field.height() - own).abs() < 1.0, "the field wraps at its whole width, no text margin: {} vs {own}", field.height()); +} From 6481c1715094802c99dd8962ce58bbf7a401c477 Mon Sep 17 00:00:00 2001 From: Camilo Agudelo Date: Mon, 19 Oct 2026 12:02:48 -0500 Subject: [PATCH 3/3] In-cell editor: test that an unwrapped multiline edit shows every line (#1970) desired_rows(1) only sets the field's minimum height; it grows with its text. Pins that an Alt+Enter edit in a cell without Wrap Text shows all of its lines and reserves no more. Review feedback on #1974. --- crates/ui-egui/tests/in_cell_editor_wrap.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/ui-egui/tests/in_cell_editor_wrap.rs b/crates/ui-egui/tests/in_cell_editor_wrap.rs index a60d88fd8..76fd9a749 100644 --- a/crates/ui-egui/tests/in_cell_editor_wrap.rs +++ b/crates/ui-egui/tests/in_cell_editor_wrap.rs @@ -84,3 +84,23 @@ fn the_editor_measures_the_text_at_the_fields_wrap_width() { assert!(own != with_margin, "the test text must wrap differently 8 px narrower"); assert!((field.height() - own).abs() < 1.0, "the field wraps at its whole width, no text margin: {} vs {own}", field.height()); } + +/// An edit with line breaks (Alt+Enter) in a cell without Wrap Text shows every line: the field's +/// one row is a minimum, it grows with the text and never scrolls. +#[test] +fn an_unwrapped_multiline_edit_shows_every_line() { + let field = |text: &str| { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some(text.into()), false); + frames(&ctx, &mut app); + ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect + }; + let one = field("one").height(); + let three = field("one\ntwo\nthree").height(); + assert!(three > 2.5 * one, "three lines are shown: {three} vs one line {one}"); + assert!(three < 3.5 * one, "and no more rows are reserved: {three} vs one line {one}"); +}