fix(grid): prevent partial wrapped text lines in fixed-height rows

This commit is contained in:
AnhNgoo
2026-10-28 17:03:09 +07:00
parent 11bb67386c
commit f9b869b3a4
2 changed files with 193 additions and 1 deletions
+12 -1
View File
@@ -260,7 +260,18 @@ pub(crate) fn paint_quadrant(
_ => text_left + pad + indent,
};
let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z);
p.with_clip_rect(rect.intersect(p.clip_rect())).galley(pos2(x, y), galley, color);
let mut text_clip = rect.intersect(p.clip_rect());
// A partially visible wrapped line can leave a few pixels
// touching the row border. For top-aligned text, show only
// complete lines within the available cell height.
if st.align.v == VAlign::Top {
let last_full_bottom = galley.rows.iter().map(|row| y + row.rect().bottom()).rfind(|bottom| *bottom <= rect.bottom() + 0.01);
text_clip.max.y = last_full_bottom.map(|bottom| text_clip.bottom().min(bottom)).unwrap_or(text_clip.top()).max(text_clip.top());
}
p.with_clip_rect(text_clip).galley(pos2(x, y), galley, color);
continue;
}
// Wrap Text off: Excel ignores the line breaks and draws the whole string on one line.
@@ -108,3 +108,184 @@ fn scrolled_overflow_does_not_draw_over_frozen_columns() {
assert!(clips.iter().any(|r| r.intersects(e1.shrink(1.0))), "C1's overflow reaches E1: {clips:?}");
assert!(clips.iter().all(|r| !r.intersects(a1.shrink(1.0))), "C1's overflow stays out of frozen column A: {clips:?}");
}
/// Issue #2752: fixed-height wrapped cells must not paint
/// a fraction of a text line against the next row's border.
#[test]
fn wrapped_text_in_fixed_height_row_shows_only_complete_lines() {
const TEXT: &str = "Row 3 target. This is intentionally long text that must wrap \
across many lines within a narrow column. It must never \
paint over the next row.";
let h = harness(|s| {
s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 12})).unwrap();
s.execute("cell.set", json!({"cell": "B3", "input": TEXT})).unwrap();
s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap();
s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap();
// Apply the custom height after Wrap Text so AutoFit
// cannot expand the row for this scenario.
s.execute("home.rowHeight", json!({"rows": "3:3", "height": 62.67})).unwrap();
});
let cell = cell_rect(&h, "B3");
let shapes: Vec<_> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::Text(t) if t.galley.job.text == TEXT => Some((t, shape.clip_rect)),
_ => None,
})
.collect();
assert_eq!(shapes.len(), 1, "expected one painted wrapped text shape");
let (text, clip) = shapes[0];
assert!(text.galley.rows.len() > 4, "test must produce multiple wrapped lines");
assert!(text.galley.size().y > cell.height(), "wrapped text must exceed the fixed row height");
assert!(clip.top() >= cell.top() - 0.5 && clip.bottom() <= cell.bottom() + 0.5, "text clip must stay within B3: {clip:?} vs {cell:?}");
let mut full_rows_visible = 0;
for (index, row) in text.galley.rows.iter().enumerate() {
let top = text.pos.y + row.rect().top();
let bottom = text.pos.y + row.rect().bottom();
let full_height = bottom - top;
let visible_height = (bottom.min(clip.bottom()) - top.max(clip.top())).max(0.0);
let partly_visible = visible_height > 0.5 && visible_height < full_height - 0.5;
assert!(
!partly_visible,
"wrapped line {} is partially visible: \
line=[{top:.2}, {bottom:.2}], clip={clip:?}",
index + 1
);
if visible_height >= full_height - 0.5 {
full_rows_visible += 1;
}
}
assert!(full_rows_visible >= 2, "the clipping fix must still show complete text lines");
}
/// Issue #2752: a row shorter than a text line must not
/// render a fragment of wrapped text.
#[test]
fn wrapped_text_in_tiny_row_has_no_partial_lines() {
const TEXT: &str = "This wrapped text should not be partly painted.";
let h = harness(|s| {
s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 12})).unwrap();
s.execute("cell.set", json!({"cell": "B3", "input": TEXT})).unwrap();
s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap();
s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap();
s.execute("home.rowHeight", json!({"rows": "3:3", "height": 5})).unwrap();
});
let cell = cell_rect(&h, "B3");
let shapes: Vec<_> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::Text(t) if t.galley.job.text == TEXT => Some((t, shape.clip_rect)),
_ => None,
})
.collect();
assert_eq!(shapes.len(), 1, "expected the wrapped text shape");
let (text, clip) = shapes[0];
assert!(text.galley.rows.len() > 1, "text must wrap into multiple lines");
assert!(cell.height() < text.galley.rows[0].rect().height(), "test setup must produce a row shorter than one line");
assert!(clip.top() >= cell.top() - 0.5 && clip.bottom() <= cell.bottom() + 0.5, "text clip must remain inside its cell");
assert!(clip.height() <= 0.5, "no partial line should be visible: clip={clip:?}");
}
/// Issue #2752: scrolling through a wrapped cell may clip at
/// the viewport edge, but must not change its row-bottom limit.
#[test]
fn wrapped_text_clipping_survives_partial_vertical_scroll() {
const TEXT: &str = "Wrapped viewport regression text with enough words \
to occupy many lines in a narrow spreadsheet column.";
let mut h = harness(|s| {
s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 12})).unwrap();
s.execute("cell.set", json!({"cell": "B3", "input": TEXT})).unwrap();
s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap();
s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap();
s.execute("home.rowHeight", json!({"rows": "3:3", "height": 76})).unwrap();
});
let original_cell = cell_rect(&h, "B3");
let original_clip = text_clips(&h, TEXT);
assert_eq!(original_clip.len(), 1);
{
let app = h.state_mut();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let scroll = sheet.row_top(2) as f32 + 8.0;
app.view_mut().unwrap().scroll.y = scroll;
}
h.run_steps(4);
let cell = cell_rect(&h, "B3");
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 viewport = geo.cells;
let shapes: Vec<_> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::Text(t) if t.galley.job.text == TEXT => Some((t, shape.clip_rect)),
_ => None,
})
.collect();
assert_eq!(shapes.len(), 1, "B3 must remain visible after scrolling");
let (text, clip) = shapes[0];
assert!(clip.top() > cell.top() + 1.0, "test must scroll through B3: clip={clip:?}, cell={cell:?}");
assert!(clip.top() >= viewport.top() - 0.5, "text must not paint above viewport: {clip:?} vs {viewport:?}");
assert!(clip.bottom() <= cell.bottom() + 0.5, "text must stay within B3: {clip:?} vs {cell:?}");
let full_line_bottoms: Vec<f32> =
text.galley.rows.iter().map(|row| text.pos.y + row.rect().bottom()).filter(|bottom| *bottom <= cell.bottom() + 0.01).collect();
let expected_bottom = full_line_bottoms.last().copied().expect("at least one wrapped line must fit B3");
assert!(
(clip.bottom() - expected_bottom).abs() <= 0.5,
"scrolling must preserve complete-line clipping: \
clip={clip:?}, expected_bottom={expected_bottom:.2}"
);
assert!(original_cell.height() > 0.0 && original_clip[0].height() > 0.0, "unscrolled B3 must display text");
}