Merge pull request #380 from vangel76/fix/merged-range-borders

Grid: merged ranges draw their right and bottom borders (#376)

# Conflicts:
#	ROADMAP.md
This commit is contained in:
Brandon Thomas 2026-10-11 15:43:31 -04:00
commit d90a072d90
3 changed files with 89 additions and 7 deletions

View File

@ -133,6 +133,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, so a missing or stray `dxcompiler.dll` no longer stops the app at start (#339, #354) |
| 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) |

View File

@ -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]);

View File

@ -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");
}