This commit is contained in:
Brandon Thomas
2026-10-30 15:42:31 -04:00
2 changed files with 320 additions and 4 deletions
+37 -3
View File
@@ -5,8 +5,8 @@ use super::cell_paint::{
use super::geo::Geo;
use super::tables::{filter_button_rect, paint_filter_button};
use super::{
CellRef, CfCache, CfLook, Color32, FontId, HAlign, MAX_COLS, Painter, Pos2, RangeRef, Rect, Sheet, Stroke, StrokeKind, Tokens, Underline, Value,
Workbook, pos2, theme, vec2,
CellRef, CfCache, CfLook, Color32, FontId, HAlign, MAX_COLS, Painter, Pos2, RangeRef, Rect, Sheet, Stroke, StrokeKind, Tokens, Underline, VAlign,
Value, Workbook, pos2, theme, vec2,
};
/// Mirror what the grid painted into `layer` (the shape indices in `range`) for a right-to-left
@@ -68,6 +68,30 @@ fn mirror_shape(shape: &mut egui::Shape, axis: f32) {
}
}
/// Restrict top-aligned wrapped text to complete lines inside the cell.
///
/// Preserve the cell and viewport clip when every line fits. Otherwise,
/// exclude overflowing lines while retaining the visible fitting lines.
fn wrapped_text_clip(p: &Painter, rect: Rect, galley: &egui::Galley, y: f32, valign: VAlign) -> Rect {
let mut clip = rect.intersect(p.clip_rect());
if valign != VAlign::Top {
return clip;
}
let Some(last_row) = galley.rows.last() else {
return clip;
};
if y + last_row.rect().bottom() <= rect.bottom() + 0.01 {
return clip;
}
let last_full_bottom = galley.rows.iter().map(|row| y + row.rect().bottom()).rfind(|bottom| *bottom <= rect.bottom() + 0.01);
clip.max.y = last_full_bottom.map(|bottom| clip.bottom().min(bottom)).unwrap_or(clip.top()).max(clip.top());
clip
}
pub(crate) struct QuadrantPaint<'a> {
pub(crate) painter: &'a Painter,
pub(crate) geo: &'a Geo,
@@ -80,6 +104,11 @@ pub(crate) struct QuadrantPaint<'a> {
pub(crate) tokens: &'a Tokens,
}
/// Paint a worksheet quadrant, including backgrounds, borders, and text.
///
/// For top-aligned wrapped text, painting is limited to complete lines
/// fitting within the cell height while respecting viewport clipping.
/// Other vertical alignment modes retain their existing behavior.
pub(crate) fn paint_quadrant(mut quadrant: QuadrantPaint<'_>) {
let (r0, r1) = quadrant.rows;
let (c0, c1) = quadrant.cols;
@@ -288,6 +317,10 @@ fn should_expand_text(filter_space: f32, overflows: bool, across_end: u32, col:
filter_space == 0.0 && (overflows || across_end > col) && !in_merge
}
/// Paint one worksheet cell using its computed appearance and visible geometry.
///
/// Handles text layout, wrapping, alignment, overflow into neighbouring cells,
/// and clipping, while preserving cell decoration and merged-cell constraints.
fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLook>), merges: &[RangeRef]) {
let (cell, rect, lk, cfl) = painted;
let c = *cell;
@@ -363,7 +396,8 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
_ => 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 text_clip = wrapped_text_clip(p, *rect, &galley, y, st.align.v);
p.with_clip_rect(text_clip).galley(pos2(x, y), galley, color);
return;
}
// Wrap Text off: Excel ignores the line breaks and draws the whole string on one line.
+283 -1
View File
@@ -12,11 +12,15 @@ const LEFT: &str = "This is a very long left-aligned text that overflows into ma
const RIGHT: &str = "This is a very long right-aligned text that overflows into many empty neighbouring cells";
fn harness(setup: impl FnOnce(&mut Session)) -> Harness {
harness_with_size(egui::vec2(1200.0, 800.0), setup)
}
fn harness_with_size(size: egui::Vec2, setup: impl FnOnce(&mut Session)) -> Harness {
let mut s = Session::new();
s.new_workbook();
setup(&mut s);
s.execute("selection.set", json!({"range": "A20"})).unwrap();
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
let mut h = crate::common::builder().with_size(size).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
@@ -108,3 +112,281 @@ 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:?}");
}
/// Fully fitting wrapped lines retain the whole cell clip, including
/// room for glyphs extending below their logical line boxes.
#[test]
fn wrapped_text_fitting_lines_keep_full_cell_clip() {
const TEXT: &str = "Short wrapped text with two or three lines.";
let h = harness(|s| {
s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 15})).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": 120})).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 wrapped text shape");
let (text, clip) = shapes[0];
assert!(text.galley.rows.len() >= 2, "text must wrap");
assert!(text.galley.size().y < cell.height(), "all lines must fit");
let last_bottom = text.pos.y + text.galley.rows.last().unwrap().rect().bottom();
assert!(last_bottom < cell.bottom() - 1.0, "room must remain below the final line");
assert!((clip.bottom() - cell.bottom()).abs() <= 0.5, "full cell clip must be retained: clip={clip:?}, cell={cell:?}");
}
/// 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: the viewport bottom can cut through a line
/// that otherwise fits fully inside a wrapped cell.
#[test]
fn wrapped_text_clipping_survives_partial_vertical_scroll() {
let text = "Wrapped text must stay within both the worksheet cell \
and the viewport during vertical scrolling. "
.repeat(40);
let mut h = harness_with_size(egui::vec2(1200.0, 520.0), |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": 350})).unwrap();
});
let initial_cell = cell_rect(&h, "B3");
let (target_center, zoom) = {
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 shapes: Vec<_> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::Text(t) if t.galley.job.text == text => Some(t),
_ => None,
})
.collect();
assert_eq!(shapes.len(), 1, "expected wrapped B3 text");
let painted = shapes[0];
let center = painted
.galley
.rows
.iter()
.map(|row| {
let top = painted.pos.y + row.rect().top();
let bottom = painted.pos.y + row.rect().bottom();
(top, bottom)
})
.find(|(top, bottom)| {
let center = (top + bottom) / 2.0;
center > geo.cells.bottom() + 32.0 && *bottom < initial_cell.bottom() - 1.0
})
.map(|(top, bottom)| (top + bottom) / 2.0)
.expect("fixture must provide a cell-fitting line below the viewport");
(center, geo.z)
};
let viewport_bottom_before_scroll = {
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);
geo.cells.bottom()
};
{
let app = h.state_mut();
app.view_mut().unwrap().scroll.y = (target_center - viewport_bottom_before_scroll) / zoom;
}
h.run_steps(4);
let cell = cell_rect(&h, "B3");
let (viewport, scroll_zoom) = {
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);
(geo.cells, geo.z)
};
assert!(scroll_zoom > 0.0);
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 painted");
let (painted, clip) = shapes[0];
assert!(clip.top() >= viewport.top() - 0.5, "clip exceeds viewport top: {clip:?} vs {viewport:?}");
assert!(clip.bottom() <= viewport.bottom() + 0.5, "clip exceeds viewport bottom: {clip:?} vs {viewport:?}");
assert!(clip.bottom() <= cell.bottom() + 0.5, "clip exceeds cell bottom: {clip:?} vs {cell:?}");
let full_line_bottoms: Vec<f32> =
painted.galley.rows.iter().map(|row| painted.pos.y + row.rect().bottom()).filter(|bottom| *bottom <= cell.bottom() + 0.01).collect();
let cell_line_bottom = full_line_bottoms.last().copied().expect("at least one line must fit within B3");
assert!(
painted.galley.rows.iter().any(|row| {
let top = painted.pos.y + row.rect().top();
let bottom = painted.pos.y + row.rect().bottom();
top + 0.5 < viewport.bottom() && viewport.bottom() < bottom - 0.5 && bottom <= cell.bottom() + 0.01
}),
"viewport bottom must cross a line that fits B3: \
viewport={viewport:?}, cell={cell:?}"
);
let expected_bottom = viewport.bottom().min(cell_line_bottom);
assert!(
(clip.bottom() - expected_bottom).abs() <= 0.5,
"clip must respect viewport and complete-line limits: \
clip={clip:?}, expected={expected_bottom:.2}"
);
}