feat: add records mode (long-format view) for drill-down

Implement records mode (long-format view) when drilling into aggregated cells.

Key changes:
- DrillIntoCell now creates views with _Index on Row and _Dim on Column
- GridLayout detects records mode and builds a records list instead of
  cross-product row/col items
- Added records_display() to render individual cell values in records mode
- GridWidget and CSV export updated to handle records mode rendering
- category_names() now includes virtual categories (_Index, _Dim)
- Tests updated to reflect virtual categories counting toward limits

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
This commit is contained in:
Edward Langley
2026-04-05 11:10:41 -07:00
parent 67041dd4a5
commit 19645a34cf
5 changed files with 166 additions and 71 deletions

View File

@ -1005,10 +1005,9 @@ impl Cmd for ViewForwardCmd {
}
}
/// Drill down into an aggregated cell: create a _Drill view that shows the
/// raw (un-aggregated) data for this cell. Categories on Axis::None in the
/// current view become visible (Row + Column) in the drill view; the cell's
/// fixed coordinates become page filters.
/// Drill down into an aggregated cell: create a _Drill view with _Index on
/// Row and _Dim on Column (records/long-format view). Fixed coordinates
/// from the drilled cell become page filters.
#[derive(Debug)]
pub struct DrillIntoCell {
pub key: crate::model::cell::CellKey,
@ -1025,46 +1024,40 @@ impl Cmd for DrillIntoCell {
effects.push(Box::new(effect::CreateView(drill_name.clone())));
effects.push(Box::new(effect::SwitchView(drill_name)));
// All categories currently exist. Set axes:
// - none_cats → Row (first) and Column (rest) to expand them
// - cell_key cats → Page with their specific items (filter)
// - other cats (not in cell_key or none_cats) → Page as well
let none_cats = &ctx.none_cats;
// Records mode: _Index on Row, _Dim on Column
effects.push(Box::new(effect::SetAxis {
category: "_Index".to_string(),
axis: crate::view::Axis::Row,
}));
effects.push(Box::new(effect::SetAxis {
category: "_Dim".to_string(),
axis: crate::view::Axis::Column,
}));
// Fixed coords (from drilled cell) → Page with that value as filter
let fixed_cats: std::collections::HashSet<String> =
self.key.0.iter().map(|(c, _)| c.clone()).collect();
for (i, cat) in none_cats.iter().enumerate() {
let axis = if i == 0 {
crate::view::Axis::Row
} else {
crate::view::Axis::Column
};
effects.push(Box::new(effect::SetAxis {
category: cat.clone(),
axis,
}));
}
// All other categories → Page, with the cell's value as the page selection
for cat_name in ctx.model.category_names() {
let cat = cat_name.to_string();
if none_cats.contains(&cat) {
continue;
}
for (cat, item) in &self.key.0 {
effects.push(Box::new(effect::SetAxis {
category: cat.clone(),
axis: crate::view::Axis::Page,
}));
// If this category was in the drilled cell's key, fix its page
// selection to the cell's value
if fixed_cats.contains(&cat) {
if let Some((_, item)) = self.key.0.iter().find(|(c, _)| c == &cat) {
effects.push(Box::new(effect::SetPageSelection {
category: cat,
item: item.clone(),
}));
}
effects.push(Box::new(effect::SetPageSelection {
category: cat.clone(),
item: item.clone(),
}));
}
// Previously-aggregated categories (none_cats) stay on Axis::None so
// they don't filter records; they'll appear as columns in records mode.
// Skip virtual categories — we already set _Index/_Dim above.
for cat in &ctx.none_cats {
if fixed_cats.contains(cat) || cat.starts_with('_') {
continue;
}
effects.push(Box::new(effect::SetAxis {
category: cat.clone(),
axis: crate::view::Axis::None,
}));
}
effects.push(effect::set_status("Drilled into cell"));
@ -2782,11 +2775,13 @@ mod tests {
#[test]
fn enter_tile_select_no_categories() {
// Models always have virtual categories (_Index, _Dim), so tile
// select always has something to operate on.
let m = Model::new("Empty");
let ctx = make_ctx(&m);
let cmd = EnterTileSelect;
let effects = cmd.execute(&ctx);
assert!(effects.is_empty());
assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode
}
#[test]