From 67041dd4a56d7fe48142eb9e78c39a61b0c8efff Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 5 Apr 2026 10:57:28 -0700 Subject: [PATCH] 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) --- src/command/cmd.rs | 126 ++++++++++++++++++++++++++++++++++++++++++ src/command/keymap.rs | 4 ++ src/model/category.rs | 28 ++++++++++ src/model/types.rs | 47 +++++++++++++--- src/ui/app.rs | 12 +++- src/ui/effect.rs | 31 +++++++++++ src/view/types.rs | 21 ++++--- 7 files changed, 253 insertions(+), 16 deletions(-) diff --git a/src/command/cmd.rs b/src/command/cmd.rs index 337ecda..e52e615 100644 --- a/src/command/cmd.rs +++ b/src/command/cmd.rs @@ -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, + /// View navigation stacks (for drill back/forward) + pub view_back_stack: Vec, + pub view_forward_stack: Vec, /// 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> { + 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> { + 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> { + let drill_name = "_Drill".to_string(); + let mut effects: Vec> = 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 = + 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(), diff --git a/src/command/keymap.rs b/src/command/keymap.rs index 325429c..1c150cf 100644 --- a/src/command/keymap.rs +++ b/src/command/keymap.rs @@ -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"); diff --git a/src/model/category.rs b/src/model/category.rs index bac860f..208512e 100644 --- a/src/model/category.rs +++ b/src/model/category.rs @@ -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, /// 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) -> ItemId { let name = name.into(); if let Some(item) = self.items.get(&name) { diff --git a/src/model/types.rs b/src/model/types.rs index 6f3451a..0476c66 100644 --- a/src/model/types.rs +++ b/src/model/types.rs @@ -28,25 +28,48 @@ pub struct Model { impl Model { pub fn new(name: impl Into) -> 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) -> Result { 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()); } } diff --git a/src/ui/app.rs b/src/ui/app.rs index 258d9e3..84c10c0 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -67,6 +67,11 @@ pub struct App { pub yanked: Option, /// 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, + /// Views that were "back-ed" from, available for forward navigation (`>`). + pub view_forward_stack: Vec, /// Named text buffers for text-entry modes pub buffers: HashMap, /// 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", diff --git a/src/ui/effect.rs b/src/ui/effect.rs index 01cbd90..7cf06df 100644 --- a/src/ui/effect.rs +++ b/src/ui/effect.rs @@ -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, diff --git a/src/view/types.rs b/src/view/types.rs index 57dcfba..22eec22 100644 --- a/src/view/types.rs +++ b/src/view/types.rs @@ -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); }