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(),

View File

@ -354,6 +354,10 @@ impl KeymapSet {
normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor");
normal.bind(KeyCode::Char('H'), none, "hide-selected-row-item");
// Drill into aggregated cell / view history
normal.bind(KeyCode::Char('>'), none, "drill-into-cell");
normal.bind(KeyCode::Char('<'), none, "view-back");
// Tile select
normal.bind(KeyCode::Char('T'), none, "enter-tile-select");
normal.bind(KeyCode::Left, ctrl, "enter-tile-select");

View File

@ -48,6 +48,25 @@ impl Group {
}
}
/// What kind of category this is.
/// Regular categories store their items explicitly. Virtual categories
/// are synthesized at query time by the layout layer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum CategoryKind {
#[default]
Regular,
/// Items are "0", "1", ... N where N = number of matching cells.
VirtualIndex,
/// Items are the names of all regular categories + "Value".
VirtualDim,
}
impl CategoryKind {
pub fn is_virtual(&self) -> bool {
!matches!(self, CategoryKind::Regular)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Category {
pub id: CategoryId,
@ -58,6 +77,9 @@ pub struct Category {
pub groups: Vec<Group>,
/// Next item id counter
next_item_id: ItemId,
/// Whether this is a regular or virtual category
#[serde(default)]
pub kind: CategoryKind,
}
impl Category {
@ -68,9 +90,15 @@ impl Category {
items: IndexMap::new(),
groups: Vec::new(),
next_item_id: 0,
kind: CategoryKind::Regular,
}
}
pub fn with_kind(mut self, kind: CategoryKind) -> Self {
self.kind = kind;
self
}
pub fn add_item(&mut self, name: impl Into<String>) -> ItemId {
let name = name.into();
if let Some(item) = self.items.get(&name) {

View File

@ -28,25 +28,48 @@ pub struct Model {
impl Model {
pub fn new(name: impl Into<String>) -> Self {
use crate::model::category::CategoryKind;
let name = name.into();
let default_view = View::new("Default");
let mut views = IndexMap::new();
views.insert("Default".to_string(), default_view);
Self {
let mut categories = IndexMap::new();
// Virtual categories — always present, default to Axis::None
categories.insert(
"_Index".to_string(),
Category::new(0, "_Index").with_kind(CategoryKind::VirtualIndex),
);
categories.insert(
"_Dim".to_string(),
Category::new(1, "_Dim").with_kind(CategoryKind::VirtualDim),
);
let mut m = Self {
name,
categories: IndexMap::new(),
categories,
data: DataStore::new(),
formulas: Vec::new(),
views,
active_view: "Default".to_string(),
next_category_id: 0,
next_category_id: 2,
measure_agg: HashMap::new(),
};
// Add virtuals to existing views (default view)
for view in m.views.values_mut() {
view.on_category_added("_Index");
view.on_category_added("_Dim");
}
m
}
pub fn add_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
let name = name.into();
if self.categories.len() >= MAX_CATEGORIES {
// Count only regular categories for the limit
let regular_count = self
.categories
.values()
.filter(|c| !c.kind.is_virtual())
.count();
if regular_count >= MAX_CATEGORIES {
return Err(anyhow!("Maximum of {MAX_CATEGORIES} categories reached"));
}
if self.categories.contains_key(&name) {
@ -157,7 +180,17 @@ impl Model {
}
/// Return all category names
/// Names of all regular (non-virtual) categories.
pub fn category_names(&self) -> Vec<&str> {
self.categories
.iter()
.filter(|(_, c)| !c.kind.is_virtual())
.map(|(s, _)| s.as_str())
.collect()
}
/// Names of all categories including virtual ones.
pub fn all_category_names(&self) -> Vec<&str> {
self.categories.keys().map(|s| s.as_str()).collect()
}
@ -399,7 +432,7 @@ mod model_tests {
let id1 = m.add_category("Region").unwrap();
let id2 = m.add_category("Region").unwrap();
assert_eq!(id1, id2);
assert_eq!(m.categories.len(), 1);
assert_eq!(m.category_names().len(), 1);
}
#[test]
@ -1367,12 +1400,12 @@ mod five_category {
#[test]
fn five_categories_well_within_limit() {
let m = build_model();
assert_eq!(m.categories.len(), 5);
assert_eq!(m.category_names().len(), 5);
let mut m2 = build_model();
for i in 0..7 {
m2.add_category(format!("Extra{i}")).unwrap();
}
assert_eq!(m2.categories.len(), 12);
assert_eq!(m2.category_names().len(), 12);
assert!(m2.add_category("OneMore").is_err());
}
}

View File

@ -67,6 +67,11 @@ pub struct App {
pub yanked: Option<CellValue>,
/// Tile select cursor (which category index is highlighted)
pub tile_cat_idx: usize,
/// View navigation history: views visited before the current one.
/// Pushed on SwitchView, popped by `<` (back).
pub view_back_stack: Vec<String>,
/// Views that were "back-ed" from, available for forward navigation (`>`).
pub view_forward_stack: Vec<String>,
/// Named text buffers for text-entry modes
pub buffers: HashMap<String, String>,
/// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.)
@ -94,6 +99,8 @@ impl App {
dirty: false,
yanked: None,
tile_cat_idx: 0,
view_back_stack: Vec::new(),
view_forward_stack: Vec::new(),
buffers: HashMap::new(),
transient_keymap: None,
keymap_set: KeymapSet::default_keymaps(),
@ -125,6 +132,9 @@ impl App {
cell_key: layout.cell_key(sel_row, sel_col),
row_count: layout.row_count(),
col_count: layout.col_count(),
none_cats: layout.none_cats.clone(),
view_back_stack: self.view_back_stack.clone(),
view_forward_stack: self.view_forward_stack.clone(),
key_code: key,
}
}
@ -184,7 +194,7 @@ impl App {
/// Hint text for the status bar (context-sensitive)
pub fn hint_text(&self) -> &'static str {
match &self.mode {
AppMode::Normal => "hjkl:nav Enter:advance i:edit x:clear t:transpose /:search F/C/V:panels T:tiles [:]:page ::cmd",
AppMode::Normal => "hjkl:nav Enter:advance i:edit x:clear t:transpose /:search F/C/V:panels T:tiles [:]:page >:drill ::cmd",
AppMode::Editing { .. } => "Enter:commit Esc:cancel",
AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back",
AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",

View File

@ -112,10 +112,41 @@ impl Effect for DeleteView {
pub struct SwitchView(pub String);
impl Effect for SwitchView {
fn apply(&self, app: &mut App) {
let current = app.model.active_view.clone();
if current != self.0 {
app.view_back_stack.push(current);
app.view_forward_stack.clear();
}
let _ = app.model.switch_view(&self.0);
}
}
/// Go back in view history (pop back stack, push current to forward stack).
#[derive(Debug)]
pub struct ViewBack;
impl Effect for ViewBack {
fn apply(&self, app: &mut App) {
if let Some(prev) = app.view_back_stack.pop() {
let current = app.model.active_view.clone();
app.view_forward_stack.push(current);
let _ = app.model.switch_view(&prev);
}
}
}
/// Go forward in view history (pop forward stack, push current to back stack).
#[derive(Debug)]
pub struct ViewForward;
impl Effect for ViewForward {
fn apply(&self, app: &mut App) {
if let Some(next) = app.view_forward_stack.pop() {
let current = app.model.active_view.clone();
app.view_back_stack.push(current);
let _ = app.model.switch_view(&next);
}
}
}
#[derive(Debug)]
pub struct SetAxis {
pub category: String,

View File

@ -41,15 +41,20 @@ impl View {
pub fn on_category_added(&mut self, cat_name: &str) {
if !self.category_axes.contains_key(cat_name) {
// Auto-assign: first → Row, second → Column, rest → Page
let rows = self.categories_on(Axis::Row).len();
let cols = self.categories_on(Axis::Column).len();
let axis = if rows == 0 {
Axis::Row
} else if cols == 0 {
Axis::Column
// Virtual categories (names starting with `_`) default to Axis::None.
// Regular categories auto-assign: first → Row, second → Column, rest → Page.
let axis = if cat_name.starts_with('_') {
Axis::None
} else {
Axis::Page
let rows = self.categories_on(Axis::Row).len();
let cols = self.categories_on(Axis::Column).len();
if rows == 0 {
Axis::Row
} else if cols == 0 {
Axis::Column
} else {
Axis::Page
}
};
self.category_axes.insert(cat_name.to_string(), axis);
}