Grid: merged ranges draw their right and bottom borders from the edge cells (#376)
Excel stores a merged range's outline on the cells along each edge; the grid only used the anchor cell's borders, so a boxed merge showed only top and left. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ce0483fc17
commit
7a4aaebab0
|
|
@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Merged cells draw their whole outline: right and bottom edges come from the last column's and last row's cells (#376) |
|
||||
| 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) |
|
||||
| 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) |
|
||||
| 2026-10-11 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) |
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use std::sync::Arc;
|
|||
use egui::{Align2, Color32, CursorIcon, FontId, Painter, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use gridcraft_engine::cf::{CfCache, CfLook};
|
||||
use gridcraft_engine::core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters};
|
||||
use gridcraft_engine::model::{BorderStyle, Color, HAlign, LineIndex, PatternType, Sheet, Style, Underline, VAlign, Workbook};
|
||||
use gridcraft_engine::model::{BorderLine, BorderStyle, Color, HAlign, LineIndex, PatternType, Sheet, Style, Underline, VAlign, Workbook};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SheetApp;
|
||||
|
|
@ -1037,12 +1037,19 @@ pub(crate) fn paint_quadrant(
|
|||
// Borders (over fills and text).
|
||||
for (c, rect, lk, _) in &looks {
|
||||
let b = &lk.style.borders;
|
||||
let edges = [
|
||||
(b.top, [rect.left_top(), rect.right_top()]),
|
||||
(b.bottom, [rect.left_bottom(), rect.right_bottom()]),
|
||||
(b.left, [rect.left_top(), rect.left_bottom()]),
|
||||
(b.right, [rect.right_top(), rect.right_bottom()]),
|
||||
];
|
||||
let merge = merges.iter().find(|m| m.start == *c);
|
||||
let edges = match merge {
|
||||
// A merged area's outline lives on the cells along each of its edges (Excel writes the
|
||||
// right edge on the last column's cells and the bottom edge on the last row's), so each
|
||||
// edge is drawn cell by cell from the cell it belongs to.
|
||||
Some(m) => merged_edges(wb, si, sh, geo, *m, *rect, (r0, r1), (c0, c1), cf),
|
||||
None => vec![
|
||||
(b.top, [rect.left_top(), rect.right_top()]),
|
||||
(b.bottom, [rect.left_bottom(), rect.right_bottom()]),
|
||||
(b.left, [rect.left_top(), rect.left_bottom()]),
|
||||
(b.right, [rect.right_top(), rect.right_bottom()]),
|
||||
],
|
||||
};
|
||||
for (line, pts) in edges {
|
||||
if line.is_none() {
|
||||
continue;
|
||||
|
|
@ -1113,6 +1120,47 @@ pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect {
|
|||
Rect::from_min_size(pos2(rect.right() - s - 2.0, rect.bottom() - s - 2.0), vec2(s, s))
|
||||
}
|
||||
|
||||
/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the
|
||||
/// first row's and first column's cells, the bottom and right edges from the last row's and last
|
||||
/// column's cells, one segment per visible cell (limited to the visible `rows` × `cols`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn merged_edges(
|
||||
wb: &Workbook,
|
||||
si: usize,
|
||||
sh: &Sheet,
|
||||
geo: &Geo,
|
||||
m: RangeRef,
|
||||
rect: Rect,
|
||||
rows: (u32, u32),
|
||||
cols: (u32, u32),
|
||||
cf: &mut CfCache,
|
||||
) -> Vec<(BorderLine, [Pos2; 2])> {
|
||||
let mut out = Vec::new();
|
||||
let (cs, ce) = (m.start.col.max(cols.0), m.end.col.min(cols.1));
|
||||
for col in cs..=ce {
|
||||
if sh.col_width(col) == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1)));
|
||||
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())]));
|
||||
out.push((bottom, [pos2(x0, rect.bottom()), pos2(x1, rect.bottom())]));
|
||||
}
|
||||
let (rs, re) = (m.start.row.max(rows.0), m.end.row.min(rows.1));
|
||||
for row in rs..=re {
|
||||
if sh.row_height(row) == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1)));
|
||||
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)]));
|
||||
out.push((right, [pos2(rect.right(), y0), pos2(rect.right(), y1)]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) {
|
||||
let w = style.width();
|
||||
let (a, b) = (pts[0], pts[1]);
|
||||
|
|
|
|||
|
|
@ -127,3 +127,36 @@ fn border_preview_uses_the_colored_draft_until_ok_and_cancel_discards_it() {
|
|||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(borders(&h, "A1"), original);
|
||||
}
|
||||
|
||||
/// #376: a merged range's outline lives on the cells along each edge (the right edge on the last
|
||||
/// column's cells, the bottom on the last row's), not on the anchor alone. All four sides are drawn.
|
||||
#[test]
|
||||
fn merged_range_draws_the_outline_from_its_edge_cells() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("home.borders", json!({"range": "B2:E5", "preset": "thickOutside", "color": "#C81E1E"})).unwrap();
|
||||
h.state_mut().run("home.mergeCells", json!({"range": "B2:E5"})).unwrap();
|
||||
h.state_mut().run("selection.set", json!({"range": "H10"})).unwrap();
|
||||
h.run_steps(6);
|
||||
assert!(borders(&h, "B2").right.is_none(), "the anchor carries only the top and left edges");
|
||||
let red = Color32::from_rgb(0xC8, 0x1E, 0x1E);
|
||||
let segs: Vec<[egui::Pos2; 2]> = h
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
Shape::LineSegment { points, stroke } if stroke.color == red => Some(*points),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let vertical = |s: &[egui::Pos2; 2]| (s[0].x - s[1].x).abs() < 0.5;
|
||||
let xs: Vec<f32> = segs.iter().filter(|s| vertical(s)).map(|s| s[0].x).collect();
|
||||
let ys: Vec<f32> = segs.iter().filter(|s| !vertical(s)).map(|s| s[0].y).collect();
|
||||
let (min_x, max_x) = xs.iter().fold((f32::MAX, f32::MIN), |(a, b), x| (a.min(*x), b.max(*x)));
|
||||
let (min_y, max_y) = ys.iter().fold((f32::MAX, f32::MIN), |(a, b), y| (a.min(*y), b.max(*y)));
|
||||
// Left and right edges (four rows each), top and bottom edges (four columns each).
|
||||
assert_eq!(xs.iter().filter(|x| (**x - min_x).abs() < 0.5).count(), 4, "left edge");
|
||||
assert_eq!(xs.iter().filter(|x| (**x - max_x).abs() < 0.5).count(), 4, "right edge");
|
||||
assert_eq!(ys.iter().filter(|y| (**y - min_y).abs() < 0.5).count(), 4, "top edge");
|
||||
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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue