From 535c502fe74b9f5eb508a78bcb967026662313b6 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:29:03 +0200 Subject: [PATCH] Draw the outline band for grouped rows and columns Grouped rows and columns now get Excel's outline band beside the headers: a bracket per group, a -/+ button on the summary line and level buttons in the corner. The buttons run data.hideDetail / data.showDetail and the new data.showLevels, so they undo. Hide Detail marks the summary line collapsed, Show Detail keeps inner groups that were collapsed on their own, and Group / Ungroup take by: rows|cols. Show Detail and Hide Detail are on the Data tab and in the Data menu, the group shortcuts are listed as Alt+Shift+Right / Left, and shortcut lookup reads navigation keys by their logical key so the numpad arrows work. Fixes #373 Co-Authored-By: Claude Opus 5.5 --- apps/gridcraft/src/native_menu.rs | 2 + crates/engine/src/cmd/data.rs | 205 +++++++++-- crates/engine/src/lib.rs | 1 + crates/engine/src/outline.rs | 133 +++++++ crates/ui-egui/src/grid.rs | 74 +++- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/es.rs | 2 + crates/ui-egui/src/i18n/ko.rs | 2 + crates/ui-egui/src/i18n/pt.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/zh.rs | 2 + crates/ui-egui/src/keymap.rs | 6 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/outline.rs | 400 +++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 8 + crates/ui-egui/tests/keyboard_shortcuts.rs | 7 + crates/ui-egui/tests/outline.rs | 92 +++++ docs/ui-parity.md | 4 +- 20 files changed, 915 insertions(+), 34 deletions(-) create mode 100644 crates/engine/src/outline.rs create mode 100644 crates/ui-egui/src/outline.rs create mode 100644 crates/ui-egui/tests/outline.rs diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 621b27a..5cf097d 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -160,6 +160,8 @@ fn tree() -> Vec<(&'static str, Vec)> { ("-", "", None), ("Group", "data.group", None), ("Ungroup", "data.ungroup", None), + ("Show Detail", "data.showDetail", None), + ("Hide Detail", "data.hideDetail", None), ("Subtotal…", "dialog:subtotal", None), ("-", "", None), ("Calculate Now", "formulas.calculateNow", None), diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 32ec866..2b7c0e0 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -8,6 +8,7 @@ use gridcraft_model::*; use serde_json::{Value as Json, json}; use super::*; +use crate::outline; use crate::selection::current_region; pub fn specs() -> Vec { @@ -74,10 +75,37 @@ pub fn specs() -> Vec { ), 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!("data.group", "Group", ["Data", "Outline"], Some("Cmd+Shift+K"), "{rows?: \"2:5\" | cols?: \"B:C\"}", has_doc, |s, p| group(s, p, true)), - cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Cmd+Shift+J"), "{rows? | cols?}", has_doc, |s, p| group(s, p, false)), - cmd!("data.hideDetail", "Hide Detail", ["Data", "Outline"], None, "{rows? | cols?}", has_doc, |s, p| detail(s, p, true)), - cmd!("data.showDetail", "Show Detail", ["Data", "Outline"], None, "{rows? | cols?}", has_doc, |s, p| detail(s, p, false)), + cmd!( + "data.group", + "Group", + ["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!( "data.subtotal", "Subtotal", @@ -1036,7 +1064,12 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> { return Ok((false, r.start.col, r.end.col)); } 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 { @@ -1045,39 +1078,94 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result { let si = cx.sheet_index(); let sh = cx.sheet_mut(si)?; 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(); - 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 { 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) }) } +/// 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 { 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| { let si = cx.sheet_index(); let sh = cx.sheet_mut(si)?; let map = if rows { &mut sh.rows } else { &mut sh.cols }; - // Expand to the whole group around the selection. - let level = (a..=b).filter_map(|i| map.get(&i).map(|e| e.outline)).max().unwrap_or(0); - if level == 0 { - return Ok(Json::Null); + let all = outline::groups(map); + 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(); + 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; - while lo > 0 && map.get(&(lo - 1)).is_some_and(|e| e.outline >= level) { - lo -= 1; + 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| 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; - while map.get(&(hi + 1)).is_some_and(|e| e.outline >= level) { - hi += 1; - } - for i in lo..=hi { - if let Some(e) = map.get_mut(&i) { - e.hidden = hide; + cx.structural = true; + Ok(Json::Null) + }) +} + +/// The outline's level buttons: show rows / columns down to outline level `n` (1 = only the +/// top-level summaries); deeper detail is hidden and its summary lines marked collapsed. +fn show_levels(s: &mut Session, p: &Json) -> Result { + 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; @@ -1176,6 +1264,81 @@ mod tests { 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) -> 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] fn split() { assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c6f0fb9..33a4927 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod cmd; pub mod display; pub mod fill; pub mod io; +pub mod outline; pub mod paper; pub mod pivot; pub mod pivot_calc; diff --git a/crates/engine/src/outline.rs b/crates/engine/src/outline.rs new file mode 100644 index 0000000..80d14af --- /dev/null +++ b/crates/engine/src/outline.rs @@ -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) -> 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) -> 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]; + let mut prev: Option = None; + let close_from = |open: &mut [Option<(u32, bool)>; MAX_LEVEL as usize], from: u8, end: u32, out: &mut Vec| { + 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, line: u32, level: u8) -> Option { + 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 { + 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); + } +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 820acf7..36aecd1 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -121,6 +121,12 @@ pub struct Geo { pub z: f32, pub header_w: 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, + pub col_outline: Arc, pub fr: u32, pub fc: u32, pub frozen_w: f32, @@ -151,8 +157,46 @@ 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 cells = Rect::from_min_max(pos2(rect.left() + header_w, rect.top() + header_h), rect.max); - 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 row_outline = Arc::new(crate::outline::Axis::new(&sh.rows)); + 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 /// 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_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t); } + crate::outline::paint(&painter, &geo, sh, &t); if geo.rtl { let mirror_to = painter.add(egui::Shape::Noop); mirror_painted(ui.ctx(), painter.layer_id(), mirror_from.0..mirror_to.0, &geo, &objects); @@ -1333,7 +1378,8 @@ pub(crate) fn paint_headers( quads: &[((u32, u32), (u32, u32), Rect)], ) { 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 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); @@ -1421,9 +1467,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & // 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 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_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.rect.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_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.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() && p.x >= geo.head_left() && p.y >= geo.head_top()); let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); let cur_rect = geo.range_rect(sh, sel.current()); let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); @@ -1604,6 +1650,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) { // 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 }; + // 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). if freeze_cols.contains(p) || freeze_rows.contains(p) { app.grid.drag = Drag::Freeze { cols: freeze_cols.contains(p), to: None }; @@ -2031,8 +2085,8 @@ fn on_border(r: Rect, p: Pos2) -> bool { pub fn freeze_handles(geo: &Geo) -> (Rect, Rect) { let x = geo.cells.left() + geo.frozen_w; 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 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 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.head_left(), y - 3.0), pos2(geo.head_left() + geo.header_w * 0.4, y + 3.0)); (cols, rows) } @@ -2070,10 +2124,10 @@ fn paint_freeze_handles(p: &Painter, geo: &Geo, sh: &Sheet, drag: &Drag, t: &Tok let stroke = Stroke::new(2.0, t.accent); if cols { 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 { 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); } } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 9b2831c..b0a1127 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -334,6 +334,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Clear Validation", "入力規則のクリア"), ("Group", "グループ化"), ("Ungroup", "グループ解除"), + ("Show Detail", "詳細データの表示"), + ("Hide Detail", "詳細を表示しない"), ("Subtotal", "小計"), ("Data\nAnalysis", "データ分析"), ("Data Analysis", "データ分析"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 65e9def..8385c25 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -164,6 +164,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Validation" => "Vymazat ověření", "Group" => "Seskupit", "Ungroup" => "Zrušit seskupení", + "Show Detail" => "Zobrazit podrobnosti", + "Hide Detail" => "Skrýt podrobnosti", "Subtotal" => "Mezisoučet", "What-If\nAnalysis" => "Citlivostní\nanalýza", "Data Table…" => "Tabulka dat…", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 5cb9881..58f891a 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -164,6 +164,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Validation" => "Datenüberprüfung löschen", "Group" => "Gruppieren", "Ungroup" => "Gruppierung aufheben", + "Show Detail" => "Details anzeigen", + "Hide Detail" => "Details ausblenden", "Subtotal" => "Teilergebnis", "What-If\nAnalysis" => "Was-wäre-wenn-\nAnalyse", "Data Table…" => "Datentabelle…", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index b30a1d1..e0b13fe 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -165,6 +165,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Validation" => "Borrar círculos de validación", "Group" => "Agrupar", "Ungroup" => "Desagrupar", + "Show Detail" => "Mostrar detalle", + "Hide Detail" => "Ocultar detalle", "Subtotal" => "Subtotal", "What-If\nAnalysis" => "Análisis de\nhipótesis", "Data Table…" => "Tabla de datos…", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index fb33cbd..6c65c06 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -171,6 +171,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Validation" => "유효성 검사 지우기", "Group" => "그룹", "Ungroup" => "그룹 해제", + "Show Detail" => "세부 정보 표시", + "Hide Detail" => "세부 정보 숨기기", "Subtotal" => "부분합", "Data\nAnalysis" => "데이터 분석", "Data Analysis" => "데이터 분석", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 35f1336..2c18fb2 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -172,6 +172,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Validation" => "Limpar Validação", "Group" => "Agrupar", "Ungroup" => "Desagrupar", + "Show Detail" => "Mostrar Detalhe", + "Hide Detail" => "Ocultar Detalhe", "Subtotal" => "Subtotal", "Data\nAnalysis" => "Análise de Dados", "Data Analysis" => "Análise de Dados", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index e50e5ff..71ae7d9 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -171,6 +171,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Validation" => "Очистить проверку данных", "Group" => "Группировать", "Ungroup" => "Разгруппировать", + "Show Detail" => "Показать детали", + "Hide Detail" => "Скрыть детали", "Subtotal" => "Промежуточный итог", "Data\nAnalysis" => "Анализ данных", "Data Analysis" => "Анализ данных", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index db71a16..dcf24c2 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -171,6 +171,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Validation" => "清除验证", "Group" => "组合", "Ungroup" => "取消组合", + "Show Detail" => "显示明细数据", + "Hide Detail" => "隐藏明细数据", "Subtotal" => "分类汇总", "Data\nAnalysis" => "数据分析", "Data Analysis" => "数据分析", diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 54ab319..29f2a27 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -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 /// 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, m: Modifiers, mac: bool) -> Option { 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, // and Alt/Option alone. let ctrl = m.ctrl && !m.alt; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7ff4ff7..1ac6f36 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -21,6 +21,7 @@ pub mod i18n; pub mod icons; pub mod keymap; pub mod keytips; +pub mod outline; pub mod panes; pub mod paper_view; pub mod pivot_pane; diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs new file mode 100644 index 0000000..7eaf133 --- /dev/null +++ b/crates/ui-egui/src/outline.rs @@ -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, +} + +impl Axis { + pub fn new(lines: &BTreeMap) -> 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