From 84ce99f9c79a2a84138e08157cf0a55f824ff230 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:08:08 +0200 Subject: [PATCH] Keep overflow text visible when its source cell scrolls out of view Fixes #1613 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 23 +++- crates/ui-egui/tests/text_overflow_scroll.rs | 110 +++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/text_overflow_scroll.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 00e0c18be..d3cadce42 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1035,7 +1035,9 @@ pub(crate) fn paint_quadrant( let (r0, r1) = rows; let (c0, c1) = cols; let range = RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)); - let merges: Vec = sh.merges.iter().filter(|m| m.intersects(&range)).copied().collect(); + // Merges are looked up 20 columns beyond the quadrant too: text overflowing in from off-screen cells stops at them. + let near = RangeRef::new(CellRef::new(r0, c0.saturating_sub(20)), CellRef::new(r1, c1.saturating_add(20).min(MAX_COLS - 1))); + let merges: Vec = sh.merges.iter().filter(|m| m.intersects(&near)).copied().collect(); let in_merge = |c: CellRef| merges.iter().find(|m| m.contains(c)).copied(); // Gridlines. if sh.show_gridlines { @@ -1089,8 +1091,25 @@ pub(crate) fn paint_quadrant( } looks.push((anchor, rect, lk, cfl)); } + // Text cells just outside the quadrant whose text may overflow into it: the nearest non-empty + // cell left of `c0` and right of `c1` in each row (within the same frozen / scrolled pane). + let mut outside: Vec<(CellRef, Rect, Look, Option)> = Vec::new(); + let same_pane = |col: u32| (col < geo.fc) == (c0 < geo.fc); + for r in r0..=r1 { + if sh.row_height(r) == 0.0 { + continue; + } + let left = (c0.saturating_sub(20)..c0).rev().map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); + let right = (c1.saturating_add(1)..=c1.saturating_add(20).min(MAX_COLS - 1)).map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); + for n in [left, right].into_iter().flatten() { + if same_pane(n.col) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() { + let (lk, cfl) = look(wb, si, sh, n, cf); + outside.push((n, geo.cell_rect(sh, n), lk, cfl)); + } + } + } // Text and per-cell decorations. - for (c, rect, lk, cfl) in &looks { + for (c, rect, lk, cfl) in looks.iter().chain(&outside) { let c = *c; if let Some(picture) = sh.cell_pictures.get(&c) { crate::cell_pictures::paint(p, *rect, picture); diff --git a/crates/ui-egui/tests/text_overflow_scroll.rs b/crates/ui-egui/tests/text_overflow_scroll.rs new file mode 100644 index 000000000..bb7c13bf1 --- /dev/null +++ b/crates/ui-egui/tests/text_overflow_scroll.rs @@ -0,0 +1,110 @@ +//! Overflowing text stays visible in its empty neighbours when its own cell is scrolled out (#1613). + +use egui::{Rect, Shape}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const LEFT: &str = "This is a very long left-aligned text that overflows into many empty neighbouring cells"; +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 { + let mut s = Session::new(); + s.new_workbook(); + setup(&mut s); + s.execute("selection.set", json!({"range": "A20"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +/// Scroll so that `col` is the first column of the scrolled area. +fn scroll_to_col(h: &mut Harness, col: u32) { + let app = h.state_mut(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let fc = sheet.freeze.map(|f| f.1).unwrap_or(0); + let x = (sheet.col_left(col) - sheet.col_left(fc)) as f32; + app.view_mut().unwrap().scroll.x = x; + h.run_steps(4); +} + +/// Clip rects of the painted text shapes whose text is `text`. +fn text_clips(h: &Harness, text: &str) -> Vec { + h.output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == text => Some(s.clip_rect), + _ => None, + }) + .collect() +} + +fn cell_rect(h: &Harness, cell: &str) -> Rect { + 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.cell_rect(sheet, CellRef::parse(cell).unwrap()) +} + +#[test] +fn overflow_text_stays_visible_when_source_cell_scrolls_out_left() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "B1", "input": LEFT})).unwrap(); + }); + assert!(!text_clips(&h, LEFT).is_empty(), "B1 is drawn before scrolling"); + scroll_to_col(&mut h, 2); + let c1 = cell_rect(&h, "C1"); + let clips = text_clips(&h, LEFT); + assert!(clips.iter().any(|r| r.intersects(c1.shrink(1.0))), "B1's overflow is still drawn into C1 after scrolling: {clips:?}"); +} + +#[test] +fn right_aligned_overflow_stays_visible_when_source_cell_is_right_of_view() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "Z1", "input": RIGHT})).unwrap(); + s.execute("home.formatCells", json!({"range": "Z1", "style": {"align": {"h": "Right"}}})).unwrap(); + }); + // Scroll so that Z1 starts just past the right edge of the cell area. + { + let app = h.state_mut(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let x = sheet.col_left(25) as f32 - (geo.cells.width() - geo.frozen_w) / geo.z - 2.0; + app.view_mut().unwrap().scroll.x = x; + } + h.run_steps(4); + let (y1, z1, cells) = { + 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.cell_rect(sheet, CellRef::parse("Y1").unwrap()), geo.cell_rect(sheet, CellRef::parse("Z1").unwrap()), geo.cells) + }; + assert!(z1.left() >= cells.right(), "Z1 is out of view on the right ({z1:?} vs {cells:?})"); + let visible = y1.intersect(cells).shrink(1.0); + let clips = text_clips(&h, RIGHT); + assert!(clips.iter().any(|r| r.intersects(visible)), "Z1's overflow is drawn into Y1: {clips:?} vs {visible:?}"); +} + +#[test] +fn scrolled_overflow_does_not_draw_over_frozen_columns() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "C1", "input": LEFT})).unwrap(); + s.execute("view.freezePanes", json!({"cell": "B2"})).unwrap(); + }); + scroll_to_col(&mut h, 4); + let a1 = cell_rect(&h, "A1"); + let e1 = cell_rect(&h, "E1"); + let clips = text_clips(&h, LEFT); + 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:?}"); +}