diff --git a/crates/ui-egui/src/grid/cell_paint.rs b/crates/ui-egui/src/grid/cell_paint.rs index 9c6a0b10b..187790e6c 100644 --- a/crates/ui-egui/src/grid/cell_paint.rs +++ b/crates/ui-egui/src/grid/cell_paint.rs @@ -194,7 +194,7 @@ pub(super) fn merged_edges(wb: &Workbook, si: usize, sh: &Sheet, geo: &Geo, area if sh.col_width(col) == 0.0 { continue; } - let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1))); + let (x0, x1) = (geo.x(sh, col), geo.x_end(sh, col)); let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf).0.style.borders.top; let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf).0.style.borders.bottom; out.push((top, [pos2(x0, rect.top()), pos2(x1, rect.top())])); @@ -205,7 +205,7 @@ pub(super) fn merged_edges(wb: &Workbook, si: usize, sh: &Sheet, geo: &Geo, area if sh.row_height(row) == 0.0 { continue; } - let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1))); + let (y0, y1) = (geo.y(sh, row), geo.y_end(sh, row)); let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf).0.style.borders.left; let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf).0.style.borders.right; out.push((left, [pos2(rect.left(), y0), pos2(rect.left(), y1)])); diff --git a/crates/ui-egui/src/grid/geo.rs b/crates/ui-egui/src/grid/geo.rs index 33b1b5461..a1005b32f 100644 --- a/crates/ui-egui/src/grid/geo.rs +++ b/crates/ui-egui/src/grid/geo.rs @@ -158,11 +158,21 @@ impl Geo { self.cells.top() + self.frozen_h + (self.ri.start(row) as f32 - self.ri.start(self.fr) as f32 - self.scroll.y) * self.z } } + /// Screen x of `col`'s right edge. Not `x(col + 1)`: across the freeze line (and between + /// pages) the next column is laid out elsewhere (#2544). + pub fn x_end(&self, sh: &Sheet, col: u32) -> f32 { + self.x(sh, col) + sh.col_width(col) * self.z + } + /// Screen y of `row`'s bottom edge (see `x_end`). + pub fn y_end(&self, sh: &Sheet, row: u32) -> f32 { + self.y(sh, row) + sh.row_height(row) * self.z + } + /// Columns `a` and `b` are on the same side of the freeze line, so they scroll together. + pub fn same_col_pane(&self, a: u32, b: u32) -> bool { + (a < self.fc) == (b < self.fc) + } pub fn cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect { - Rect::from_min_max( - pos2(self.x(sh, c.col), self.y(sh, c.row)), - pos2(self.x(sh, c.col) + sh.col_width(c.col) * self.z, self.y(sh, c.row) + sh.row_height(c.row) * self.z), - ) + Rect::from_min_max(pos2(self.x(sh, c.col), self.y(sh, c.row)), pos2(self.x_end(sh, c.col), self.y_end(sh, c.row))) } pub fn range_rect(&self, sh: &Sheet, r: RangeRef) -> Rect { let a = self.cell_rect(sh, r.start); diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index b9dded075..c50e352f9 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -902,7 +902,7 @@ fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 { return 0; } let c = geo.col_at(sh, p.x); - let (l, r) = (geo.x(sh, c), geo.x(sh, c.saturating_add(1).min(MAX_COLS - 1))); + let (l, r) = (geo.x(sh, c), geo.x_end(sh, c)); let b = if p.x - l > r - p.x { c.saturating_add(1) } else { c }; let room = geo.ci.at((((geo.cells.width() - 40.0) / geo.z).max(0.0)) as f64); b.min(room).min(MAX_COLS - 1) @@ -911,7 +911,7 @@ fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 { return 0; } let rr = geo.row_at(sh, p.y); - let (t, b2) = (geo.y(sh, rr), geo.y(sh, rr.saturating_add(1).min(MAX_ROWS - 1))); + let (t, b2) = (geo.y(sh, rr), geo.y_end(sh, rr)); let b = if p.y - t > b2 - p.y { rr.saturating_add(1) } else { rr }; let room = geo.ri.at((((geo.cells.height() - 40.0) / geo.z).max(0.0)) as f64); b.min(room).min(MAX_ROWS - 1) diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs index 554bdcfdd..9c5712da7 100644 --- a/crates/ui-egui/src/grid/quadrant.rs +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -142,21 +142,17 @@ fn paint_gridlines(q: &QuadrantPaint<'_>) { if sh.show_gridlines { let stroke = Stroke::new(1.0, t.gridline); let top = geo.y(sh, r0); - let bottom = geo.y(sh, r1 + 1); + let bottom = geo.y_end(sh, r1); let left = geo.x(sh, c0); - let right = geo.x(sh, c1 + 1); - for c in c0..=c1 + 1 { - if c <= c1 && sh.col_width(c) == 0.0 { - continue; - } - let x = geo.x(sh, c).round() - 0.5; + let right = geo.x_end(sh, c1); + let xs = (c0..=c1).filter(|&c| sh.col_width(c) > 0.0).map(|c| geo.x(sh, c)).chain([right]); + for x in xs { + let x = x.round() - 0.5; p.line_segment([pos2(x, top), pos2(x, bottom)], stroke); } - for r in r0..=r1 + 1 { - if r <= r1 && sh.row_height(r) == 0.0 { - continue; - } - let y = geo.y(sh, r).round() - 0.5; + let ys = (r0..=r1).filter(|&r| sh.row_height(r) > 0.0).map(|r| geo.y(sh, r)).chain([bottom]); + for y in ys { + let y = y.round() - 0.5; p.line_segment([pos2(left, y), pos2(right, y)], stroke); } } @@ -224,7 +220,6 @@ fn outside_text_cells(q: &mut QuadrantPaint<'_>, merges: &[RangeRef]) -> Vec<(Ce // 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; @@ -232,7 +227,7 @@ fn outside_text_cells(q: &mut QuadrantPaint<'_>, merges: &[RangeRef]) -> Vec<(Ce 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() { + if geo.same_col_pane(n.col, c0) && 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)); } @@ -333,7 +328,9 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option, painted: &(CellRef, Rect, Look, Option c.col { - right = geo.x(sh, across_end + 1); + right = geo.x_end(sh, across_end); } if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) { let mut cc = c.col + 1; while right - rect.left() < w + 2.0 * pad && cc < MAX_COLS && empty_ok(CellRef::new(c.row, cc)) && cc <= c1 + 20 { - right = geo.x(sh, cc + 1); + right = geo.x_end(sh, cc); cc += 1; } } diff --git a/crates/ui-egui/tests/it/borders.rs b/crates/ui-egui/tests/it/borders.rs index a9f203012..c0213ed92 100644 --- a/crates/ui-egui/tests/it/borders.rs +++ b/crates/ui-egui/tests/it/borders.rs @@ -160,3 +160,40 @@ fn merged_range_draws_the_outline_from_its_edge_cells() { assert_eq!(ys.iter().filter(|y| (**y - max_y).abs() < 0.5).count(), 4, "bottom edge"); assert!(max_x - min_x > 100.0 && max_y - min_y > 40.0, "the outline spans the whole merged area"); } + +/// #2544: a merged range in frozen columns keeps its whole outline while the others scroll: the +/// bottom edge under its last column ends at that column's own right edge, the freeze line. +#[test] +fn frozen_merged_range_keeps_its_outline_when_scrolled() { + let mut h = harness(); + h.state_mut().run("home.borders", json!({"range": "A2:B3", "preset": "thickOutside", "color": "#C81E1E"})).unwrap(); + h.state_mut().run("home.mergeCells", json!({"range": "A2:B3"})).unwrap(); + h.state_mut().run("view.freezePanes", json!({"cell": "C1"})).unwrap(); + h.state_mut().run("selection.set", json!({"range": "H10"})).unwrap(); + h.state_mut().view_mut().unwrap().scroll.x = 300.0; + h.run_steps(6); + let freeze_x = { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = gridcraft_ui_egui::grid::Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cells.left() + geo.frozen_w + }; + let red = Color32::from_rgb(0xC8, 0x1E, 0x1E); + let right_ends: Vec = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::LineSegment { points, stroke } if stroke.color == red && (points[0].y - points[1].y).abs() < 0.5 => { + Some(points[0].x.max(points[1].x)) + } + _ => None, + }) + .collect(); + assert_eq!(right_ends.len(), 4, "top and bottom edges, one segment per column: {right_ends:?}"); + assert_eq!( + right_ends.iter().filter(|x| (**x - freeze_x).abs() < 1.0).count(), + 2, + "B's segments end at the freeze line: {right_ends:?} vs {freeze_x}" + ); +} diff --git a/crates/ui-egui/tests/it/freeze_handles.rs b/crates/ui-egui/tests/it/freeze_handles.rs index 7bcc81d1a..141d0b9ef 100644 --- a/crates/ui-egui/tests/it/freeze_handles.rs +++ b/crates/ui-egui/tests/it/freeze_handles.rs @@ -66,3 +66,36 @@ fn dragging_the_handles_freezes_moves_and_unfreezes() { drag(&mut h, rows.center(), pos2(rows.center().x, g.cells.top() - 5.0)); assert_eq!(freeze(&h), None); } + +/// The last frozen column ends at the freeze line however far the other columns scroll, while +/// the next column's left edge moves with the scroll (#2544). +#[test] +fn last_frozen_column_ends_at_the_freeze_line_when_scrolled() { + let mut h = harness(); + h.state_mut().run("view.freezePanes", serde_json::json!({"cell": "C3"})).unwrap(); + h.state_mut().view_mut().unwrap().scroll = egui::vec2(300.0, 200.0); + h.run_steps(2); + let g = geo(&h); + let sh = h.state().session.active().unwrap().wb.active().unwrap().clone(); + let split = g.cells.min + egui::vec2(g.frozen_w, g.frozen_h); + assert!((g.x_end(&sh, 1) - split.x).abs() < 0.01 && (g.y_end(&sh, 1) - split.y).abs() < 0.01); + assert!(g.x(&sh, 2) < split.x && g.y(&sh, 2) < split.y, "C and row 3 are scrolled under the frozen panes"); + assert!(g.same_col_pane(0, 1) && g.same_col_pane(2, 9) && !g.same_col_pane(1, 2)); +} + +/// Dropping the column handle on the left part of the last frozen column while scrolled freezes +/// up to its left edge: the column's right edge is the freeze line, not the next column's +/// scrolled position (#2544). +#[test] +fn dragging_the_column_handle_when_scrolled_measures_the_last_frozen_column() { + let mut h = harness(); + h.state_mut().run("view.freezePanes", serde_json::json!({"cell": "C1"})).unwrap(); + h.state_mut().view_mut().unwrap().scroll = egui::vec2(300.0, 0.0); + h.run_steps(2); + let g = geo(&h); + let sh = h.state().session.active().unwrap().wb.active().unwrap().clone(); + let (cols, _) = freeze_handles(&g); + assert!(g.x(&sh, 2) < g.x(&sh, 1), "C is scrolled left of B"); + drag(&mut h, cols.center(), pos2(g.x(&sh, 1) + 10.0, g.cells.top() + 40.0)); + assert_eq!(freeze(&h), Some((0, 1))); +} diff --git a/crates/ui-egui/tests/it/text_overflow_scroll.rs b/crates/ui-egui/tests/it/text_overflow_scroll.rs index 8756666db..371c0aeee 100644 --- a/crates/ui-egui/tests/it/text_overflow_scroll.rs +++ b/crates/ui-egui/tests/it/text_overflow_scroll.rs @@ -53,6 +53,18 @@ fn text_clips(h: &Harness, text: &str) -> Vec { .collect() } +/// Painted extents of the text shapes whose text is `text`, cut to their clip rects. +fn text_visible_rects(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(t.visual_bounding_rect().intersect(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(); @@ -390,3 +402,71 @@ fn wrapped_text_clipping_survives_partial_vertical_scroll() { clip={clip:?}, expected={expected_bottom:.2}" ); } + +/// Issue #2544: text in a frozen column keeps its whole cell while the other columns scroll. +#[test] +fn frozen_column_text_keeps_its_cell_when_scrolled() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "A1", "input": LEFT})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "v"})).unwrap(); + s.execute("view.freezeFirstColumn", json!({})).unwrap(); + }); + for col in [1, 2, 3, 8] { + scroll_to_col(&mut h, col); + let a1 = cell_rect(&h, "A1"); + let clips = text_clips(&h, LEFT); + assert_eq!(clips.len(), 1, "A1 is drawn once: {clips:?}"); + assert!( + (clips[0].left() - a1.left()).abs() <= 0.5 && (clips[0].right() - a1.right()).abs() <= 0.5, + "A1's text spans A1 at column {col}: {clips:?} vs {a1:?}" + ); + let visible = text_visible_rects(&h, LEFT); + assert!(visible.len() == 1 && visible[0].is_positive(), "A1's text shows inside its clip at column {col}: {visible:?}"); + } +} + +/// Issue #2544: with two frozen columns, A1's text keeps overflowing across the whole of empty B1. +#[test] +fn frozen_text_overflows_to_the_freeze_line_when_scrolled() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "A1", "input": LEFT})).unwrap(); + s.execute("view.freezePanes", json!({"cell": "C1"})).unwrap(); + }); + for col in [2, 3, 4, 10] { + scroll_to_col(&mut h, col); + let (a1, b1) = (cell_rect(&h, "A1"), cell_rect(&h, "B1")); + let clips = text_clips(&h, LEFT); + assert_eq!(clips.len(), 1, "A1 is drawn once: {clips:?}"); + assert!( + (clips[0].left() - a1.left()).abs() <= 0.5 && (clips[0].right() - b1.right()).abs() <= 0.5, + "A1's text spans A1:B1 at column {col}: {clips:?} vs {a1:?} {b1:?}" + ); + } +} + +/// Issue #2544: the frozen columns' gridlines reach the freeze line, and none is drawn inside +/// them at a scrolled column's edge. +#[test] +fn frozen_column_gridlines_stay_put_when_scrolled() { + let mut h = harness(|s| { + s.execute("view.freezePanes", json!({"cell": "C1"})).unwrap(); + }); + scroll_to_col(&mut h, 6); + let (a5, b5) = (cell_rect(&h, "A5"), cell_rect(&h, "B5")); + let frozen = |s: &egui::epaint::ClippedShape| s.clip_rect.right() <= b5.right() + 0.5 && s.clip_rect.left() <= a5.left(); + let segments: Vec<[egui::Pos2; 2]> = h + .output() + .shapes + .iter() + .filter(|s| frozen(s)) + .filter_map(|s| match &s.shape { + Shape::LineSegment { points, .. } => Some(*points), + _ => None, + }) + .collect(); + let row5 = segments.iter().find(|p| (p[0].y - (a5.top() - 0.5).round()).abs() <= 1.0 && p[0].y == p[1].y); + assert!(row5.is_some_and(|p| p[1].x >= b5.right() - 1.0), "row 5's gridline reaches the freeze line: {row5:?} vs {b5:?}"); + let verticals: Vec = segments.iter().filter(|p| p[0].x == p[1].x && p[0].y < b5.bottom()).map(|p| p[0].x).collect(); + let expected = [a5.left(), a5.right(), b5.right()].map(|x| x.round() - 0.5); + assert!(verticals.iter().all(|x| expected.iter().any(|e| (x - e).abs() <= 0.5)), "only A and B's edges: {verticals:?} vs {expected:?}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 85f63d42d..92016f546 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (every Paste Special option has its access key; earlier: the Draw tab's live stroke preview uses the pen's colour and thickness; earlier: copy quotes only cells with a tab or line break, as Excel does; earlier: Ctrl+. Next Corner of Selection; earlier: dragging the selection's border onto data asks before replacing it; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (frozen columns keep their text, gridlines and merged borders whole while the sheet scrolls (#2544); earlier: every Paste Special option has its access key; earlier: the Draw tab's live stroke preview uses the pen's colour and thickness; earlier: copy quotes only cells with a tab or line break, as Excel does; earlier: Ctrl+. Next Corner of Selection; earlier: dragging the selection's border onto data asks before replacing it; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -238,6 +238,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Frozen panes: text in frozen columns overflows only into its own pane and keeps its whole width while the sheet scrolls; frozen gridlines and merged-cell borders reach the freeze line, and the freeze handle measures the last frozen column itself (#2544). Page Layout view gridlines stop at each page's last column | | 2026-11-03 | minor | Excel 2003 menu access keys: 43 legacy sequences (Insert, Edit, Format, Data, View, Tools and File ▸ Page Setup, e.g. Alt+I, R inserts rows, Alt+E, A, A clears all, Alt+D, S sorts, Alt+T, O opens Options) run the existing commands and dialogs; Alt+I, D, V and T paint no badges, as in Excel (#941, #2311) | | 2026-11-03 | minor | Paste Special: every option has Excel's access key (A, F, V, T, C, N, X, W, R, U; O, D, S, M, I; B, E; L pastes the link at once), not only F, V, T, C and E (#1482) | | 2026-11-03 | minor | Draw tab: the stroke drawn under the pointer while dragging a pen is the ink that will be committed, in the pen's colour and thickness (translucent for a highlighter), instead of always blue at 2 pt (#1786) |