From 118cd15d935bf79f75c1465932107cbd2bd44778 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 11:22:54 -0700 Subject: [PATCH] Outline: Settings puts summary rows above and summary columns left of their detail Data > Outline > Settings now has Summary rows below detail and Summary columns to right of detail. With one cleared, each group's - / + button sits on the line before the group, its bracket runs down to the last detail line, and Hide Detail, Show Detail, the level buttons and Ungroup mark or clear the collapsed flag on that line. Changing a direction moves the flags of collapsed groups to their new summary lines. - model: Sheet::summary_below and summary_right (on by default, also for older JSON). - xlsx: read and written as sheetPr/outlinePr summaryBelow and summaryRight; files that set them no longer lose them on save. - engine: data.outlineSettings {sheet?, summaryBelow?, summaryRight?}, undoable; outline::groups_with places the summary line. - ui: a Settings button in the Data tab's Outline group opens the dialog; the outline band follows the setting. --- crates/engine/src/cmd/data.rs | 146 +++++++++++++++++++++++++++-- crates/engine/src/outline.rs | 54 +++++++++-- crates/model/src/sheet.rs | 9 ++ crates/ui-egui/src/dialogs.rs | 14 +++ crates/ui-egui/src/grid.rs | 4 +- crates/ui-egui/src/outline.rs | 60 +++++++++++- crates/ui-egui/src/ribbon.rs | 3 + crates/ui-egui/tests/it/outline.rs | 30 +++++- crates/xlsx/src/sheet_read.rs | 4 + crates/xlsx/src/sheet_write.rs | 12 ++- crates/xlsx/src/tests/malformed.rs | 28 ++++++ docs/ui-parity.md | 5 +- 12 files changed, 343 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 8f7c47fb8..9a98e4e6a 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -106,6 +106,15 @@ pub fn specs() -> Vec { 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 { 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 { } } // 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 { 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 { 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 { }; 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 = 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 { 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 { 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 { }) } +/// 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 { + 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::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 = 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 { 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::>(), [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::>(), [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::>(), [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(); diff --git a/crates/engine/src/outline.rs b/crates/engine/src/outline.rs index 80d14afe2..a3ca782c9 100644 --- a/crates/engine/src/outline.rs +++ b/crates/engine/src/outline.rs @@ -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) -> 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) -> Vec { + 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, summary_before: bool) -> Vec { 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) -> Vec { 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)); } } diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index f7ef8f3be..b43bfa525 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -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, @@ -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, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e83db2fa1..80b647875 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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`. diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index b485104b1..b635d40b6 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -252,8 +252,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 }; diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs index 19fd88497..9096d1611 100644 --- a/crates/ui-egui/src/outline.rs +++ b/crates/ui-egui/src/outline.rs @@ -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) -> 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(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ab9cc311d..3519be8f1 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2176,6 +2176,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() { diff --git a/crates/ui-egui/tests/it/outline.rs b/crates/ui-egui/tests/it/outline.rs index 7b507af3e..57aa7b740 100644 --- a/crates/ui-egui/tests/it/outline.rs +++ b/crates/ui-egui/tests/it/outline.rs @@ -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)); +} diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 7cb2e8c22..aadd1141e 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -120,6 +120,10 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result read_views(e, &mut sheet), diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index e0e863ec3..52dc979e6 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -138,11 +138,21 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( let mut s = format!("{XML_DECL}"); // 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(""); if let Some(c) = &sheet.tab_color { let _ = write!(s, "", color_attrs(c)); } + if summary { + s.push_str(""); + } if fit { s.push_str(""); } diff --git a/crates/xlsx/src/tests/malformed.rs b/crates/xlsx/src/tests/malformed.rs index 91b5d4b9c..4ee815e86 100644 --- a/crates/xlsx/src/tests/malformed.rs +++ b/crates/xlsx/src/tests/malformed.rs @@ -343,3 +343,31 @@ fn outline_symbols_flag_round_trips() { let (wb, _) = read_xlsx(&minimal("", &[], "", "")).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#""#; + 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(""), xml.find(""#; + 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("", &[], "", "")).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}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5d5f1efee..d4174ab13 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-10-23 · **Change:** minor (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 (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 @@ -59,7 +59,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) | @@ -178,6 +178,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 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 | 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 | | 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) |