feat: add view history navigation and drill-into-cell

Add view navigation history with back/forward stacks (bound to < and >).

Introduce CategoryKind enum to distinguish regular categories from
virtual ones (_Index, _Dim) that are synthesized at query time.

Add DrillIntoCell command that creates a drill view showing raw data
for an aggregated cell, expanding categories on Axis::None into Row
and Column axes while filtering by the cell's fixed coordinates.

Virtual categories default to Axis::None and are automatically added
to all views when the model is initialized.

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 10:57:28 -07:00
parent b2d633eb7d
commit 67041dd4a5
7 changed files with 253 additions and 16 deletions

View File

@ -36,6 +36,11 @@ pub struct CmdContext<'a> {
/// Grid dimensions (so commands don't need GridLayout)
pub row_count: usize,
pub col_count: usize,
/// Categories on Axis::None — aggregated away in the current view
pub none_cats: Vec<String>,
/// View navigation stacks (for drill back/forward)
pub view_back_stack: Vec<String>,
pub view_forward_stack: Vec<String>,
/// The key that triggered this command
pub key_code: KeyCode,
}
@ -968,6 +973,105 @@ impl Cmd for HideSelectedRowItem {
}
}
/// Navigate back in view history.
#[derive(Debug)]
pub struct ViewBackCmd;
impl Cmd for ViewBackCmd {
fn name(&self) -> &'static str {
"view-back"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
if ctx.view_back_stack.is_empty() {
vec![effect::set_status("No previous view")]
} else {
vec![Box::new(effect::ViewBack)]
}
}
}
/// Navigate forward in view history.
#[derive(Debug)]
pub struct ViewForwardCmd;
impl Cmd for ViewForwardCmd {
fn name(&self) -> &'static str {
"view-forward"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
if ctx.view_forward_stack.is_empty() {
vec![effect::set_status("No forward view")]
} else {
vec![Box::new(effect::ViewForward)]
}
}
}
/// 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.
#[derive(Debug)]
pub struct DrillIntoCell {
pub key: crate::model::cell::CellKey,
}
impl Cmd for DrillIntoCell {
fn name(&self) -> &'static str {
"drill-into-cell"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let drill_name = "_Drill".to_string();
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
// Create (or replace) the drill view
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;
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;
}
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(effect::set_status("Drilled into cell"));
effects
}
}
/// Enter tile select mode.
#[derive(Debug)]
pub struct EnterTileSelect;
@ -2202,6 +2306,25 @@ pub fn default_registry() -> CmdRegistry {
r.register_nullary(|| Box::new(EnterExportPrompt));
r.register_nullary(|| Box::new(EnterFormulaEdit));
r.register_nullary(|| Box::new(EnterTileSelect));
r.register(
&DrillIntoCell {
key: crate::model::cell::CellKey::new(vec![]),
},
|args| {
if args.is_empty() {
return Err("drill-into-cell requires Cat/Item coordinates".into());
}
Ok(Box::new(DrillIntoCell {
key: parse_cell_key_from_args(args),
}))
},
|_args, ctx| {
let key = ctx.cell_key.clone().ok_or("no cell at cursor")?;
Ok(Box::new(DrillIntoCell { key }))
},
);
r.register_nullary(|| Box::new(ViewBackCmd));
r.register_nullary(|| Box::new(ViewForwardCmd));
r.register_pure(&NamedCmd("enter-mode"), |args| {
require_args("enter-mode", args, 1)?;
let mode = match args[0].as_str() {
@ -2464,6 +2587,9 @@ mod tests {
view_panel_cursor: 0,
tile_cat_idx: 0,
buffers: &EMPTY_BUFFERS,
none_cats: layout.none_cats.clone(),
view_back_stack: Vec::new(),
view_forward_stack: Vec::new(),
cell_key: layout.cell_key(sr, sc),
row_count: layout.row_count(),
col_count: layout.col_count(),