This commit is contained in:
Brandon Thomas committed 2026-11-03 09:55:37 -05:00
commit 502fac89fa
8 files changed
+184 -26

No files matched your search

+2 -2
View File
@@ -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)]));
+14 -4
View File
@@ -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);
+2 -2
View File
@@ -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)
+14 -17
View File
@@ -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<CfLook>)> = 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<CfLo
let z = geo.z;
let (c0, c1) = q.cols;
let in_merge = |cell| merges.iter().find(|m| m.contains(cell)).copied();
let empty_ok = |cell| sh.value_ref(cell).is_none_or(Value::is_empty) && in_merge(cell).is_none();
// Text overflows only into empty cells of its own pane: the frozen and scrolled columns move
// apart when scrolling (#2544).
let empty_ok = |cell: CellRef| geo.same_col_pane(cell.col, c.col) && sh.value_ref(cell).is_none_or(Value::is_empty) && in_merge(cell).is_none();
if paint_cell_decorations(q, c, *rect, lk, cfl.as_ref()) {
return;
}
@@ -454,12 +451,12 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
let mut right = rect.right();
let mut left = rect.left();
if across_end > 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;
}
}
+37
View File
@@ -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<f32> = 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}"
);
}
+33
View File
@@ -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)));
}
@@ -53,6 +53,18 @@ fn text_clips(h: &Harness, text: &str) -> Vec<Rect> {
.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<Rect> {
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<f32> = 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:?}");
}
+2 -1
View File
@@ -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) |