diff --git a/src/command/cmd.rs b/src/command/cmd.rs index e52e615..2ed9f9f 100644 --- a/src/command/cmd.rs +++ b/src/command/cmd.rs @@ -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 = 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] diff --git a/src/model/types.rs b/src/model/types.rs index 0476c66..a47a24d 100644 --- a/src/model/types.rs +++ b/src/model/types.rs @@ -63,7 +63,7 @@ impl Model { pub fn add_category(&mut self, name: impl Into) -> Result { let name = name.into(); - // Count only regular categories for the limit + // Virtuals don't count against the regular category limit let regular_count = self .categories .values() @@ -180,17 +180,8 @@ impl Model { } /// Return all category names - /// Names of all regular (non-virtual) categories. + /// Names of all categories (including virtual ones). 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() } @@ -432,7 +423,8 @@ mod model_tests { let id1 = m.add_category("Region").unwrap(); let id2 = m.add_category("Region").unwrap(); assert_eq!(id1, id2); - assert_eq!(m.category_names().len(), 1); + // Region + 2 virtuals (_Index, _Dim) + assert_eq!(m.category_names().len(), 3); } #[test] @@ -1400,12 +1392,14 @@ mod five_category { #[test] fn five_categories_well_within_limit() { let m = build_model(); - assert_eq!(m.category_names().len(), 5); + // 5 regular + 2 virtual (_Index, _Dim) + assert_eq!(m.category_names().len(), 7); let mut m2 = build_model(); for i in 0..7 { m2.add_category(format!("Extra{i}")).unwrap(); } - assert_eq!(m2.category_names().len(), 12); + // 12 regular + 2 virtuals = 14 + assert_eq!(m2.category_names().len(), 14); assert!(m2.add_category("OneMore").is_err()); } } diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 80b3482..2912448 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -459,11 +459,15 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> { } let row_values: Vec = (0..layout.col_count()) .map(|ci| { - layout - .cell_key(ri, ci) - .and_then(|key| model.evaluate_aggregated(&key, &layout.none_cats)) - .map(|v| v.to_string()) - .unwrap_or_default() + if layout.is_records_mode() { + layout.records_display(ri, ci).unwrap_or_default() + } else { + layout + .cell_key(ri, ci) + .and_then(|key| model.evaluate_aggregated(&key, &layout.none_cats)) + .map(|v| v.to_string()) + .unwrap_or_default() + } }) .collect(); out.push_str(&row_values.join(",")); diff --git a/src/ui/grid.rs b/src/ui/grid.rs index 337f11b..ad06671 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -291,16 +291,27 @@ impl<'a> GridWidget<'a> { break; } - let key = match layout.cell_key(ri, ci) { - Some(k) => k, - None => { - x += COL_WIDTH; - continue; - } + let (cell_str, value) = if layout.is_records_mode() { + let s = layout.records_display(ri, ci).unwrap_or_default(); + // In records mode the value is a string, not aggregated + let v = if !s.is_empty() { + Some(crate::model::cell::CellValue::Text(s.clone())) + } else { + None + }; + (s, v) + } else { + let key = match layout.cell_key(ri, ci) { + Some(k) => k, + None => { + x += COL_WIDTH; + continue; + } + }; + let value = self.model.evaluate_aggregated(&key, &layout.none_cats); + let s = format_value(value.as_ref(), fmt_comma, fmt_decimals); + (s, value) }; - let value = self.model.evaluate_aggregated(&key, &layout.none_cats); - - let cell_str = format_value(value.as_ref(), fmt_comma, fmt_decimals); let is_selected = ri == sel_row && ci == sel_col; let is_search_match = !self.search_query.is_empty() && cell_str diff --git a/src/view/layout.rs b/src/view/layout.rs index 0b7c40b..3371059 100644 --- a/src/view/layout.rs +++ b/src/view/layout.rs @@ -1,4 +1,4 @@ -use crate::model::cell::CellKey; +use crate::model::cell::{CellKey, CellValue}; use crate::model::Model; use crate::view::{Axis, View}; @@ -29,6 +29,9 @@ pub struct GridLayout { pub col_items: Vec, /// Categories on `Axis::None` — hidden, implicitly aggregated. pub none_cats: Vec, + /// In records mode: the filtered cell list, one per row. + /// None for normal pivot views. + pub records: Option>, } impl GridLayout { @@ -75,19 +78,107 @@ impl GridLayout { }) .collect(); - let row_items = cross_product(model, view, &row_cats); - let col_items = cross_product(model, view, &col_cats); + // Detect records mode: _Index on Row and _Dim on Col + let is_records_mode = + row_cats.iter().any(|c| c == "_Index") && col_cats.iter().any(|c| c == "_Dim"); + + if is_records_mode { + Self::build_records_mode(model, view, page_coords, none_cats) + } else { + let row_items = cross_product(model, view, &row_cats); + let col_items = cross_product(model, view, &col_cats); + Self { + row_cats, + col_cats, + page_coords, + row_items, + col_items, + none_cats, + records: None, + } + } + } + + /// Build a records-mode layout: rows are individual cells, columns are + /// category names + "Value". Cells matching the page filter are enumerated. + fn build_records_mode( + model: &Model, + _view: &View, + page_coords: Vec<(String, String)>, + none_cats: Vec, + ) -> Self { + // Filter cells by page_coords + let partial: Vec<(String, String)> = page_coords.clone(); + let mut records: Vec<(CellKey, CellValue)> = if partial.is_empty() { + model + .data + .iter_cells() + .map(|(k, v)| (k, v.clone())) + .collect() + } else { + model + .data + .matching_cells(&partial) + .into_iter() + .map(|(k, v)| (k, v.clone())) + .collect() + }; + // Sort for deterministic ordering + records.sort_by(|a, b| a.0.0.cmp(&b.0.0)); + + // Synthesize row items: one per record, labeled with its index + let row_items: Vec = (0..records.len()) + .map(|i| AxisEntry::DataItem(vec![i.to_string()])) + .collect(); + + // Synthesize col items: one per regular category + "Value" + let cat_names: Vec = model + .category_names() + .into_iter() + .map(String::from) + .collect(); + let mut col_items: Vec = cat_names + .iter() + .map(|c| AxisEntry::DataItem(vec![c.clone()])) + .collect(); + col_items.push(AxisEntry::DataItem(vec!["Value".to_string()])); Self { - row_cats, - col_cats, + row_cats: vec!["_Index".to_string()], + col_cats: vec!["_Dim".to_string()], page_coords, row_items, col_items, none_cats, + records: Some(records), } } + /// Get the display string for the cell at (row, col) in records mode. + /// Returns None for normal (non-records) layouts. + pub fn records_display(&self, row: usize, col: usize) -> Option { + let records = self.records.as_ref()?; + let record = records.get(row)?; + let col_item = self.col_label(col); + if col_item == "Value" { + Some(record.1.to_string()) + } else { + // col_item is a category name + let found = record + .0 + .0 + .iter() + .find(|(c, _)| c == &col_item) + .map(|(_, v)| v.clone()); + Some(found.unwrap_or_default()) + } + } + + /// Whether this layout is in records mode. + pub fn is_records_mode(&self) -> bool { + self.records.is_some() + } + /// Number of data rows (group headers excluded). pub fn row_count(&self) -> usize { self.row_items