Merge pull request #388 from itrycodinganditsbad/fix-373

Draw the outline band for grouped rows and columns (#373)
This commit is contained in:
Brandon Thomas 2026-10-11 16:06:48 -04:00
commit 773f7e6b76
20 changed files with 915 additions and 34 deletions

View File

@ -160,6 +160,8 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
("-", "", None), ("-", "", None),
("Group", "data.group", None), ("Group", "data.group", None),
("Ungroup", "data.ungroup", None), ("Ungroup", "data.ungroup", None),
("Show Detail", "data.showDetail", None),
("Hide Detail", "data.hideDetail", None),
("Subtotal…", "dialog:subtotal", None), ("Subtotal…", "dialog:subtotal", None),
("-", "", None), ("-", "", None),
("Calculate Now", "formulas.calculateNow", None), ("Calculate Now", "formulas.calculateNow", None),

View File

@ -8,6 +8,7 @@ use gridcraft_model::*;
use serde_json::{Value as Json, json}; use serde_json::{Value as Json, json};
use super::*; use super::*;
use crate::outline;
use crate::selection::current_region; use crate::selection::current_region;
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
@ -74,10 +75,37 @@ pub fn specs() -> Vec<CommandSpec> {
), ),
cmd!(query "data.validate", "Check Value Against Validation", [], None, "{cell?, input} → {ok, message?}", has_doc, validate_cmd), cmd!(query "data.validate", "Check Value Against Validation", [], None, "{cell?, input} → {ok, message?}", has_doc, validate_cmd),
cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → invalid cells", has_doc, circle_invalid), cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → invalid cells", has_doc, circle_invalid),
cmd!("data.group", "Group", ["Data", "Outline"], Some("Cmd+Shift+K"), "{rows?: \"2:5\" | cols?: \"B:C\"}", has_doc, |s, p| group(s, p, true)), cmd!(
cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Cmd+Shift+J"), "{rows? | cols?}", has_doc, |s, p| group(s, p, false)), "data.group",
cmd!("data.hideDetail", "Hide Detail", ["Data", "Outline"], None, "{rows? | cols?}", has_doc, |s, p| detail(s, p, true)), "Group",
cmd!("data.showDetail", "Show Detail", ["Data", "Outline"], None, "{rows? | cols?}", has_doc, |s, p| detail(s, p, false)), ["Data", "Outline"],
Some("Alt+Shift+Right"),
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default)}",
has_doc,
|s, p| group(s, p, true)
),
cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Alt+Shift+Left"), "{rows? | cols? | by?: rows|cols}", has_doc, |s, p| group(
s, p, false
)),
cmd!(
"data.hideDetail",
"Hide Detail",
["Data", "Outline"],
None,
"{rows? | cols? | by?, level?: 1-7} (the innermost group, or the one at `level`, holding the lines or summarised by them)",
has_doc,
|s, p| detail(s, p, true)
),
cmd!("data.showDetail", "Show Detail", ["Data", "Outline"], None, "{rows? | cols? | by?, level?: 1-7}", has_doc, |s, p| detail(s, p, false)),
cmd!(
"data.showLevels",
"Show Outline Levels",
[],
None,
"{rows?: 1-8, cols?: 1-8} (1 shows only the top-level summaries, as the outline's level buttons)",
has_doc,
show_levels
),
cmd!( cmd!(
"data.subtotal", "data.subtotal",
"Subtotal", "Subtotal",
@ -1067,7 +1095,12 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
return Ok((false, r.start.col, r.end.col)); return Ok((false, r.start.col, r.end.col));
} }
let r = s.doc()?.selection.current(); let r = s.doc()?.selection.current();
if r.is_full_cols() && !r.is_full_rows() { Ok((false, r.start.col, r.end.col)) } else { Ok((true, r.start.row, r.end.row)) } match str_param(p, "by") {
Some("rows") => Ok((true, r.start.row, r.end.row)),
Some("cols" | "columns") => Ok((false, r.start.col, r.end.col)),
_ if r.is_full_cols() && !r.is_full_rows() => Ok((false, r.start.col, r.end.col)),
_ => Ok((true, r.start.row, r.end.row)),
}
} }
fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> { fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
@ -1076,39 +1109,94 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
let si = cx.sheet_index(); let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?; let sh = cx.sheet_mut(si)?;
let map = if rows { &mut sh.rows } else { &mut sh.cols }; let map = if rows { &mut sh.rows } else { &mut sh.cols };
for i in a..=b.min(a + 1_048_575) { let b = b.min(a.saturating_add(1_048_575));
for i in a..=b {
let e = map.entry(i).or_default(); let e = map.entry(i).or_default();
e.outline = if on { (e.outline + 1).min(7) } else { e.outline.saturating_sub(1) }; e.outline = if on { (e.outline + 1).min(outline::MAX_LEVEL) } else { e.outline.saturating_sub(1) };
if !on && e.outline == 0 { if !on && e.outline == 0 {
e.hidden = false; e.hidden = false;
} }
} }
// A summary line whose group is gone has nothing left to collapse.
if !on
&& !outline::groups(map).iter().any(|g| g.end == b)
&& let Some(e) = map.get_mut(&b.saturating_add(1))
{
e.collapsed = false;
}
Ok(Json::Null) Ok(Json::Null)
}) })
} }
/// Hide Detail / Show Detail: the innermost group (or the one at `level`) that holds the lines, or
/// failing that the one they are the summary of. Hiding marks the summary line collapsed (the
/// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed.
fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> { fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
let (rows, a, b) = outline_lines(s, p)?; let (rows, a, b) = outline_lines(s, p)?;
let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8);
edit(s, |cx| { edit(s, |cx| {
let si = cx.sheet_index(); let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?; let sh = cx.sheet_mut(si)?;
let map = if rows { &mut sh.rows } else { &mut sh.cols }; let map = if rows { &mut sh.rows } else { &mut sh.cols };
// Expand to the whole group around the selection. let all = outline::groups(map);
let level = (a..=b).filter_map(|i| map.get(&i).map(|e| e.outline)).max().unwrap_or(0); let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l);
if level == 0 { let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec<outline::OutlineGroup> {
return Ok(Json::Null); let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect();
let deepest = hits.iter().map(|g| g.level).max().unwrap_or(0);
hits.into_iter().filter(|g| g.level == deepest).collect()
};
let mut chosen = pick(&|g| g.start <= b && g.end >= a);
if chosen.is_empty() {
chosen = pick(&|g| g.summary() >= a && g.summary() <= b);
} }
let mut lo = a; for g in chosen {
while lo > 0 && map.get(&(lo - 1)).is_some_and(|e| e.outline >= level) { // Inner groups collapsed on their own (their summary marked), before this one changes.
lo -= 1; 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| map.get(&o.summary()).is_some_and(|e| e.collapsed))
.copied()
.collect();
for i in g.start..=g.end {
if let Some(e) = map.get_mut(&i) {
e.hidden = hide || inner.iter().any(|o| o.contains(i));
}
}
if hide {
map.entry(g.summary()).or_default().collapsed = true;
} else if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
} }
let mut hi = b; cx.structural = true;
while map.get(&(hi + 1)).is_some_and(|e| e.outline >= level) { Ok(Json::Null)
hi += 1; })
} }
for i in lo..=hi {
if let Some(e) = map.get_mut(&i) { /// The outline's level buttons: show rows / columns down to outline level `n` (1 = only the
e.hidden = hide; /// top-level summaries); deeper detail is hidden and its summary lines marked collapsed.
fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
let level = |k: &str| p.get(k).and_then(Json::as_u64).map(|n| n.clamp(1, outline::MAX_LEVEL as u64 + 1) as u8);
let (rows, cols) = (level("rows"), level("cols"));
if rows.is_none() && cols.is_none() {
return Err(EngineError::BadParams {
cmd: "data.showLevels".into(),
msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(),
});
}
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 Some(n) = n else { continue };
let groups = outline::groups(map);
for e in map.values_mut().filter(|e| e.outline > 0) {
e.hidden = 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 {
map.entry(g.summary()).or_default().collapsed = g.level >= n;
} }
} }
cx.structural = true; cx.structural = true;
@ -1207,6 +1295,81 @@ mod tests {
assert!(!ok(&mut s, "B1", "150")); assert!(!ok(&mut s, "B1", "150"));
} }
/// Rows 5:8 grouped, with 6:7 grouped again inside (0-based 4..=7 and 5..=6).
fn nested_rows() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("data.group", json!({"rows": "5:8"})).unwrap();
s.execute("data.group", json!({"rows": "6:7"})).unwrap();
s
}
/// (hidden, collapsed) of 0-based rows.
fn row_state(s: &Session, rows: std::ops::RangeInclusive<u32>) -> Vec<(bool, bool)> {
let sh = s.doc().unwrap().wb.active().unwrap();
rows.map(|r| sh.rows.get(&r).map(|l| (l.hidden, l.collapsed)).unwrap_or_default()).collect()
}
#[test]
fn hide_detail_marks_the_summary_collapsed_and_show_detail_keeps_inner_groups_collapsed() {
let mut s = nested_rows();
s.execute("data.hideDetail", json!({"rows": "6:7", "level": 2})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]);
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]);
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]);
s.execute("data.showDetail", json!({"rows": "6:7", "level": 2})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false); 5]);
// Each step is one undoable command.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(row_state(&s, 5..=7), [(true, false), (true, false), (false, true)]);
}
#[test]
fn hide_detail_on_the_summary_row_collapses_the_group_above_it() {
let mut s = nested_rows();
s.execute("data.hideDetail", json!({"rows": "9:9"})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, false), (false, true)]);
}
#[test]
fn show_levels_collapses_and_expands_to_a_level() {
let mut s = nested_rows();
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]);
s.execute("data.showLevels", json!({"rows": 2})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]);
s.execute("data.showLevels", json!({"rows": 3})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false); 5]);
// Columns are separate; no level at all is an error rather than a guess.
s.execute("data.group", json!({"cols": "C:E"})).unwrap();
s.execute("data.showLevels", json!({"cols": 1})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert!((2..=4).all(|c| sh.cols.get(&c).is_some_and(|l| l.hidden)));
assert!(sh.cols.get(&5).is_some_and(|l| l.collapsed));
assert_eq!(row_state(&s, 4..=4), [(false, false)]);
assert!(s.execute("data.showLevels", json!({})).is_err());
}
#[test]
fn group_by_columns_for_a_block_of_cells() {
let mut s = Session::new();
s.new_workbook();
s.execute("selection.set", json!({"range": "B2:C5"})).unwrap();
s.execute("data.group", json!({"by": "cols"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert!((1..=2).all(|c| sh.cols.get(&c).is_some_and(|l| l.outline == 1)));
assert!(sh.rows.values().all(|l| l.outline == 0));
}
#[test]
fn group_shortcuts_are_the_windows_ones() {
let sc = |id: &str| specs().into_iter().find(|c| c.id == id).and_then(|c| c.shortcut);
assert_eq!(sc("data.group"), Some("Alt+Shift+Right"));
assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left"));
}
#[test] #[test]
fn split() { fn split() {
assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]); assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]);

View File

@ -13,6 +13,7 @@ pub mod cmd;
pub mod display; pub mod display;
pub mod fill; pub mod fill;
pub mod io; pub mod io;
pub mod outline;
pub mod paper; pub mod paper;
pub mod pivot; pub mod pivot;
pub mod pivot_calc; pub mod pivot_calc;

View File

@ -0,0 +1,133 @@
//! 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).
use std::collections::BTreeMap;
use gridcraft_model::LineInfo;
/// 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`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OutlineGroup {
pub level: u8,
pub start: u32,
pub end: u32,
/// Every detail line is hidden: the button shows +.
pub collapsed: bool,
}
impl OutlineGroup {
/// The summary line, where the − / + button sits.
pub fn summary(&self) -> u32 {
self.end.saturating_add(1)
}
pub fn contains(&self, line: u32) -> bool {
line >= self.start && line <= self.end
}
}
/// 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).
pub fn groups(lines: &BTreeMap<u32, LineInfo>) -> 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];
let mut prev: Option<u32> = None;
let close_from = |open: &mut [Option<(u32, bool)>; MAX_LEVEL as usize], from: u8, end: u32, out: &mut Vec<OutlineGroup>| {
for level in (from..=MAX_LEVEL).rev() {
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 });
}
}
};
for (&i, info) in lines.iter().filter(|(_, l)| l.outline > 0) {
let level = info.outline.min(MAX_LEVEL);
match prev {
// A gap ends every group.
Some(p) if p.checked_add(1) != Some(i) => close_from(&mut open, 1, p, &mut out),
Some(p) if level < MAX_LEVEL => close_from(&mut open, level + 1, p, &mut out),
_ => {}
}
for slot in open.iter_mut().take(level as usize) {
match slot {
Some((_, hidden)) => *hidden &= info.hidden,
None => *slot = Some((i, info.hidden)),
}
}
prev = Some(i);
}
if let Some(p) = prev {
close_from(&mut open, 1, p, &mut out);
}
out
}
/// The group at `level` that holds `line`.
pub fn group_at(lines: &BTreeMap<u32, LineInfo>, line: u32, level: u8) -> Option<OutlineGroup> {
groups(lines).into_iter().find(|g| g.level == level && g.contains(line))
}
#[cfg(test)]
mod tests {
use super::*;
fn lines(spec: &[(u32, u8, bool)]) -> BTreeMap<u32, LineInfo> {
spec.iter().map(|&(i, outline, hidden)| (i, LineInfo { outline, hidden, ..Default::default() })).collect()
}
#[test]
fn no_outline_has_no_groups() {
let l = lines(&[(3, 0, true)]);
assert_eq!(max_level(&l), 0);
assert!(groups(&l).is_empty());
}
#[test]
fn nested_and_separate_groups() {
// Rows 5..8 at level 1 with 6..7 at level 2; rows 12..13 at level 1 after a gap.
let l = lines(&[(4, 1, false), (5, 2, false), (6, 2, false), (7, 1, false), (11, 1, true), (12, 1, true)]);
assert_eq!(max_level(&l), 2);
let g = groups(&l);
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 },
]
);
assert_eq!(g[1].summary(), 8);
assert_eq!(group_at(&l, 6, 1).map(|g| g.start), Some(4));
assert_eq!(group_at(&l, 4, 2), None);
}
#[test]
fn adjacent_groups_at_one_level_split_by_a_shallower_line() {
// Two level-2 groups inside one level-1 group, separated by a level-1 line.
let l = lines(&[(0, 2, true), (1, 2, true), (2, 1, false), (3, 2, false)]);
let g = groups(&l);
assert_eq!(g.iter().filter(|g| g.level == 2).count(), 2);
assert_eq!(g.iter().find(|g| g.level == 2 && g.start == 0).map(|g| g.collapsed), Some(true));
assert_eq!(g.iter().find(|g| g.level == 1).map(|g| (g.start, g.end, g.collapsed)), Some((0, 3, false)));
}
#[test]
fn deepest_level_and_last_line_do_not_overflow() {
let l = lines(&[(u32::MAX - 1, 9, false), (u32::MAX, 7, false)]);
let g = groups(&l);
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);
}
}

View File

@ -121,6 +121,12 @@ pub struct Geo {
pub z: f32, pub z: f32,
pub header_w: f32, pub header_w: f32,
pub header_h: f32, pub header_h: f32,
/// The outline bands (grouped rows / columns): left of the row headers and above the column
/// headers; 0 without an outline (see `outline`).
pub band_w: f32,
pub band_h: f32,
pub row_outline: Arc<crate::outline::Axis>,
pub col_outline: Arc<crate::outline::Axis>,
pub fr: u32, pub fr: u32,
pub fc: u32, pub fc: u32,
pub frozen_w: f32, pub frozen_w: f32,
@ -151,8 +157,46 @@ impl Geo {
let (fr, fc) = sh.freeze.unwrap_or((0, 0)); let (fr, fc) = sh.freeze.unwrap_or((0, 0));
let frozen_w = (ci.start(fc) as f32) * z; let frozen_w = (ci.start(fc) as f32) * z;
let frozen_h = (ri.start(fr) as f32) * z; let frozen_h = (ri.start(fr) as f32) * z;
let cells = Rect::from_min_max(pos2(rect.left() + header_w, rect.top() + header_h), rect.max); let row_outline = Arc::new(crate::outline::Axis::new(&sh.rows));
Geo { rect, cells, z, header_w, header_h, fr, fc, frozen_w, frozen_h, scroll, rtl: sh.right_to_left, ri, ci, paper: None } let col_outline = Arc::new(crate::outline::Axis::new(&sh.cols));
let mut band_w = crate::outline::band(row_outline.levels, zoom);
let mut band_h = crate::outline::band(col_outline.levels, zoom);
// Without headings the level buttons still need a corner to sit in.
if row_outline.levels > 0 && header_h == 0.0 {
band_h = band_h.max(crate::outline::lane(zoom) + 3.0);
}
if col_outline.levels > 0 && header_w == 0.0 {
band_w = band_w.max(crate::outline::lane(zoom) + 3.0);
}
let cells = Rect::from_min_max(pos2(rect.left() + band_w + header_w, rect.top() + band_h + header_h), rect.max);
Geo {
rect,
cells,
z,
header_w,
header_h,
band_w,
band_h,
row_outline,
col_outline,
fr,
fc,
frozen_w,
frozen_h,
scroll,
rtl: sh.right_to_left,
ri,
ci,
paper: None,
}
}
/// Left edge of the row headers (right of the outline band).
pub fn head_left(&self) -> f32 {
self.cells.left() - self.header_w
}
/// Top edge of the column headers (below the outline band).
pub fn head_top(&self) -> f32 {
self.cells.top() - self.header_h
} }
/// Page Layout view geometry: `rect` excludes the rulers; cells are drawn at the print scale /// Page Layout view geometry: `rect` excludes the rulers; cells are drawn at the print scale
/// on pages laid out by `grid`; frozen panes don't apply (as in Excel). /// on pages laid out by `grid`; frozen panes don't apply (as in Excel).
@ -558,6 +602,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
paint_headers(&painter, &geo, sh, &sel, &t, &quads); paint_headers(&painter, &geo, sh, &sel, &t, &quads);
paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t); paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t);
} }
crate::outline::paint(&painter, &geo, sh, &t);
if geo.rtl { if geo.rtl {
let mirror_to = painter.add(egui::Shape::Noop); let mirror_to = painter.add(egui::Shape::Noop);
mirror_painted(ui.ctx(), painter.layer_id(), mirror_from.0..mirror_to.0, &geo, &objects); mirror_painted(ui.ctx(), painter.layer_id(), mirror_from.0..mirror_to.0, &geo, &objects);
@ -1401,7 +1446,8 @@ pub(crate) fn paint_headers(
quads: &[((u32, u32), (u32, u32), Rect)], quads: &[((u32, u32), (u32, u32), Rect)],
) { ) {
let font = theme::ui_font((11.5 * (geo.z / DISPLAY_SCALE).max(0.75)).min(16.0)); let font = theme::ui_font((11.5 * (geo.z / DISPLAY_SCALE).max(0.75)).min(16.0));
let r = geo.rect; // The headers' own area: inside the outline bands.
let r = Rect::from_min_max(pos2(geo.head_left(), geo.head_top()), geo.rect.max);
let top = Rect::from_min_max(pos2(geo.cells.left(), r.top()), pos2(r.right(), geo.cells.top())); let top = Rect::from_min_max(pos2(geo.cells.left(), r.top()), pos2(r.right(), geo.cells.top()));
let left = Rect::from_min_max(pos2(r.left(), geo.cells.top()), pos2(geo.cells.left(), r.bottom())); let left = Rect::from_min_max(pos2(r.left(), geo.cells.top()), pos2(geo.cells.left(), r.bottom()));
p.rect_filled(top, 0.0, t.header_bg); p.rect_filled(top, 0.0, t.header_bg);
@ -1489,9 +1535,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
// Hit-testing works in logical coordinates (see `Geo::rtl`). // Hit-testing works in logical coordinates (see `Geo::rtl`).
let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p));
let in_cells = pos.is_some_and(|p| geo.in_cells(p)); let in_cells = pos.is_some_and(|p| geo.in_cells(p));
let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.rect.top() && p.x >= geo.cells.left()); let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left());
let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.rect.left() && p.y >= geo.cells.top()); let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top());
let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && geo.rect.contains(p)); let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && p.x >= geo.head_left() && p.y >= geo.head_top());
let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default();
let cur_rect = geo.range_rect(sh, sel.current()); let cur_rect = geo.range_rect(sh, sel.current());
let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0));
@ -1672,6 +1718,14 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) { if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) {
// Where the press began (a drag is only recognised after the pointer has moved). // Where the press began (a drag is only recognised after the pointer has moved).
let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return }; let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return };
// The outline band's − / + and level buttons act on release, like buttons.
if let Some(action) = crate::outline::hit(geo, sh, p) {
if primary_clicked {
let (command, params) = action.command();
app.run_or_alert(command, params);
}
return;
}
// Freeze handles sit on the header edges (and the corner's edges when nothing is frozen). // Freeze handles sit on the header edges (and the corner's edges when nothing is frozen).
if freeze_cols.contains(p) || freeze_rows.contains(p) { if freeze_cols.contains(p) || freeze_rows.contains(p) {
app.grid.drag = Drag::Freeze { cols: freeze_cols.contains(p), to: None }; app.grid.drag = Drag::Freeze { cols: freeze_cols.contains(p), to: None };
@ -2099,8 +2153,8 @@ fn on_border(r: Rect, p: Pos2) -> bool {
pub fn freeze_handles(geo: &Geo) -> (Rect, Rect) { pub fn freeze_handles(geo: &Geo) -> (Rect, Rect) {
let x = geo.cells.left() + geo.frozen_w; let x = geo.cells.left() + geo.frozen_w;
let y = geo.cells.top() + geo.frozen_h; let y = geo.cells.top() + geo.frozen_h;
let cols = Rect::from_min_max(pos2(x - 3.0, geo.rect.top()), pos2(x + 3.0, geo.rect.top() + geo.header_h * 0.4)); let cols = Rect::from_min_max(pos2(x - 3.0, geo.head_top()), pos2(x + 3.0, geo.head_top() + geo.header_h * 0.4));
let rows = Rect::from_min_max(pos2(geo.rect.left(), y - 3.0), pos2(geo.rect.left() + geo.header_w * 0.4, y + 3.0)); let rows = Rect::from_min_max(pos2(geo.head_left(), y - 3.0), pos2(geo.head_left() + geo.header_w * 0.4, y + 3.0));
(cols, rows) (cols, rows)
} }
@ -2138,10 +2192,10 @@ fn paint_freeze_handles(p: &Painter, geo: &Geo, sh: &Sheet, drag: &Drag, t: &Tok
let stroke = Stroke::new(2.0, t.accent); let stroke = Stroke::new(2.0, t.accent);
if cols { if cols {
let x = geo.cells.left() + sh.col_left(to) as f32 * geo.z; let x = geo.cells.left() + sh.col_left(to) as f32 * geo.z;
p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.cells.bottom())], stroke); p.line_segment([pos2(x, geo.head_top()), pos2(x, geo.cells.bottom())], stroke);
} else { } else {
let y = geo.cells.top() + sh.row_top(to) as f32 * geo.z; let y = geo.cells.top() + sh.row_top(to) as f32 * geo.z;
p.line_segment([pos2(geo.rect.left(), y), pos2(geo.cells.right(), y)], stroke); p.line_segment([pos2(geo.head_left(), y), pos2(geo.cells.right(), y)], stroke);
} }
} }
} }

View File

@ -334,6 +334,8 @@ const JAPANESE: &[(&str, &str)] = &[
("Clear Validation", "入力規則のクリア"), ("Clear Validation", "入力規則のクリア"),
("Group", "グループ化"), ("Group", "グループ化"),
("Ungroup", "グループ解除"), ("Ungroup", "グループ解除"),
("Show Detail", "詳細データの表示"),
("Hide Detail", "詳細を表示しない"),
("Subtotal", "小計"), ("Subtotal", "小計"),
("Data\nAnalysis", "データ分析"), ("Data\nAnalysis", "データ分析"),
("Data Analysis", "データ分析"), ("Data Analysis", "データ分析"),

View File

@ -164,6 +164,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Clear Validation" => "Vymazat ověření", "Clear Validation" => "Vymazat ověření",
"Group" => "Seskupit", "Group" => "Seskupit",
"Ungroup" => "Zrušit seskupení", "Ungroup" => "Zrušit seskupení",
"Show Detail" => "Zobrazit podrobnosti",
"Hide Detail" => "Skrýt podrobnosti",
"Subtotal" => "Mezisoučet", "Subtotal" => "Mezisoučet",
"What-If\nAnalysis" => "Citlivostní\nanalýza", "What-If\nAnalysis" => "Citlivostní\nanalýza",
"Data Table…" => "Tabulka dat…", "Data Table…" => "Tabulka dat…",

View File

@ -164,6 +164,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Clear Validation" => "Datenüberprüfung löschen", "Clear Validation" => "Datenüberprüfung löschen",
"Group" => "Gruppieren", "Group" => "Gruppieren",
"Ungroup" => "Gruppierung aufheben", "Ungroup" => "Gruppierung aufheben",
"Show Detail" => "Details anzeigen",
"Hide Detail" => "Details ausblenden",
"Subtotal" => "Teilergebnis", "Subtotal" => "Teilergebnis",
"What-If\nAnalysis" => "Was-wäre-wenn-\nAnalyse", "What-If\nAnalysis" => "Was-wäre-wenn-\nAnalyse",
"Data Table…" => "Datentabelle…", "Data Table…" => "Datentabelle…",

View File

@ -165,6 +165,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Clear Validation" => "Borrar círculos de validación", "Clear Validation" => "Borrar círculos de validación",
"Group" => "Agrupar", "Group" => "Agrupar",
"Ungroup" => "Desagrupar", "Ungroup" => "Desagrupar",
"Show Detail" => "Mostrar detalle",
"Hide Detail" => "Ocultar detalle",
"Subtotal" => "Subtotal", "Subtotal" => "Subtotal",
"What-If\nAnalysis" => "Análisis de\nhipótesis", "What-If\nAnalysis" => "Análisis de\nhipótesis",
"Data Table…" => "Tabla de datos…", "Data Table…" => "Tabla de datos…",

View File

@ -171,6 +171,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Clear Validation" => "유효성 검사 지우기", "Clear Validation" => "유효성 검사 지우기",
"Group" => "그룹", "Group" => "그룹",
"Ungroup" => "그룹 해제", "Ungroup" => "그룹 해제",
"Show Detail" => "세부 정보 표시",
"Hide Detail" => "세부 정보 숨기기",
"Subtotal" => "부분합", "Subtotal" => "부분합",
"Data\nAnalysis" => "데이터 분석", "Data\nAnalysis" => "데이터 분석",
"Data Analysis" => "데이터 분석", "Data Analysis" => "데이터 분석",

View File

@ -172,6 +172,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Clear Validation" => "Limpar Validação", "Clear Validation" => "Limpar Validação",
"Group" => "Agrupar", "Group" => "Agrupar",
"Ungroup" => "Desagrupar", "Ungroup" => "Desagrupar",
"Show Detail" => "Mostrar Detalhe",
"Hide Detail" => "Ocultar Detalhe",
"Subtotal" => "Subtotal", "Subtotal" => "Subtotal",
"Data\nAnalysis" => "Análise de Dados", "Data\nAnalysis" => "Análise de Dados",
"Data Analysis" => "Análise de Dados", "Data Analysis" => "Análise de Dados",

View File

@ -171,6 +171,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Clear Validation" => "Очистить проверку данных", "Clear Validation" => "Очистить проверку данных",
"Group" => "Группировать", "Group" => "Группировать",
"Ungroup" => "Разгруппировать", "Ungroup" => "Разгруппировать",
"Show Detail" => "Показать детали",
"Hide Detail" => "Скрыть детали",
"Subtotal" => "Промежуточный итог", "Subtotal" => "Промежуточный итог",
"Data\nAnalysis" => "Анализ данных", "Data\nAnalysis" => "Анализ данных",
"Data Analysis" => "Анализ данных", "Data Analysis" => "Анализ данных",

View File

@ -171,6 +171,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Clear Validation" => "清除验证", "Clear Validation" => "清除验证",
"Group" => "组合", "Group" => "组合",
"Ungroup" => "取消组合", "Ungroup" => "取消组合",
"Show Detail" => "显示明细数据",
"Hide Detail" => "隐藏明细数据",
"Subtotal" => "分类汇总", "Subtotal" => "分类汇总",
"Data\nAnalysis" => "数据分析", "Data\nAnalysis" => "数据分析",
"Data Analysis" => "数据分析", "Data Analysis" => "数据分析",

View File

@ -55,10 +55,12 @@ fn borders(preset: &str) -> Shortcut {
/// The shortcut for a key press in the grid, if any. `physical` is the key's position on a US /// The shortcut for a key press in the grid, if any. `physical` is the key's position on a US
/// layout (as Excel matches Ctrl+Shift+4 rather than the `$` character); `mac` selects the Excel /// layout (as Excel matches Ctrl+Shift+4 rather than the `$` character); `mac` selects the Excel
/// for Mac variants where they differ. /// for Mac variants where they differ. Navigation keys go by the logical key: with Num Lock off
/// the numpad's arrows and Page Down report the digit keys (Num6, Num3) as their physical keys.
pub fn lookup(key: Key, physical: Option<Key>, m: Modifiers, mac: bool) -> Option<Shortcut> { pub fn lookup(key: Key, physical: Option<Key>, m: Modifiers, mac: bool) -> Option<Shortcut> {
use Key::*; use Key::*;
let k = physical.unwrap_or(key); let navigation = matches!(key, ArrowLeft | ArrowRight | ArrowUp | ArrowDown | PageUp | PageDown | Home | End | Insert | Delete);
let k = if navigation { key } else { physical.unwrap_or(key) };
// Control itself (Excel for Mac also uses Control for these), Ctrl on Windows / Cmd on Mac, // Control itself (Excel for Mac also uses Control for these), Ctrl on Windows / Cmd on Mac,
// and Alt/Option alone. // and Alt/Option alone.
let ctrl = m.ctrl && !m.alt; let ctrl = m.ctrl && !m.alt;

View File

@ -22,6 +22,7 @@ pub mod i18n;
pub mod icons; pub mod icons;
pub mod keymap; pub mod keymap;
pub mod keytips; pub mod keytips;
pub mod outline;
pub mod panes; pub mod panes;
pub mod paper_view; pub mod paper_view;
pub mod pivot_pane; pub mod pivot_pane;

View File

@ -0,0 +1,400 @@
//! 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.
//!
//! 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
//! (`data.hideDetail`, `data.showDetail`, `data.showLevels`), so they undo like any edit.
use egui::{Align2, Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2};
use gridcraft_engine::core::col_to_letters;
use gridcraft_engine::model::{LineInfo, Sheet};
use gridcraft_engine::outline::{self, OutlineGroup};
use serde_json::{Value as Json, json};
use std::collections::BTreeMap;
use crate::grid::{DISPLAY_SCALE, Geo};
use crate::theme::{self, Tokens};
/// Width of one level's lane at 100% zoom (points).
const LANE: f32 = 15.0;
/// The outline of one direction (rows or columns): its deepest level and its groups.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Axis {
pub levels: u8,
pub groups: Vec<OutlineGroup>,
}
impl Axis {
pub fn new(lines: &BTreeMap<u32, LineInfo>) -> Axis {
Axis { levels: outline::max_level(lines), groups: outline::groups(lines) }
}
}
/// One lane's width at this zoom (`zoom` is the sheet zoom, 1.0 = 100%).
pub fn lane(zoom: f32) -> f32 {
(LANE * zoom.max(0.75)).round()
}
/// Thickness of the band for an outline `levels` deep: a lane per level plus one for the level
/// button that shows everything. No outline, no band.
pub fn band(levels: u8, zoom: f32) -> f32 {
if levels == 0 { 0.0 } else { (levels as f32 + 1.0) * lane(zoom) + 3.0 }
}
/// What a button does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// A level button: show `rows` (or columns) down to this level.
Level { rows: bool, level: u8 },
/// A group's − (hide its detail) or + (show it) button.
Toggle { rows: bool, group: OutlineGroup },
}
impl Action {
/// The command (and its parameters) the button runs.
pub fn command(&self) -> (&'static str, Json) {
match *self {
Action::Level { rows, level } => ("data.showLevels", json!({ if rows { "rows" } else { "cols" }: level })),
Action::Toggle { rows, group } => {
let id = if group.collapsed { "data.showDetail" } else { "data.hideDetail" };
let (key, lines) = if rows {
("rows", format!("{}:{}", group.start as u64 + 1, group.end as u64 + 1))
} else {
("cols", format!("{}:{}", col_to_letters(group.start), col_to_letters(group.end)))
};
(id, json!({ key: lines, "level": group.level }))
}
}
}
/// The button's label: the level digit, − or +.
pub fn label(&self) -> String {
match *self {
Action::Level { level, .. } => level.to_string(),
Action::Toggle { group, .. } => if group.collapsed { "+" } else { "\u{2212}" }.to_string(),
}
}
}
/// A button in the band.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Button {
pub rect: Rect,
pub action: Action,
}
/// An expanded group's bracket: a line along its lane from the first detail line to its button,
/// with a tick at the start (`tick` is `None` when the start is scrolled out of view).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Bracket {
pub line: [Pos2; 2],
pub tick: Option<[Pos2; 2]>,
}
/// Everything drawn in the bands this frame.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Layout {
pub buttons: Vec<Button>,
pub brackets: Vec<Bracket>,
}
/// The row band (left of the row headers) and the column band (above the column headers).
pub fn bands(geo: &Geo) -> (Rect, Rect) {
let r = geo.rect;
let rows = Rect::from_min_max(r.min, pos2(r.left() + geo.band_w, r.bottom()));
let cols = Rect::from_min_max(r.min, pos2(r.right(), r.top() + geo.band_h));
(rows, cols)
}
/// One direction's geometry, in (along, across) coordinates: along the lines, across the lanes.
struct Dir<'a> {
rows: bool,
geo: &'a Geo,
sh: &'a Sheet,
/// The cells' extent along, where the scrolling pane begins, and the first scrolling line.
lo: f32,
hi: f32,
split: f32,
frozen: u32,
/// Lane `k`'s centre across (1-based).
lane0: f32,
/// Centre across, and along, of this direction's level buttons.
level_along: f32,
}
impl Dir<'_> {
/// Where the line starts on screen, and its size.
fn start(&self, line: u32) -> f32 {
if self.rows { self.geo.y(self.sh, line) } else { self.geo.x(self.sh, line) }
}
fn size(&self, line: u32) -> f32 {
self.geo.z * if self.rows { self.sh.row_height(line) } else { self.sh.col_width(line) }
}
fn lane(&self, step: f32, k: u8) -> f32 {
self.lane0 + (k as f32 - 0.5) * step
}
/// The visible extent of a line along: clipped to its pane, `None` when hidden or off screen.
fn visible(&self, line: u32) -> Option<(f32, f32)> {
let (a, size) = (self.start(line), self.size(line));
let top = if line >= self.frozen { self.split } else { self.lo };
let (a0, a1) = (a.max(top), (a + size).min(self.hi));
(size > 0.0 && a1 > a0).then_some((a0, a1))
}
fn point(&self, along: f32, across: f32) -> Pos2 {
if self.rows { pos2(across, along) } else { pos2(along, across) }
}
fn rect(&self, along: f32, across: f32, side: f32) -> Rect {
Rect::from_center_size(self.point(along, across), vec2(side, side))
}
}
/// The band's buttons and brackets for this frame.
pub fn layout(geo: &Geo, sh: &Sheet) -> Layout {
let mut out = Layout::default();
let zoom = (geo.z / DISPLAY_SCALE).max(0.1);
let step = lane(zoom);
let side = (step - 4.0).max(7.0);
let corner_h = if geo.header_h > 0.0 { geo.header_h } else { geo.band_h };
let corner_w = if geo.header_w > 0.0 { geo.header_w } else { geo.band_w };
let rows = Dir {
rows: true,
geo,
sh,
lo: geo.cells.top(),
hi: geo.cells.bottom(),
split: geo.cells.top() + geo.frozen_h,
frozen: geo.fr,
lane0: geo.rect.left() + 2.0,
level_along: geo.cells.top() - corner_h / 2.0,
};
let cols = Dir {
rows: false,
geo,
sh,
lo: geo.cells.left(),
hi: geo.cells.right(),
split: geo.cells.left() + geo.frozen_w,
frozen: geo.fc,
lane0: geo.rect.top() + 2.0,
level_along: geo.cells.left() - corner_w / 2.0,
};
for (dir, axis) in [(&rows, &geo.row_outline), (&cols, &geo.col_outline)] {
if axis.levels == 0 {
continue;
}
for k in 1..=axis.levels + 1 {
out.buttons.push(Button { rect: dir.rect(dir.level_along, dir.lane(step, k), side), action: Action::Level { rows: dir.rows, level: k } });
}
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);
if let Some(mid) = button {
out.buttons.push(Button { rect: dir.rect(mid, across, side), action: Action::Toggle { rows: dir.rows, group: *grp } });
}
if grp.collapsed {
continue;
}
// The bracket, over the part of the detail that is on screen.
let first = (grp.start..=grp.end).take(4096).find_map(|l| dir.visible(l).map(|v| (l, v)));
let Some((first_line, (a0, _))) = first else { continue };
let a1 = match button {
Some(mid) => mid - side / 2.0,
None => dir.start(grp.summary()).min(dir.hi),
};
if a1 <= a0 {
continue;
}
let start_shown = first_line == grp.start && (a0 - dir.start(grp.start)).abs() < 0.5;
let tick = start_shown.then(|| [dir.point(a0 + 0.5, across), dir.point(a0 + 0.5, across + side / 2.0)]);
out.brackets.push(Bracket { line: [dir.point(a0 + 0.5, across), dir.point(a1, across)], tick });
}
}
out
}
/// The button under `p` (logical coordinates), if any.
pub fn hit(geo: &Geo, sh: &Sheet, p: Pos2) -> Option<Action> {
let (rows, cols) = bands(geo);
if !rows.contains(p) && !cols.contains(p) {
return None;
}
layout(geo, sh).buttons.into_iter().find(|b| b.rect.expand(1.5).contains(p)).map(|b| b.action)
}
/// Paints the bands (in logical coordinates, before the grid is mirrored for right-to-left).
pub fn paint(p: &Painter, geo: &Geo, sh: &Sheet, t: &Tokens) {
if geo.band_w <= 0.0 && geo.band_h <= 0.0 {
return;
}
let (rows, cols) = bands(geo);
for band in [rows, cols] {
if band.width() > 0.0 && band.height() > 0.0 {
p.rect_filled(band, 0.0, t.window);
}
}
if geo.band_w > 0.0 {
p.line_segment([pos2(rows.right() - 0.5, rows.top()), pos2(rows.right() - 0.5, rows.bottom())], Stroke::new(1.0, t.header_line));
}
if geo.band_h > 0.0 {
p.line_segment([pos2(cols.left(), cols.bottom() - 0.5), pos2(cols.right(), cols.bottom() - 0.5)], Stroke::new(1.0, t.header_line));
}
let l = layout(geo, sh);
let ink = Stroke::new(1.0, t.header_text);
// Brackets stay out of the corner, where the level buttons are.
let row_clip = Rect::from_min_max(pos2(rows.left(), geo.cells.top()), rows.max);
let col_clip = Rect::from_min_max(pos2(geo.cells.left(), cols.top()), cols.max);
for b in &l.brackets {
let clip = if b.line[0].x == b.line[1].x { row_clip } else { col_clip };
let pc = p.with_clip_rect(clip);
pc.line_segment(b.line, ink);
if let Some(tick) = b.tick {
pc.line_segment(tick, ink);
}
}
let font = theme::ui_font((10.0 * (geo.z / DISPLAY_SCALE).max(0.75)).min(14.0));
for b in &l.buttons {
let clip = match b.action {
Action::Toggle { rows: true, .. } => row_clip,
Action::Toggle { rows: false, .. } => col_clip,
Action::Level { .. } => geo.rect,
};
let pc = p.with_clip_rect(clip);
pc.rect_filled(b.rect, 1.0, t.header_bg);
pc.rect_stroke(b.rect, 1.0, ink, StrokeKind::Inside);
pc.text(b.rect.center(), Align2::CENTER_CENTER, b.action.label(), font.clone(), t.header_text);
}
}
#[cfg(test)]
mod tests {
use super::*;
use gridcraft_engine::core::CellRef;
/// Rows 5:8 grouped with 6:7 inside, columns C:E grouped.
fn sheet() -> Sheet {
let mut sh = Sheet::new("Sheet1");
for (r, level) in [(4, 1), (5, 2), (6, 2), (7, 1)] {
sh.rows.entry(r).or_default().outline = level;
}
for c in 2..=4 {
sh.cols.entry(c).or_default().outline = 1;
}
sh
}
fn geo(sh: &Sheet) -> Geo {
Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 700.0)), vec2(0.0, 0.0))
}
#[test]
fn no_outline_no_band() {
let sh = Sheet::new("Sheet1");
let g = geo(&sh);
assert_eq!((g.band_w, g.band_h), (0.0, 0.0));
assert_eq!(layout(&g, &sh), Layout::default());
assert_eq!(g.head_left(), g.rect.left());
}
#[test]
fn bands_push_the_headers_and_cells_in() {
let sh = sheet();
let g = geo(&sh);
let plain = geo(&Sheet::new("Sheet1"));
assert_eq!(g.band_w, band(2, 1.0));
assert_eq!(g.band_h, band(1, 1.0));
assert!(g.band_w > g.band_h && g.band_h > 0.0);
assert_eq!(g.head_left(), g.rect.left() + g.band_w);
assert_eq!(g.head_top(), g.rect.top() + g.band_h);
assert_eq!(g.cells.left(), plain.cells.left() + g.band_w);
assert_eq!(g.cells.top(), plain.cells.top() + g.band_h);
}
#[test]
fn level_buttons_sit_in_the_corner_and_toggles_on_summary_lines() {
let sh = sheet();
let g = geo(&sh);
let l = layout(&g, &sh);
let levels: Vec<_> =
l.buttons.iter().filter_map(|b| if let Action::Level { rows, level } = b.action { Some((rows, level)) } else { None }).collect();
assert_eq!(levels, [(true, 1), (true, 2), (true, 3), (false, 1), (false, 2)]);
// Row level buttons: side by side in the row band, level with the column headers.
let row1 = l.buttons.iter().find(|b| b.action == Action::Level { rows: true, level: 1 }).unwrap().rect;
let row2 = l.buttons.iter().find(|b| b.action == Action::Level { rows: true, level: 2 }).unwrap().rect;
assert!(row1.right() < row2.left() && (row1.center().y - row2.center().y).abs() < 0.01);
assert!(row1.center().y > g.head_top() && row1.center().y < g.cells.top() && row2.right() <= g.head_left());
// The level-1 row group's − is on row 9 (index 8) in lane 1; the inner group's on row 8.
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()
};
let outer = toggle(true, 1);
assert!(g.cell_rect(&sh, CellRef::new(8, 0)).y_range().contains(outer.rect.center().y));
assert!((outer.rect.center().x - row1.center().x).abs() < 0.01);
let inner = toggle(true, 2);
assert!(g.cell_rect(&sh, CellRef::new(7, 0)).y_range().contains(inner.rect.center().y));
assert!((inner.rect.center().x - row2.center().x).abs() < 0.01);
// Column group C:E: its − is above column F.
let col = toggle(false, 1);
assert!(g.cell_rect(&sh, CellRef::new(0, 5)).x_range().contains(col.rect.center().x));
assert!(col.rect.bottom() <= g.head_top());
// Brackets: one per expanded group, from the first detail line to its button.
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();
assert!((b.line[0].y - g.y(&sh, 4)).abs() < 1.0 && (b.line[1].y - outer.rect.top()).abs() < 0.01);
assert!(b.tick.is_some());
assert_eq!(outer.action.label(), "\u{2212}");
}
#[test]
fn collapsed_groups_show_plus_and_no_bracket() {
let mut sh = sheet();
for r in 4..=7 {
sh.rows.entry(r).or_default().hidden = true;
}
let g = geo(&sh);
let l = layout(&g, &sh);
let outer = l.buttons.iter().find(|b| matches!(b.action, Action::Toggle { rows: true, group } if group.level == 1)).unwrap();
assert_eq!(outer.action.label(), "+");
// The inner group's summary row is hidden with the rest: no button for it.
assert!(!l.buttons.iter().any(|b| matches!(b.action, Action::Toggle { rows: true, group } if group.level == 2)));
assert_eq!(l.brackets.len(), 1); // only the columns' bracket
assert_eq!(outer.action.command(), ("data.showDetail", json!({"rows": "5:8", "level": 1})));
}
#[test]
fn hit_finds_buttons_and_maps_them_to_commands() {
let sh = sheet();
let g = geo(&sh);
let l = layout(&g, &sh);
for b in &l.buttons {
assert_eq!(hit(&g, &sh, b.rect.center()), Some(b.action));
}
assert_eq!(hit(&g, &sh, g.cells.center()), None);
assert_eq!(hit(&g, &sh, pos2(g.rect.left() + 1.0, g.cells.bottom() - 2.0)), None);
let col = l.buttons.iter().find(|b| matches!(b.action, Action::Toggle { rows: false, .. })).unwrap();
assert_eq!(col.action.command(), ("data.hideDetail", json!({"cols": "C:E", "level": 1})));
assert_eq!(Action::Level { rows: true, level: 2 }.command(), ("data.showLevels", json!({"rows": 2})));
assert_eq!(Action::Level { rows: false, level: 1 }.command(), ("data.showLevels", json!({"cols": 1})));
}
#[test]
fn groups_scrolled_under_frozen_rows_stay_out_of_their_band() {
let mut sh = sheet();
sh.freeze = Some((10, 0));
// Rows 20:22 (summary row 23) scrolled under the ten frozen rows; rows 5:8 are frozen.
for r in 19..=21 {
sh.rows.entry(r).or_default().outline = 1;
}
let g = Geo::new(&sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 700.0)), vec2(0.0, 14.0 * sh.default_row_height));
let l = layout(&g, &sh);
let split = g.cells.top() + g.frozen_h;
let low = |grp: &OutlineGroup| grp.start == 19;
assert!(!l.buttons.iter().any(|b| matches!(b.action, Action::Toggle { group, .. } if low(&group))));
for b in l.buttons.iter().filter(|b| matches!(b.action, Action::Toggle { rows: true, group } if group.start >= 10)) {
assert!(b.rect.center().y >= split);
}
assert!(l.buttons.iter().any(|b| matches!(b.action, Action::Toggle { rows: true, group } if group.start == 4)));
}
}

View File

@ -1648,6 +1648,14 @@ fn data(app: &mut SheetApp, ui: &mut Ui) {
app.open_dialog("subtotal", json!({})); app.open_dialog("subtotal", json!({}));
} }
}); });
ui.vertical(|ui| {
if small_button(ui, Icon::Plus, "Show Detail", "Show Detail", false).clicked() {
act(app, "data.showDetail", json!({}));
}
if small_button(ui, Icon::Minus, "Hide Detail", "Hide Detail", false).clicked() {
act(app, "data.hideDetail", json!({}));
}
});
sep(ui); sep(ui);
if big_button(ui, Icon::ChartBar, "Data\nAnalysis", "Data Analysis", false).clicked() { if big_button(ui, Icon::ChartBar, "Data\nAnalysis", "Data Analysis", false).clicked() {
app.open_dialog("analysisToolPak", json!({})); app.open_dialog("analysisToolPak", json!({}));

View File

@ -102,6 +102,13 @@ fn windows_and_mac_variants() {
lookup(Key::Exclamationmark, Some(Key::Num1), CTRL_SHIFT, false), lookup(Key::Exclamationmark, Some(Key::Num1), CTRL_SHIFT, false),
Some(Shortcut::Run("home.numberFormat", json!({"code": "#,##0.00"}))) Some(Shortcut::Run("home.numberFormat", json!({"code": "#,##0.00"})))
); );
// Navigation keys go by what they are, not where: the numpad's arrows and Page Down (Num
// Lock off) report Num6 / Num3 as their physical keys.
let alt_shift = Modifiers { alt: true, shift: true, ..Modifiers::NONE };
assert_eq!(lookup(Key::ArrowRight, Some(Key::Num6), alt_shift, false), run("data.group"));
assert_eq!(lookup(Key::ArrowLeft, Some(Key::Num4), alt_shift, false), run("data.ungroup"));
assert_eq!(lookup(Key::PageDown, Some(Key::Num3), Modifiers::ALT, false), Some(Shortcut::ScreenRight));
assert_eq!(lookup(Key::PageDown, Some(Key::Num3), CTRL_SHIFT, false), None);
// Plain keys stay with the grid (typing, navigation). // Plain keys stay with the grid (typing, navigation).
assert_eq!(lookup(Key::A, Some(Key::A), Modifiers::NONE, false), None); assert_eq!(lookup(Key::A, Some(Key::A), Modifiers::NONE, false), None);
assert_eq!(lookup(Key::F2, Some(Key::F2), Modifiers::NONE, false), None); assert_eq!(lookup(Key::F2, Some(Key::F2), Modifiers::NONE, false), None);

View File

@ -0,0 +1,92 @@
//! The outline band of grouped rows and columns: − / + and level buttons (#373).
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::outline::{Action, layout};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness() -> Harness {
let mut session = Session::new();
session.new_workbook();
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(4);
h
}
fn click(h: &mut Harness, pos: Pos2) {
let press = |pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
h.input_mut().events.extend([Event::PointerMoved(pos), press(true)]);
h.step();
h.input_mut().events.push(press(false));
h.run_steps(3);
}
/// Where the button that does `which` is drawn this frame (screen coordinates).
fn button(h: &Harness, which: impl Fn(&Action) -> bool) -> Pos2 {
let app = h.state();
let geo = app.grid.geo.clone().unwrap();
let sh = app.session.active().unwrap().wb.active().unwrap().clone();
let b = layout(&geo, &sh).buttons.into_iter().find(|b| which(&b.action)).expect("button on screen");
geo.mirror_pos(b.rect.center())
}
fn hidden_rows(h: &Harness) -> Vec<u32> {
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect()
}
fn toggle(rows: bool, level: u8) -> impl Fn(&Action) -> bool {
move |a| matches!(a, Action::Toggle { rows: r, group } if *r == rows && group.level == level)
}
fn outline_buttons_collapse_and_expand(rtl: bool) {
let mut h = harness();
if rtl {
h.state_mut().session.run("view.rightToLeft", json!({"on": true})).unwrap();
}
h.state_mut().session.run("data.group", json!({"rows": "5:8"})).unwrap();
h.state_mut().session.run("data.group", json!({"cols": "C:E"})).unwrap();
h.run_steps(2);
let geo = h.state().grid.geo.clone().unwrap();
assert!(geo.band_w > 0.0 && geo.band_h > 0.0);
// − on row 9 hides rows 5:8 and turns into +.
let minus = button(&h, toggle(true, 1));
click(&mut h, minus);
assert_eq!(hidden_rows(&h), [4, 5, 6, 7]);
let plus = button(&h, toggle(true, 1));
click(&mut h, plus);
assert!(hidden_rows(&h).is_empty());
// Level 1 for the columns collapses C:E; level 2 shows them again.
let level1 = button(&h, |a| *a == Action::Level { rows: false, level: 1 });
click(&mut h, level1);
let col_hidden = |h: &Harness| (2..=4).all(|c| h.state().session.active().unwrap().wb.active().unwrap().col_width(c) == 0.0);
assert!(col_hidden(&h));
let level2 = button(&h, |a| *a == Action::Level { rows: false, level: 2 });
click(&mut h, level2);
assert!(!col_hidden(&h));
// Every click was a command: undo walks them back.
h.state_mut().session.run("edit.undo", json!({})).unwrap();
assert!(col_hidden(&h));
}
#[test]
fn outline_buttons_collapse_expand_and_undo() {
outline_buttons_collapse_and_expand(false);
}
#[test]
fn outline_buttons_work_on_right_to_left_sheets() {
outline_buttons_collapse_and_expand(true);
}

View File

@ -1,6 +1,6 @@
# UI parity # UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selected columns can be reordered by dragging their headers) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (outline band with − / + and level buttons for grouped rows and columns, #373) · **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). 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 **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
@ -43,6 +43,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes
| Point mode (click/drag/arrow references), reference colouring, F4 | done | | Point mode (click/drag/arrow references), reference colouring, F4 | done |
| AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) | | 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) | | Header resize and double-click AutoFit | done (#42, #88 for header dividers) |
| 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), no "Show outline symbols" option yet, and a block of cells still groups rows without the Rows / Columns dialog |
| Reorder selected whole columns by dragging their headers | done (10-11) | | Reorder selected whole columns by dragging their headers | done (10-11) |
| Freeze panes, split panes | freeze done; split to verify | | Freeze panes, split panes | freeze done; split to verify |
| Copy as HTML table for other apps | done (#36, 10-10) | | Copy as HTML table for other apps | done (#36, 10-10) |
@ -150,6 +151,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) |
| 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) | | 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) |
| 2026-10-11 | minor | Selected whole columns can be reordered by dragging their headers to an insertion point | | 2026-10-11 | minor | Selected whole columns can be reordered by dragging their headers to an insertion point |
| 2026-10-11 | minor | Page Layout view with rulers and in-place headers and footers (#329) | | 2026-10-11 | minor | Page Layout view with rulers and in-place headers and footers (#329) |