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:
@ -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(),
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user