Merge storytold/gridcraft#2482: Outline: Settings puts summary rows above and summary columns left of their detail

# Conflicts:
#	docs/ui-parity.md
This commit is contained in:
aiongg
2026-10-25 02:54:23 +00:00
12 changed files with 343 additions and 26 deletions
+138 -8
View File
@@ -106,6 +106,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
show_levels
),
cmd!(
"data.outlineSettings",
"Outline Settings",
["Data", "Outline"],
None,
"{sheet?, summaryBelow?: bool, summaryRight?: bool} (summary rows below their detail, summary columns right of it; off puts them above / left) → {summaryBelow, summaryRight}",
has_doc,
outline_settings
),
cmd!(
"data.subtotal",
"Subtotal",
@@ -1329,6 +1338,7 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
let (rows, a, b) = outline_lines(s, p)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let before = outline::summary_before(sh, rows);
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let b = b.min(a.saturating_add(1_048_575));
for i in a..=b {
@@ -1339,9 +1349,10 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
}
}
// A summary line whose group is gone has nothing left to collapse.
let summary = if before { a.checked_sub(1) } else { b.checked_add(1) };
if !on
&& !outline::groups(map).iter().any(|g| g.end == b)
&& let Some(e) = map.get_mut(&b.saturating_add(1))
&& !outline::groups_with(map, before).iter().any(|g| if before { g.start == a } else { g.end == b })
&& let Some(e) = summary.and_then(|i| map.get_mut(&i))
{
e.collapsed = false;
}
@@ -1359,8 +1370,9 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
let before = outline::summary_before(sh, rows);
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let all = outline::groups(map);
let all = outline::groups_with(map, before);
let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l);
let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec<outline::OutlineGroup> {
let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect();
@@ -1369,13 +1381,13 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
};
let mut chosen = pick(&|g| g.start <= b && g.end >= a);
if chosen.is_empty() {
chosen = pick(&|g| g.summary() >= a && g.summary() <= b);
chosen = pick(&|g| g.has_summary() && g.summary() >= a && g.summary() <= b);
}
for g in chosen {
// Inner groups collapsed on their own (their summary marked), before this one changes.
let inner: Vec<outline::OutlineGroup> = all
.iter()
.filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.summary() != g.summary())
.filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.has_summary() && o.summary() != g.summary())
.filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed))
.copied()
.collect();
@@ -1384,6 +1396,9 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
set_hidden(e, hide || inner.iter().any(|o| o.contains(i)));
}
}
if !g.has_summary() {
continue;
}
if hide {
map.entry(g.summary()).or_default().collapsed = true;
} else if let Some(e) = map.get_mut(&g.summary()) {
@@ -1419,15 +1434,16 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
for (map, n) in [(&mut sh.rows, rows), (&mut sh.cols, cols)] {
let before = (outline::summary_before(sh, true), outline::summary_before(sh, false));
for (map, n, before) in [(&mut sh.rows, rows, before.0), (&mut sh.cols, cols, before.1)] {
let Some(n) = n else { continue };
let groups = outline::groups(map);
let groups = outline::groups_with(map, before);
for e in map.values_mut().filter(|e| e.outline > 0) {
set_hidden(e, e.outline >= n);
}
// Inner groups come first, so a summary line shared with the group around it ends up
// as that outer group says.
for g in groups {
for g in groups.into_iter().filter(|g| g.has_summary()) {
map.entry(g.summary()).or_default().collapsed = g.level >= n;
}
}
@@ -1436,6 +1452,53 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns
/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line.
fn outline_settings(s: &mut Session, p: &Json) -> Result<Json> {
let si = target_sheet(s, p)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (rows, key) in [(true, "summaryBelow"), (false, "summaryRight")] {
let Some(on) = bool_param(p, key) else { continue };
let was_before = outline::summary_before(sh, rows);
if rows {
sh.summary_below = on;
} else {
sh.summary_right = on;
}
if was_before != on {
continue;
}
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let mut marked: Vec<outline::OutlineGroup> = outline::groups_with(map, was_before)
.into_iter()
.filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed))
.collect();
// A mark on a line that nested groups share moves with the outermost of them whose
// detail is all hidden, or with the outermost when none is.
let mut owner: std::collections::BTreeMap<u32, (bool, u8)> = std::collections::BTreeMap::new();
for g in &marked {
let key = (!g.collapsed, g.level);
let best = owner.entry(g.summary()).or_insert(key);
*best = (*best).min(key);
}
marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level)));
for g in &marked {
if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
}
for g in marked {
let g = outline::OutlineGroup { summary_before: !on, ..g };
if g.has_summary() {
map.entry(g.summary()).or_default().collapsed = true;
}
}
}
Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right}))
})
}
fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, r) = data_target(s, p, "data.subtotal")?;
let header = bool_param(p, "header").unwrap_or(true);
@@ -1771,6 +1834,73 @@ mod tests {
assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left"));
}
#[test]
fn summary_rows_above_the_detail_take_the_collapsed_mark() {
let mut s = nested_rows();
let r = s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
assert_eq!(r, json!({"summaryBelow": false, "summaryRight": true}));
// Row 4 (index 3) is the outer group's summary, row 5 the inner one's.
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, true), (true, false), (true, false), (true, false), (true, false), (false, false)]);
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false); 6]);
// Hide Detail on the summary row collapses the group below it.
s.execute("data.hideDetail", json!({"rows": "4:4"})).unwrap();
assert_eq!(row_state(&s, 3..=4), [(false, true), (true, false)]);
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, true), (true, true), (true, false), (true, false), (true, false), (false, false)]);
// Ungrouping the outer group clears its mark on the row above.
s.execute("data.showLevels", json!({"rows": 3})).unwrap();
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
s.execute("data.ungroup", json!({"rows": "5:8"})).unwrap();
assert_eq!(row_state(&s, 3..=3), [(false, false)]);
}
#[test]
fn changing_the_summary_direction_moves_collapsed_marks() {
let mut s = nested_rows();
s.execute("data.group", json!({"cols": "C:E"})).unwrap();
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
s.execute("data.hideDetail", json!({"cols": "C:E"})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false, "summaryRight": false})).unwrap();
let col_marked = |s: &Session, c: u32| s.doc().unwrap().wb.active().unwrap().cols.get(&c).is_some_and(|l| l.collapsed);
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [true, false, false, false, false, false]);
assert!(col_marked(&s, 1) && !col_marked(&s, 5));
s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap();
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [false, false, false, false, false, true]);
assert!(col_marked(&s, 1), "columns keep their direction");
// One undo step per change; no params leaves the settings as they are.
s.execute("edit.undo", json!({})).unwrap();
assert!(row_state(&s, 3..=3)[0].1);
assert_eq!(s.execute("data.outlineSettings", json!({})).unwrap(), json!({"summaryBelow": false, "summaryRight": false}));
// Nested groups that end on one row share its mark: it moves to the outer group only.
let mut s = Session::new();
s.new_workbook();
s.execute("data.group", json!({"rows": "5:8"})).unwrap();
s.execute("data.group", json!({"rows": "6:8"})).unwrap();
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [true, false, false, false, false, false]);
s.execute("data.showDetail", json!({"rows": "4:4"})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false); 6]);
// With only the inner group collapsed, its mark goes to row 5, and the outer group's
// Hide and Show Detail keep it collapsed.
s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap();
s.execute("data.hideDetail", json!({"rows": "6:8", "level": 2})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]);
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]);
// A group on the first row has no row above it to mark.
let mut s = Session::new();
s.new_workbook();
s.execute("data.group", json!({"rows": "1:3"})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
s.execute("data.hideDetail", json!({"rows": "1:3"})).unwrap();
assert_eq!(row_state(&s, 0..=3), [(true, false), (true, false), (true, false), (false, false)]);
}
#[test]
fn column_sort_rejects_row_key_zero_without_panicking() {
let mut s = Session::new();
+45 -9
View File
@@ -1,17 +1,18 @@
//! Outline groups (Data ▸ Group) of a sheet's rows or columns: the runs of lines at or above
//! each outline level, as the grid draws them (brackets, − / + buttons, level buttons) and the
//! Show / Hide Detail commands act on them. Summary rows sit below their detail and summary
//! columns right of it (Excel's defaults; the model has no setting for the other way round).
//! columns right of it, or above / left of it when the sheet's outline settings say so
//! ([`Sheet::summary_below`], [`Sheet::summary_right`]).
use std::collections::BTreeMap;
use gridcraft_model::LineInfo;
use gridcraft_model::{LineInfo, Sheet};
/// The deepest outline level XLSX allows.
pub const MAX_LEVEL: u8 = 7;
/// One group: lines `start..=end` at outline level `level` or deeper, with its summary line
/// just after `end`.
/// just after `end`, or just before `start` when `summary_before` is set.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OutlineGroup {
pub level: u8,
@@ -19,25 +20,43 @@ pub struct OutlineGroup {
pub end: u32,
/// Every detail line is hidden: the button shows +.
pub collapsed: bool,
/// The summary line is above (rows) or left of (columns) the detail.
pub summary_before: bool,
}
impl OutlineGroup {
/// The summary line, where the − / + button sits.
pub fn summary(&self) -> u32 {
self.end.saturating_add(1)
if self.summary_before { self.start.saturating_sub(1) } else { self.end.saturating_add(1) }
}
/// Whether the summary line exists: a group that starts at the first line (summary before)
/// or ends at the last one (summary after) has none.
pub fn has_summary(&self) -> bool {
!self.contains(self.summary())
}
pub fn contains(&self, line: u32) -> bool {
line >= self.start && line <= self.end
}
}
/// Whether the sheet's summary rows (`rows`) or summary columns sit before their detail.
pub fn summary_before(sh: &Sheet, rows: bool) -> bool {
if rows { !sh.summary_below } else { !sh.summary_right }
}
/// The deepest outline level used by these lines (0 = no outline).
pub fn max_level(lines: &BTreeMap<u32, LineInfo>) -> u8 {
lines.values().map(|l| l.outline.min(MAX_LEVEL)).max().unwrap_or(0)
}
/// Every group, ordered by where it ends (inner groups before the group around them).
/// Every group, with its summary line after the detail; see [`groups_with`].
pub fn groups(lines: &BTreeMap<u32, LineInfo>) -> Vec<OutlineGroup> {
groups_with(lines, false)
}
/// Every group, ordered by where it ends (inner groups before the group around them), with its
/// summary line before the detail when `summary_before` is set.
pub fn groups_with(lines: &BTreeMap<u32, LineInfo>, summary_before: bool) -> Vec<OutlineGroup> {
let mut out = Vec::new();
// Open group per level (index level - 1): start line and whether all its lines are hidden.
let mut open: [Option<(u32, bool)>; MAX_LEVEL as usize] = [None; MAX_LEVEL as usize];
@@ -47,7 +66,7 @@ pub fn groups(lines: &BTreeMap<u32, LineInfo>) -> Vec<OutlineGroup> {
if let Some(slot) = open.get_mut(level as usize - 1)
&& let Some((start, hidden)) = slot.take()
{
out.push(OutlineGroup { level, start, end, collapsed: hidden });
out.push(OutlineGroup { level, start, end, collapsed: hidden, summary_before });
}
}
};
@@ -102,9 +121,9 @@ mod tests {
assert_eq!(
g,
vec![
OutlineGroup { level: 2, start: 5, end: 6, collapsed: false },
OutlineGroup { level: 1, start: 4, end: 7, collapsed: false },
OutlineGroup { level: 1, start: 11, end: 12, collapsed: true },
OutlineGroup { level: 2, start: 5, end: 6, collapsed: false, summary_before: false },
OutlineGroup { level: 1, start: 4, end: 7, collapsed: false, summary_before: false },
OutlineGroup { level: 1, start: 11, end: 12, collapsed: true, summary_before: false },
]
);
assert_eq!(g[1].summary(), 8);
@@ -129,5 +148,22 @@ mod tests {
assert_eq!(g.len(), 7);
assert!(g.iter().all(|g| g.start == u32::MAX - 1 && g.end == u32::MAX));
assert_eq!(g[0].summary(), u32::MAX);
assert!(!g[0].has_summary());
}
#[test]
fn summary_before_the_detail() {
// Rows 5..8 at level 1 with 6..7 at level 2: the summaries are rows 4 and 5.
let l = lines(&[(4, 1, false), (5, 2, false), (6, 2, false), (7, 1, false), (0, 1, false)]);
let g = groups_with(&l, true);
let at = |level: u8, start: u32| g.iter().find(|g| g.level == level && g.start == start).copied();
assert_eq!(at(1, 4).map(|g| (g.summary(), g.has_summary())), Some((3, true)));
assert_eq!(at(2, 5).map(|g| (g.summary(), g.has_summary())), Some((4, true)));
// A group on the first line has no line before it.
assert_eq!(at(1, 0).map(|g| (g.summary(), g.has_summary())), Some((0, false)));
let mut sh = Sheet::new("S");
assert!(!summary_before(&sh, true) && !summary_before(&sh, false));
sh.summary_below = false;
assert!(summary_before(&sh, true) && !summary_before(&sh, false));
}
}
+9
View File
@@ -80,6 +80,13 @@ pub struct Sheet {
/// outline itself stays.
#[serde(default = "yes")]
pub show_outline_symbols: bool,
/// Outline summary rows sit below their detail, and summary columns right of it (Data ▸
/// Outline ▸ Settings; off puts them above / left). Saved as `outlinePr summaryBelow` and
/// `summaryRight`.
#[serde(default = "yes")]
pub summary_below: bool,
#[serde(default = "yes")]
pub summary_right: bool,
pub right_to_left: bool,
pub zoom: u16,
pub cond_formats: Vec<CondFormat>,
@@ -149,6 +156,8 @@ impl Sheet {
visibility: Visibility::Visible,
show_gridlines: true,
show_outline_symbols: true,
summary_below: true,
summary_right: true,
show_headings: true,
show_formulas: false,
show_zeros: true,
+14
View File
@@ -642,6 +642,20 @@ impl Dialog {
],
json!({"groupBy": "A", "function": "sum"}),
),
"outlineSettings" => {
let (below, right) =
app.session.active().and_then(|d| d.wb.active().map(|s| (s.summary_below, s.summary_right))).unwrap_or((true, true));
Dialog::form(
"outlineSettings",
"Settings",
"data.outlineSettings",
vec![
Check { key: "summaryBelow", label: "Summary rows below detail" },
Check { key: "summaryRight", label: "Summary columns to right of detail" },
],
json!({"summaryBelow": below, "summaryRight": right}),
)
}
"zoom" => {
// The current zoom is pre-selected (Custom when it matches no preset). Fit
// selection needs the grid's size, so it is captured here for `view.zoomToSelection`.
+2 -2
View File
@@ -262,8 +262,8 @@ impl Geo {
let (fr, fc) = sh.freeze.unwrap_or((0, 0));
let frozen_w = (ci.start(fc) as f32) * z;
let frozen_h = (ri.start(fr) as f32) * z;
let row_outline = Arc::new(crate::outline::Axis::new(&sh.rows));
let col_outline = Arc::new(crate::outline::Axis::new(&sh.cols));
let row_outline = Arc::new(crate::outline::Axis::of(sh, true));
let col_outline = Arc::new(crate::outline::Axis::of(sh, false));
// Show Outline Symbols off: no bands (the outline's hidden lines stay hidden).
let symbols = sh.show_outline_symbols;
let mut band_w = if symbols { crate::outline::band(row_outline.levels, zoom) } else { 0.0 };
+57 -3
View File
@@ -1,7 +1,8 @@
//! The outline band of grouped rows and columns (Data ▸ Group), as in Excel: a strip left of the
//! row headers and one above the column headers, one lane per outline level, with a bracket per
//! expanded group, a − / + button on its summary line (below / right of the detail) and the level
//! buttons 1…n+1 in the corner. The bands exist only while the sheet has an outline.
//! expanded group, a − / + button on its summary line (below / right of the detail, or above /
//! left of it when the sheet's outline settings say so) and the level buttons 1…n+1 in the
//! corner. The bands exist only while the sheet has an outline.
//!
//! Layout and hit-testing are pure functions of [`Geo`] in logical (left-to-right) coordinates,
//! so right-to-left sheets get them mirrored with the rest of the grid. Clicks run commands
@@ -31,6 +32,12 @@ impl Axis {
pub fn new(lines: &BTreeMap<u32, LineInfo>) -> Axis {
Axis { levels: outline::max_level(lines), groups: outline::groups(lines) }
}
/// The outline of a sheet's rows (`rows`) or columns, with its summary lines where the
/// sheet's outline settings put them.
pub fn of(sh: &Sheet, rows: bool) -> Axis {
let lines = if rows { &sh.rows } else { &sh.cols };
Axis { levels: outline::max_level(lines), groups: outline::groups_with(lines, outline::summary_before(sh, rows)) }
}
}
/// One lane's width at this zoom (`zoom` is the sheet zoom, 1.0 = 100%).
@@ -197,7 +204,7 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout {
for grp in &axis.groups {
let across = dir.lane(step, grp.level);
// The button on the summary line, if that line is on screen.
let button = dir.visible(grp.summary()).map(|(a0, a1)| (a0 + a1) / 2.0).filter(|_| grp.summary() > grp.end);
let button = dir.visible(grp.summary()).map(|(a0, a1)| (a0 + a1) / 2.0).filter(|_| grp.has_summary());
if let Some(mid) = button {
out.buttons.push(Button { rect: dir.rect(mid, across, side), action: Action::Toggle { rows: dir.rows, group: *grp } });
}
@@ -207,6 +214,18 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout {
// The bracket, over the part of the detail that is on screen.
let first = dir.first_visible(grp.start, grp.end);
let Some((first_line, (a0, _))) = first else { continue };
if grp.summary_before {
// From the button down (or across) to the last detail line, with the tick there.
let a0 = button.map_or(a0, |mid| mid + side / 2.0);
let end = dir.start(grp.end) + dir.size(grp.end);
let a1 = end.min(dir.hi);
if a1 <= a0 {
continue;
}
let tick = (end <= dir.hi).then(|| [dir.point(a1 - 0.5, across), dir.point(a1 - 0.5, across + side / 2.0)]);
out.brackets.push(Bracket { line: [dir.point(a0, across), dir.point(a1 - 0.5, across)], tick });
continue;
}
let a1 = match button {
Some(mid) => mid - side / 2.0,
None => dir.start(grp.summary()).min(dir.hi),
@@ -381,6 +400,41 @@ mod tests {
assert_eq!(outer.action.label(), "\u{2212}");
}
#[test]
fn summaries_above_and_left_take_the_toggles_and_the_bracket_ticks_move_to_the_end() {
let mut sh = sheet();
sh.summary_below = false;
sh.summary_right = false;
let g = geo(&sh);
let l = layout(&g, &sh);
let toggle = |rows: bool, level: u8| {
l.buttons.iter().find(|b| matches!(b.action, Action::Toggle { rows: r, group } if r == rows && group.level == level)).unwrap()
};
// Rows 5:8: the outer − is on row 4 (index 3), the inner one (rows 6:7) on row 5.
let outer = toggle(true, 1);
assert!(g.cell_rect(&sh, CellRef::new(3, 0)).y_range().contains(outer.rect.center().y));
assert!(g.cell_rect(&sh, CellRef::new(4, 0)).y_range().contains(toggle(true, 2).rect.center().y));
// Column group C:E: its − is above column B.
let col = toggle(false, 1);
assert!(g.cell_rect(&sh, CellRef::new(0, 1)).x_range().contains(col.rect.center().x));
// The bracket runs from the button to the end of row 8, with the tick there.
assert_eq!(l.brackets.len(), 3);
let b = l.brackets.iter().find(|b| (b.line[0].x - outer.rect.center().x).abs() < 0.01).unwrap();
let row8 = g.cell_rect(&sh, CellRef::new(7, 0));
assert!((b.line[0].y - outer.rect.bottom()).abs() < 0.01 && (b.line[1].y - (row8.bottom() - 0.5)).abs() < 1.0);
assert!(b.tick.is_some_and(|t| (t[0].y - b.line[1].y).abs() < 0.01 && t[1].x > t[0].x));
// A group on the first row has no row above it: no toggle, still a bracket.
let mut sh = Sheet::new("S");
sh.summary_below = false;
for r in 0..=2 {
sh.rows.entry(r).or_default().outline = 1;
}
let g = geo(&sh);
let l = layout(&g, &sh);
assert!(!l.buttons.iter().any(|b| matches!(b.action, Action::Toggle { .. })));
assert_eq!(l.brackets.len(), 1);
}
#[test]
fn collapsed_groups_show_plus_and_no_bracket() {
let mut sh = sheet();
+3
View File
@@ -2222,6 +2222,9 @@ fn data(app: &mut SheetApp, ui: &mut Ui) {
if small_button(ui, Icon::Minus, "Hide Detail", "Hide Detail", false).clicked() {
act(app, "data.hideDetail", json!({}));
}
if small_button(ui, Icon::Settings, "Settings", "Outline Settings", false).clicked() {
app.open_dialog("outlineSettings", json!({}));
}
});
sep(ui);
if big_button(ui, Icon::ChartBar, "Data\nAnalysis", "Data Analysis", false).clicked() {
+29 -1
View File
@@ -1,4 +1,5 @@
//! The outline band of grouped rows and columns: − / + and level buttons (#373).
//! The outline band of grouped rows and columns: − / + and level buttons (#373), and the
//! Outline Settings dialog that moves the summary rows above their detail.
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use gridcraft_engine::Session;
@@ -90,3 +91,30 @@ fn outline_buttons_collapse_expand_and_undo() {
fn outline_buttons_work_on_right_to_left_sheets() {
outline_buttons_collapse_and_expand(true);
}
#[test]
fn settings_dialog_puts_summary_rows_above_their_detail() {
use egui_kittest::kittest::Queryable as _;
let mut h = harness();
h.state_mut().session.run("data.group", json!({"rows": "5:8"})).unwrap();
h.state_mut().open_dialog("outlineSettings", json!({}));
h.run_steps(4);
h.get_by_label("Summary rows below detail").click();
h.run_steps(3);
h.get_by_label_contains("OK").click();
h.run_steps(3);
assert!(h.state().dialog.is_none());
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
assert!(!sh.summary_below && sh.summary_right);
// The − now sits on row 4, above the detail; it hides rows 5:8 and marks row 4 collapsed.
let minus = button(&h, toggle(true, 1));
let geo = h.state().grid.geo.clone().unwrap();
let row4 = geo.cell_rect(&sh, gridcraft_engine::core::CellRef::new(3, 0));
assert!(row4.y_range().contains(minus.y), "{minus:?} is not on row 4 ({row4:?})");
click(&mut h, minus);
assert_eq!(hidden_rows(&h), [4, 5, 6, 7]);
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
assert!(sh.rows.get(&3).is_some_and(|l| l.collapsed));
assert!(!sh.rows.get(&8).is_some_and(|l| l.collapsed));
}
+4
View File
@@ -120,6 +120,10 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
if let Some(t) = e.child("tabColor") {
sheet.tab_color = Some(read_color(t, &cx.palette));
}
if let Some(o) = e.child("outlinePr") {
sheet.summary_below = o.flag("summaryBelow", true);
sheet.summary_right = o.flag("summaryRight", true);
}
fit_to_page = e.child("pageSetUpPr").is_some_and(|p| p.flag("fitToPage", false));
}
"sheetViews" => read_views(e, &mut sheet),
+11 -1
View File
@@ -138,11 +138,21 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
let mut s = format!("{XML_DECL}<worksheet xmlns=\"{NS_MAIN}\" xmlns:r=\"{NS_REL}\">");
// sheetPr
let fit = sheet.print.fit_to.is_some();
if sheet.tab_color.is_some() || fit {
let summary = !sheet.summary_below || !sheet.summary_right;
if sheet.tab_color.is_some() || fit || summary {
s.push_str("<sheetPr>");
if let Some(c) = &sheet.tab_color {
let _ = write!(s, "<tabColor {}/>", color_attrs(c));
}
if summary {
s.push_str("<outlinePr");
for (key, on) in [("summaryBelow", sheet.summary_below), ("summaryRight", sheet.summary_right)] {
if !on {
let _ = write!(s, " {key}=\"0\"");
}
}
s.push_str("/>");
}
if fit {
s.push_str("<pageSetUpPr fitToPage=\"1\"/>");
}
+28
View File
@@ -343,3 +343,31 @@ fn outline_symbols_flag_round_trips() {
let (wb, _) = read_xlsx(&minimal("<sheetData/>", &[], "", "")).unwrap();
assert!(wb.sheet(0).unwrap().show_outline_symbols);
}
/// Outline Settings (`outlinePr summaryBelow="0" summaryRight="0"`) are read and written back,
/// between the tab color and the page setup properties.
#[test]
fn outline_summary_directions_round_trip() {
let body = r#"<sheetPr><tabColor rgb="FF00B050"/><outlinePr summaryBelow="0"/><pageSetUpPr fitToPage="1"/></sheetPr><sheetData/><pageSetup fitToWidth="1" fitToHeight="1"/>"#;
let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap();
let sh = wb.sheet(0).unwrap();
assert!(!sh.summary_below && sh.summary_right);
let bytes = write_xlsx(&wb).unwrap();
let xml = String::from_utf8(crate::package::Package::open(&bytes).unwrap().read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap();
let (tab, pr, fit) = (xml.find("<tabColor"), xml.find("<outlinePr summaryBelow=\"0\"/>"), xml.find("<pageSetUpPr"));
assert!(tab < pr && pr < fit && tab.is_some(), "{xml}");
let (back, _) = read_xlsx(&bytes).unwrap();
assert!(!back.sheet(0).unwrap().summary_below && back.sheet(0).unwrap().summary_right);
let body = r#"<sheetPr><outlinePr summaryRight="0"/></sheetPr><sheetData/>"#;
let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap();
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
let sh = back.sheet(0).unwrap();
assert!(sh.summary_below && !sh.summary_right);
// Absent means below and right, and the defaults write no `outlinePr`.
let (wb, _) = read_xlsx(&minimal("<sheetData/>", &[], "", "")).unwrap();
let sh = wb.sheet(0).unwrap();
assert!(sh.summary_below && sh.summary_right);
let bytes = write_xlsx(&wb).unwrap();
let xml = String::from_utf8(crate::package::Package::open(&bytes).unwrap().read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap();
assert!(!xml.contains("outlinePr"), "{xml}");
}
+3 -2
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Go To lists the named ranges of every sheet and the references entered before, and takes several names or references separated by commas; F3 Paste Name dialog with Paste List; right-clicking the status bar opens a menu of its statistics, with Numerical Count, Minimum and Maximum added; Ctrl+Shift+U expands and collapses the formula bar; Fill Days, Fill Weekdays, Fill Months and Fill Years in the fill-handle menus for a date; Show Details in the cell menu of a PivotTable value cell; dragging the handle between the sheet tabs and the horizontal scrollbar resizes the tabs; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Go To lists the named ranges of every sheet and the references entered before, and takes several names or references separated by commas; F3 Paste Name dialog with Paste List; right-clicking the status bar opens a menu of its statistics, with Numerical Count, Minimum and Maximum added; Ctrl+Shift+U expands and collapses the formula bar; Fill Days, Fill Weekdays, Fill Months and Fill Years in the fill-handle menus for a date; Show Details in the cell menu of a PivotTable value cell; dragging the handle between the sheet tabs and the horizontal scrollbar resizes the tabs; Outline Settings: summary rows above and summary columns left of their detail; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **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
@@ -60,7 +60,7 @@ this logic, with one undo step.
| AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) |
| Header resize and double-click AutoFit | done (#42, #88 for header dividers) |
| Data › Advanced Filter | dialog offers in-place or copy-to filtering, list and optional criteria ranges, and unique records; Alt+A,Q opens it (#1622). The engine's copy-to path clears only the result footprint and rejects worksheet overflow (#1031). |
| Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), View › Show Outline Symbols (Ctrl+8) hides the bands and is saved with the sheet, and a block of cells still groups rows without the Rows / Columns dialog |
| Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; Data › Outline › Settings (`data.outlineSettings`) puts summary rows above and summary columns left of their detail, saved as `outlinePr` (the dialog's Automatic styles check box and Apply Styles button are missing), View › Show Outline Symbols (Ctrl+8) hides the bands and is saved with the sheet, and a block of cells still groups rows without the Rows / Columns dialog |
| Reorder selected whole columns by dragging their headers | done (10-11) |
| Freeze panes, split panes | freeze done; split to verify |
| A Table's header names and filter buttons in the column headings once its header row scrolls away | done (#1423): with the active cell in the Table, as in Excel; the button opens the column's filter menu, the rest of the heading still selects the column. Plain AutoFilter ranges keep their letters (Excel does the same) |
@@ -190,6 +190,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| 2026-10-24 | minor | Ctrl+Shift+U (Control+Shift+U on a Mac) expands or collapses the formula bar, as in Excel ("Expand or collapse the formula bar. Ctrl+Shift+U"), on the grid and in the cell editor or formula bar while editing. On Windows and Linux it used to underline the selection, as Ctrl+U does. The command is `view.expandFormulaBar` (`{on?}`, toggles without it) |
| 2026-10-24 | minor | Right-clicking a PivotTable's value, subtotal or grand total cell offers Show Details in the cell menu, as in Excel ("Right-click a field in the values area of the PivotTable, and then click Show Details"): it lists the cell's source records on a new sheet, as double-clicking the cell does (`pivot.showDetails`). Labels and headers of a current report don't offer it; in a report that is out of date every cell offers it, and choosing it asks for a refresh |
| 2026-10-24 | minor | Sheet tabs: the handle at the horizontal scrollbar's left edge shows the resize cursor, and dragging it moves the boundary, as in Excel ("Hover the mouse pointer at the edge of the scrollbar until you see the double-headed arrow"). Dragging it right gives the tabs more of the bar, until the scrollbar is 100 px wide (the scrollbar stays 100 px wide after the window is narrowed); dragging it left gives the scrollbar more, down to the New sheet button. Room the tabs don't fill stays empty, with New sheet after the last tab. Each open workbook keeps its own share; a workbook starts at the default (tabs up to 60%) |
| 2026-10-24 | minor | Outline Settings (Data ▸ Outline ▸ Settings, `data.outlineSettings`): clearing Summary rows below detail or Summary columns to right of detail puts the − / + buttons on the line above or left of each group, and Hide Detail, Show Detail and the level buttons mark that line collapsed. Changing a direction moves the marks of collapsed groups. Read and written as `outlinePr summaryBelow` / `summaryRight` |
| 2026-10-23 | minor | F3, Formulas › Use in Formula › Paste Names… and Alt+M, S, P open the Paste Name dialog (F3 only when the workbook has names). It lists the workbook's names and the active sheet's own in alphabetical order, hidden names left out; OK or a double-click puts the selected name at the caret of the formula being edited, in place of selected text, or starts `=Name` in the active cell (`formulas.pasteName` `{name}`), and Paste List writes each name and the text of what it refers to in two columns from the active cell (`{list: true}`). While a dialog is open over the edit, the cell editor takes no keys, and Cancel leaves the selected text selected. The Use in Formula menu lists the same names and inserts at the caret too (`formulas.useInFormula`); it used to list hidden names and other sheets' names, the menu added the name at the end of the text, and the command replaced the text being edited |
| 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) |
| 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is |