Files
gridcraft/crates/engine/src/outline.rs
T
Fabio LussiandClaude Opus 5.5 535c502fe7 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 <noreply@anthropic.com>
2026-10-11 21:29:03 +02:00

134 lines
5.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}