Merge pull request #388 from itrycodinganditsbad/fix-373
Draw the outline band for grouped rows and columns (#373)
This commit is contained in:
commit
773f7e6b76
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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"]);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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", "データ分析"),
|
||||||
|
|
|
||||||
|
|
@ -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…",
|
||||||
|
|
|
||||||
|
|
@ -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…",
|
||||||
|
|
|
||||||
|
|
@ -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…",
|
||||||
|
|
|
||||||
|
|
@ -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" => "데이터 분석",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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" => "Анализ данных",
|
||||||
|
|
|
||||||
|
|
@ -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" => "数据分析",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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!({}));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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) |
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue