diff --git a/src/command/cmd.rs b/src/command/cmd.rs index 2ed9f9f..0d3f01a 100644 --- a/src/command/cmd.rs +++ b/src/command/cmd.rs @@ -41,6 +41,12 @@ pub struct CmdContext<'a> { /// View navigation stacks (for drill back/forward) pub view_back_stack: Vec, pub view_forward_stack: Vec, + /// Records-mode info (drill view). None for normal pivot views. + /// When Some, edits stage to drill_state.pending_edits. + pub records_col: Option, + /// The display value at the cursor in records mode (including any + /// pending edit override). None for normal pivot views. + pub records_value: Option, /// The key that triggered this command pub key_code: KeyCode, } @@ -984,7 +990,11 @@ impl Cmd for ViewBackCmd { if ctx.view_back_stack.is_empty() { vec![effect::set_status("No previous view")] } else { - vec![Box::new(effect::ViewBack)] + // Apply any pending drill edits first, then navigate back. + vec![ + Box::new(effect::ApplyAndClearDrill), + Box::new(effect::ViewBack), + ] } } } @@ -1020,6 +1030,27 @@ impl Cmd for DrillIntoCell { let drill_name = "_Drill".to_string(); let mut effects: Vec> = Vec::new(); + // Capture the records snapshot NOW (before we switch views). + let records: Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)> = + if self.key.0.is_empty() { + ctx.model + .data + .iter_cells() + .map(|(k, v)| (k, v.clone())) + .collect() + } else { + ctx.model + .data + .matching_cells(&self.key.0) + .into_iter() + .map(|(k, v)| (k, v.clone())) + .collect() + }; + let n = records.len(); + + // Freeze the snapshot in the drill state + effects.push(Box::new(effect::StartDrill(records))); + // Create (or replace) the drill view effects.push(Box::new(effect::CreateView(drill_name.clone()))); effects.push(Box::new(effect::SwitchView(drill_name))); @@ -1060,7 +1091,7 @@ impl Cmd for DrillIntoCell { })); } - effects.push(effect::set_status("Drilled into cell")); + effects.push(effect::set_status(format!("Drilled into cell: {n} rows"))); effects } } @@ -1599,10 +1630,15 @@ impl Cmd for PopChar { // ── Commit commands (mode-specific buffer consumers) ──────────────────────── /// Commit a cell edit: set cell value, advance cursor, return to Normal. +/// In records mode, stages the edit in drill_state.pending_edits instead of +/// writing directly to the model. #[derive(Debug)] pub struct CommitCellEdit { pub key: crate::model::cell::CellKey, pub value: String, + /// Records-mode edit: (record_idx, column_name). When Some, stage in + /// pending_edits; otherwise write to the model directly. + pub records_edit: Option<(usize, String)>, } impl Cmd for CommitCellEdit { fn name(&self) -> &'static str { @@ -1611,20 +1647,29 @@ impl Cmd for CommitCellEdit { fn execute(&self, ctx: &CmdContext) -> Vec> { let mut effects: Vec> = Vec::new(); - if self.value.is_empty() { + if let Some((record_idx, col_name)) = &self.records_edit { + // Stage the edit in drill_state.pending_edits + effects.push(Box::new(effect::SetDrillPendingEdit { + record_idx: *record_idx, + col_name: col_name.clone(), + new_value: self.value.clone(), + })); + } else if self.value.is_empty() { effects.push(Box::new(effect::ClearCell(self.key.clone()))); + effects.push(effect::mark_dirty()); } else if let Ok(n) = self.value.parse::() { effects.push(Box::new(effect::SetCell( self.key.clone(), CellValue::Number(n), ))); + effects.push(effect::mark_dirty()); } else { effects.push(Box::new(effect::SetCell( self.key.clone(), CellValue::Text(self.value.clone()), ))); + effects.push(effect::mark_dirty()); } - effects.push(effect::mark_dirty()); effects.push(effect::change_mode(AppMode::Normal)); // Advance cursor down (typewriter-style) let adv = EnterAdvance { @@ -2501,7 +2546,11 @@ pub fn default_registry() -> CmdRegistry { // ── Commit ─────────────────────────────────────────────────────────── r.register( - &CommitCellEdit { key: CellKey::new(vec![]), value: String::new() }, + &CommitCellEdit { + key: CellKey::new(vec![]), + value: String::new(), + records_edit: None, + }, |args| { // parse: commit-cell-edit ... if args.len() < 2 { @@ -2510,12 +2559,26 @@ pub fn default_registry() -> CmdRegistry { Ok(Box::new(CommitCellEdit { key: parse_cell_key_from_args(&args[1..]), value: args[0].clone(), + records_edit: None, })) }, |_args, ctx| { - let key = ctx.cell_key.clone().ok_or("no cell at cursor")?; let value = read_buffer(ctx, "edit"); - Ok(Box::new(CommitCellEdit { key, value })) + // In records mode, stage the edit instead of writing to the model + if let Some(col_name) = &ctx.records_col { + let record_idx = ctx.selected.0; + return Ok(Box::new(CommitCellEdit { + key: CellKey::new(vec![]), // ignored in records mode + value, + records_edit: Some((record_idx, col_name.clone())), + })); + } + let key = ctx.cell_key.clone().ok_or("no cell at cursor")?; + Ok(Box::new(CommitCellEdit { + key, + value, + records_edit: None, + })) }, ); r.register_nullary(|| Box::new(CommitFormula)); @@ -2583,6 +2646,7 @@ mod tests { none_cats: layout.none_cats.clone(), view_back_stack: Vec::new(), view_forward_stack: Vec::new(), + records_col: None, cell_key: layout.cell_key(sr, sc), row_count: layout.row_count(), col_count: layout.col_count(), diff --git a/src/draw.rs b/src/draw.rs index b7439d2..a54d91d 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -245,7 +245,13 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) { } f.render_widget( - GridWidget::new(&app.model, &app.mode, &app.search_query, &app.buffers), + GridWidget::new( + &app.model, + &app.mode, + &app.search_query, + &app.buffers, + app.drill_state.as_ref(), + ), grid_area, ); } diff --git a/src/ui/app.rs b/src/ui/app.rs index 84c10c0..689418b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -13,6 +13,20 @@ use crate::model::Model; use crate::persistence; use crate::view::GridLayout; +/// Drill-down state: frozen record snapshot + pending edits that have not +/// yet been applied to the model. +#[derive(Debug, Clone, Default)] +pub struct DrillState { + /// Frozen snapshot of records shown in the drill view. + pub records: Vec<( + crate::model::cell::CellKey, + crate::model::cell::CellValue, + )>, + /// Pending edits keyed by (record_idx, column_name) → new string value. + /// column_name is either "Value" or a category name. + pub pending_edits: std::collections::HashMap<(usize, String), String>, +} + #[derive(Debug, Clone, PartialEq)] pub enum AppMode { Normal, @@ -72,6 +86,11 @@ pub struct App { pub view_back_stack: Vec, /// Views that were "back-ed" from, available for forward navigation (`>`). pub view_forward_stack: Vec, + /// Frozen records list for the drill view. When present, this is the + /// snapshot that records-mode layouts iterate — records don't disappear + /// when filters would change. Pending edits are stored alongside and + /// applied to the model on commit/navigate-away. + pub drill_state: Option, /// Named text buffers for text-entry modes pub buffers: HashMap, /// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.) @@ -101,6 +120,7 @@ impl App { tile_cat_idx: 0, view_back_stack: Vec::new(), view_forward_stack: Vec::new(), + drill_state: None, buffers: HashMap::new(), transient_keymap: None, keymap_set: KeymapSet::default_keymaps(), @@ -109,7 +129,8 @@ impl App { pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> { let view = self.model.active_view(); - let layout = GridLayout::new(&self.model, view); + let frozen_records = self.drill_state.as_ref().map(|s| s.records.clone()); + let layout = GridLayout::with_frozen_records(&self.model, view, frozen_records); let (sel_row, sel_col) = view.selected; CmdContext { model: &self.model, @@ -135,6 +156,21 @@ impl App { none_cats: layout.none_cats.clone(), view_back_stack: self.view_back_stack.clone(), view_forward_stack: self.view_forward_stack.clone(), + records_col: if layout.is_records_mode() { + Some(layout.col_label(sel_col)) + } else { + None + }, + records_value: if layout.is_records_mode() { + // Check pending edits first, then fall back to original + let col_name = layout.col_label(sel_col); + let pending = self.drill_state.as_ref().and_then(|s| { + s.pending_edits.get(&(sel_row, col_name.clone())).cloned() + }); + pending.or_else(|| layout.records_display(sel_row, sel_col)) + } else { + None + }, key_code: key, } } diff --git a/src/ui/effect.rs b/src/ui/effect.rs index 7cf06df..0945091 100644 --- a/src/ui/effect.rs +++ b/src/ui/effect.rs @@ -337,6 +337,91 @@ impl Effect for SetTileCatIdx { } } +/// Populate the drill state with a frozen snapshot of records. +/// Clears any previous drill state. +#[derive(Debug)] +pub struct StartDrill(pub Vec<(CellKey, CellValue)>); +impl Effect for StartDrill { + fn apply(&self, app: &mut App) { + app.drill_state = Some(super::app::DrillState { + records: self.0.clone(), + pending_edits: std::collections::HashMap::new(), + }); + } +} + +/// Apply any pending edits to the model and clear the drill state. +#[derive(Debug)] +pub struct ApplyAndClearDrill; +impl Effect for ApplyAndClearDrill { + fn apply(&self, app: &mut App) { + let Some(drill) = app.drill_state.take() else { + return; + }; + // For each pending edit, update the cell + for ((record_idx, col_name), new_value) in &drill.pending_edits { + let Some((orig_key, _)) = drill.records.get(*record_idx) else { + continue; + }; + if col_name == "Value" { + // Update the cell's value + let value = if new_value.is_empty() { + app.model.clear_cell(orig_key); + continue; + } else if let Ok(n) = new_value.parse::() { + CellValue::Number(n) + } else { + CellValue::Text(new_value.clone()) + }; + app.model.set_cell(orig_key.clone(), value); + } else { + // Rename a coordinate: remove old cell, insert new with updated coord + let value = match app.model.get_cell(orig_key) { + Some(v) => v.clone(), + None => continue, + }; + app.model.clear_cell(orig_key); + // Build new key by replacing the coord + let new_coords: Vec<(String, String)> = orig_key + .0 + .iter() + .map(|(c, i)| { + if c == col_name { + (c.clone(), new_value.clone()) + } else { + (c.clone(), i.clone()) + } + }) + .collect(); + let new_key = CellKey::new(new_coords); + // Ensure the new item exists in that category + if let Some(cat) = app.model.category_mut(col_name) { + cat.add_item(new_value.clone()); + } + app.model.set_cell(new_key, value); + } + } + app.dirty = true; + } +} + +/// Stage a pending edit in the drill state. +#[derive(Debug)] +pub struct SetDrillPendingEdit { + pub record_idx: usize, + pub col_name: String, + pub new_value: String, +} +impl Effect for SetDrillPendingEdit { + fn apply(&self, app: &mut App) { + if let Some(drill) = &mut app.drill_state { + drill + .pending_edits + .insert((self.record_idx, self.col_name.clone()), self.new_value.clone()); + } + } +} + // ── Side effects ───────────────────────────────────────────────────────────── #[derive(Debug)] diff --git a/src/ui/grid.rs b/src/ui/grid.rs index ad06671..72d585e 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -21,6 +21,7 @@ pub struct GridWidget<'a> { pub mode: &'a AppMode, pub search_query: &'a str, pub buffers: &'a std::collections::HashMap, + pub drill_state: Option<&'a crate::ui::app::DrillState>, } impl<'a> GridWidget<'a> { @@ -29,19 +30,22 @@ impl<'a> GridWidget<'a> { mode: &'a AppMode, search_query: &'a str, buffers: &'a std::collections::HashMap, + drill_state: Option<&'a crate::ui::app::DrillState>, ) -> Self { Self { model, mode, search_query, buffers, + drill_state, } } fn render_grid(&self, area: Rect, buf: &mut Buffer) { let view = self.model.active_view(); - let layout = GridLayout::new(self.model, view); + let frozen = self.drill_state.map(|s| s.records.clone()); + let layout = GridLayout::with_frozen_records(self.model, view, frozen); let (sel_row, sel_col) = view.selected; let row_offset = view.row_offset; let col_offset = view.col_offset; @@ -542,7 +546,7 @@ mod tests { let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); let bufs = std::collections::HashMap::new(); - GridWidget::new(model, &AppMode::Normal, "", &bufs).render(area, &mut buf); + GridWidget::new(model, &AppMode::Normal, "", &bufs, None).render(area, &mut buf); buf } diff --git a/src/view/layout.rs b/src/view/layout.rs index 3371059..ef87a75 100644 --- a/src/view/layout.rs +++ b/src/view/layout.rs @@ -35,6 +35,27 @@ pub struct GridLayout { } impl GridLayout { + /// Build a layout. When records-mode is active and `frozen_records` + /// is provided, use that snapshot instead of re-querying the store. + pub fn with_frozen_records( + model: &Model, + view: &View, + frozen_records: Option>, + ) -> Self { + let mut layout = Self::new(model, view); + if layout.is_records_mode() { + if let Some(records) = frozen_records { + // Re-build with the frozen records instead + let row_items: Vec = (0..records.len()) + .map(|i| AxisEntry::DataItem(vec![i.to_string()])) + .collect(); + layout.row_items = row_items; + layout.records = Some(records); + } + } + layout + } + pub fn new(model: &Model, view: &View) -> Self { let row_cats: Vec = view .categories_on(Axis::Row) @@ -131,7 +152,7 @@ impl GridLayout { .map(|i| AxisEntry::DataItem(vec![i.to_string()])) .collect(); - // Synthesize col items: one per regular category + "Value" + // Synthesize col items: one per category + "Value" let cat_names: Vec = model .category_names() .into_iter() @@ -227,7 +248,17 @@ impl GridLayout { /// Build the CellKey for the data cell at (row, col), including the active /// page-axis filter. Returns None if row or col is out of bounds. + /// In records mode: returns the real underlying CellKey when the column + /// is "Value" (editable); returns None for coord columns (read-only). pub fn cell_key(&self, row: usize, col: usize) -> Option { + if let Some(records) = &self.records { + // Records mode: only the Value column maps to a real, editable cell. + if self.col_label(col) == "Value" { + return records.get(row).map(|(k, _)| k.clone()); + } else { + return None; + } + } let row_item = self .row_items .iter() @@ -393,6 +424,79 @@ mod tests { use super::{AxisEntry, GridLayout}; use crate::model::cell::{CellKey, CellValue}; use crate::model::Model; + use crate::view::Axis; + + fn records_model() -> Model { + let mut m = Model::new("T"); + m.add_category("Region").unwrap(); + m.add_category("Measure").unwrap(); + m.category_mut("Region").unwrap().add_item("North"); + m.category_mut("Measure").unwrap().add_item("Revenue"); + m.category_mut("Measure").unwrap().add_item("Cost"); + m.set_cell( + CellKey::new(vec![ + ("Region".into(), "North".into()), + ("Measure".into(), "Revenue".into()), + ]), + CellValue::Number(100.0), + ); + m.set_cell( + CellKey::new(vec![ + ("Region".into(), "North".into()), + ("Measure".into(), "Cost".into()), + ]), + CellValue::Number(50.0), + ); + m + } + + #[test] + fn records_mode_activated_when_index_and_dim_on_axes() { + let mut m = records_model(); + let v = m.active_view_mut(); + v.set_axis("_Index", Axis::Row); + v.set_axis("_Dim", Axis::Column); + let layout = GridLayout::new(&m, m.active_view()); + assert!(layout.is_records_mode()); + assert_eq!(layout.row_count(), 2); // 2 cells + } + + #[test] + fn records_mode_cell_key_editable_for_value_column() { + let mut m = records_model(); + let v = m.active_view_mut(); + v.set_axis("_Index", Axis::Row); + v.set_axis("_Dim", Axis::Column); + let layout = GridLayout::new(&m, m.active_view()); + assert!(layout.is_records_mode()); + // Find the "Value" column index + let cols: Vec = (0..layout.col_count()).map(|i| layout.col_label(i)).collect(); + let value_col = cols.iter().position(|c| c == "Value").unwrap(); + // cell_key should be Some for Value column + let key = layout.cell_key(0, value_col); + assert!(key.is_some(), "Value column should be editable"); + // cell_key should be None for coord columns + let region_col = cols.iter().position(|c| c == "Region").unwrap(); + assert!( + layout.cell_key(0, region_col).is_none(), + "Region column should not be editable" + ); + } + + #[test] + fn records_mode_cell_key_maps_to_real_cell() { + let mut m = records_model(); + let v = m.active_view_mut(); + v.set_axis("_Index", Axis::Row); + v.set_axis("_Dim", Axis::Column); + let layout = GridLayout::new(&m, m.active_view()); + let cols: Vec = (0..layout.col_count()).map(|i| layout.col_label(i)).collect(); + let value_col = cols.iter().position(|c| c == "Value").unwrap(); + // The CellKey at (0, Value) should look up a real cell value + let key = layout.cell_key(0, value_col).unwrap(); + let val = m.evaluate(&key); + assert!(val.is_some(), "cell_key should resolve to a real cell"); + } fn coord(pairs: &[(&str, &str)]) -> CellKey { CellKey::new(