diff --git a/src/command/cmd.rs b/src/command/cmd.rs index b282f9f..a2b6838 100644 --- a/src/command/cmd.rs +++ b/src/command/cmd.rs @@ -12,6 +12,7 @@ use crate::view::{Axis, AxisEntry, GridLayout}; /// Read-only context available to commands for decision-making. pub struct CmdContext<'a> { pub model: &'a Model, + pub layout: &'a GridLayout, pub mode: &'a AppMode, pub selected: (usize, usize), pub row_offset: usize, @@ -31,24 +32,12 @@ pub struct CmdContext<'a> { pub tile_cat_idx: usize, /// Named text buffers pub buffers: &'a HashMap, - /// Pre-resolved cell key at the cursor position (None if out of bounds) - pub cell_key: Option, - /// 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, - /// 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, + pub view_back_stack: &'a [String], + pub view_forward_stack: &'a [String], + /// Display value at the cursor — works uniformly for pivot and records mode. + pub display_value: String, /// How many data rows/cols fit on screen (for viewport scrolling). - /// Defaults to generous fallbacks when unknown. pub visible_rows: usize, pub visible_cols: usize, /// Expanded categories in the tree panel @@ -57,6 +46,21 @@ pub struct CmdContext<'a> { pub key_code: KeyCode, } +impl<'a> CmdContext<'a> { + pub fn cell_key(&self) -> Option { + self.layout.cell_key(self.selected.0, self.selected.1) + } + pub fn row_count(&self) -> usize { + self.layout.row_count() + } + pub fn col_count(&self) -> usize { + self.layout.col_count() + } + pub fn none_cats(&self) -> &[String] { + &self.layout.none_cats + } +} + impl<'a> CmdContext<'a> { /// Resolve the category panel tree entry at the current cursor. pub fn cat_tree_entry(&self) -> Option { @@ -251,8 +255,8 @@ impl CursorState { Self { row: ctx.selected.0, col: ctx.selected.1, - row_count: ctx.row_count, - col_count: ctx.col_count, + row_count: ctx.row_count(), + col_count: ctx.col_count(), row_offset: ctx.row_offset, col_offset: ctx.col_offset, visible_rows: ctx.visible_rows, @@ -317,75 +321,32 @@ impl Cmd for MoveSelection { } } +/// Unified jump-to-edge: jump to first/last row or column. +/// `is_row` selects the axis; `end` selects first (false) or last (true). #[derive(Debug)] -pub struct JumpToFirstRow { - pub col: usize, +pub struct JumpToEdge { + pub cursor: CursorState, + pub is_row: bool, + pub end: bool, + pub cmd_name: &'static str, } -impl Cmd for JumpToFirstRow { +impl Cmd for JumpToEdge { fn name(&self) -> &'static str { - "jump-first-row" + self.cmd_name } fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![ - Box::new(effect::SetSelected(0, self.col)), - Box::new(effect::SetRowOffset(0)), - ] - } -} - -#[derive(Debug)] -pub struct JumpToLastRow { - pub col: usize, - pub row_count: usize, - pub row_offset: usize, -} -impl Cmd for JumpToLastRow { - fn name(&self) -> &'static str { - "jump-last-row" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - let last = self.row_count.saturating_sub(1); - let mut effects: Vec> = vec![Box::new(effect::SetSelected(last, self.col))]; - if last >= self.row_offset + 20 { - effects.push(Box::new(effect::SetRowOffset(last.saturating_sub(19)))); - } - effects - } -} - -#[derive(Debug)] -pub struct JumpToFirstCol { - pub row: usize, -} -impl Cmd for JumpToFirstCol { - fn name(&self) -> &'static str { - "jump-first-col" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![ - Box::new(effect::SetSelected(self.row, 0)), - Box::new(effect::SetColOffset(0)), - ] - } -} - -#[derive(Debug)] -pub struct JumpToLastCol { - pub row: usize, - pub col_count: usize, - pub col_offset: usize, -} -impl Cmd for JumpToLastCol { - fn name(&self) -> &'static str { - "jump-last-col" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - let last = self.col_count.saturating_sub(1); - let mut effects: Vec> = vec![Box::new(effect::SetSelected(self.row, last))]; - if last >= self.col_offset + 8 { - effects.push(Box::new(effect::SetColOffset(last.saturating_sub(7)))); - } - effects + let (nr, nc) = if self.is_row { + let r = if self.end { self.cursor.row_count.saturating_sub(1) } else { 0 }; + (r, self.cursor.col) + } else { + let c = if self.end { self.cursor.col_count.saturating_sub(1) } else { 0 }; + (self.cursor.row, c) + }; + viewport_effects( + nr, nc, + self.cursor.row_offset, self.cursor.col_offset, + self.cursor.visible_rows, self.cursor.visible_cols, + ) } } @@ -401,19 +362,38 @@ impl Cmd for ScrollRows { fn execute(&self, _ctx: &CmdContext) -> Vec> { let row_max = self.cursor.row_count.saturating_sub(1) as i32; let nr = (self.cursor.row as i32 + self.delta).clamp(0, row_max) as usize; - let mut effects: Vec> = - vec![Box::new(effect::SetSelected(nr, self.cursor.col))]; - let mut row_offset = self.cursor.row_offset; - if nr < row_offset { - row_offset = nr; - } - if nr >= row_offset + 20 { - row_offset = nr.saturating_sub(19); - } - if row_offset != self.cursor.row_offset { - effects.push(Box::new(effect::SetRowOffset(row_offset))); - } - effects + viewport_effects( + nr, + self.cursor.col, + self.cursor.row_offset, + self.cursor.col_offset, + self.cursor.visible_rows, + self.cursor.visible_cols, + ) + } +} + +#[derive(Debug)] +pub struct PageScroll { + pub direction: i32, // 1 for down, -1 for up + pub cursor: CursorState, +} +impl Cmd for PageScroll { + fn name(&self) -> &'static str { + "page-scroll" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + let delta = (self.cursor.visible_rows as i32 * 3 / 4).max(1) * self.direction; + let row_max = self.cursor.row_count.saturating_sub(1) as i32; + let nr = (self.cursor.row as i32 + delta).clamp(0, row_max) as usize; + viewport_effects( + nr, + self.cursor.col, + self.cursor.row_offset, + self.cursor.col_offset, + self.cursor.visible_rows, + self.cursor.visible_cols, + ) } } @@ -471,7 +451,7 @@ impl Cmd for SaveAndQuit { // ── Cell operations ────────────────────────────────────────────────────────── // All cell commands take an explicit CellKey. The interactive spec fills it -// from ctx.cell_key; the parser fills it from Cat/Item coordinate args. +// from ctx.cell_key(); the parser fills it from Cat/Item coordinate args. /// Clear a cell. #[derive(Debug)] @@ -500,8 +480,7 @@ impl Cmd for YankCell { "yank" } fn execute(&self, ctx: &CmdContext) -> Vec> { - let layout = GridLayout::new(ctx.model, ctx.model.active_view()); - let value = ctx.model.evaluate_aggregated(&self.key, &layout.none_cats); + let value = ctx.model.evaluate_aggregated(&self.key, ctx.none_cats()); vec![ Box::new(effect::SetYanked(value)), effect::set_status("Yanked"), @@ -590,12 +569,7 @@ impl Cmd for TogglePanelAndFocus { open: self.open, })); if self.focused { - let mode = match self.panel { - Panel::Formula => AppMode::FormulaPanel, - Panel::Category => AppMode::CategoryPanel, - Panel::View => AppMode::ViewPanel, - }; - effects.push(effect::change_mode(mode)); + effects.push(effect::change_mode(self.panel.mode())); } else { effects.push(effect::change_mode(AppMode::Normal)); } @@ -682,33 +656,29 @@ impl Cmd for EditOrDrill { // Only consider regular (non-virtual, non-label) categories on None // as true aggregation. Virtuals like _Index/_Dim are always None in // pivot mode and don't imply aggregation. - let regular_none = ctx.none_cats.iter().any(|c| { + let regular_none = ctx.none_cats().iter().any(|c| { ctx.model .category(c) .map(|cat| cat.kind.is_regular()) .unwrap_or(false) }); - let is_aggregated = ctx.records_col.is_none() && regular_none; + // In records mode (synthetic key), always edit directly — no drilling. + let is_synthetic = ctx + .cell_key() + .as_ref() + .and_then(|k| crate::view::synthetic_record_info(k)) + .is_some(); + let is_aggregated = !is_synthetic && regular_none; if is_aggregated { - let Some(key) = ctx.cell_key.clone() else { - return vec![effect::set_status( - "cannot drill — no cell at cursor", - )]; + let Some(key) = ctx.cell_key().clone() else { + return vec![effect::set_status("cannot drill — no cell at cursor")]; }; return DrillIntoCell { key }.execute(ctx); } - // Edit path: prefer records display value (includes pending edits), - // else the underlying cell's stored value. - let initial_value = if let Some(v) = &ctx.records_value { - v.clone() - } else { - ctx.cell_key - .as_ref() - .and_then(|k| ctx.model.get_cell(k).cloned()) - .map(|v| v.to_string()) - .unwrap_or_default() - }; - EnterEditMode { initial_value }.execute(ctx) + EnterEditMode { + initial_value: ctx.display_value.clone(), + } + .execute(ctx) } } @@ -721,8 +691,15 @@ impl Cmd for AddRecordRow { "add-record-row" } fn execute(&self, ctx: &CmdContext) -> Vec> { - if ctx.records_col.is_none() { - return vec![effect::set_status("add-record-row only works in records mode")]; + let is_records = ctx + .cell_key() + .as_ref() + .and_then(|k| crate::view::synthetic_record_info(k)) + .is_some(); + if !is_records { + return vec![effect::set_status( + "add-record-row only works in records mode", + )]; } // Build a CellKey from the current page filters let view = ctx.model.active_view(); @@ -751,6 +728,30 @@ impl Cmd for AddRecordRow { } } +/// Vim-style 'o': add a new record row below cursor and enter edit mode. +#[derive(Debug)] +pub struct OpenRecordRow; +impl Cmd for OpenRecordRow { + fn name(&self) -> &'static str { + "open-record-row" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let is_records = ctx + .cell_key() + .as_ref() + .and_then(|k| crate::view::synthetic_record_info(k)) + .is_some(); + if !is_records { + return vec![effect::set_status( + "open-record-row only works in records mode", + )]; + } + let mut effects = AddRecordRow.execute(ctx); + effects.push(Box::new(effect::EnterEditAtCursor)); + effects + } +} + /// Typewriter-style advance: move down, wrap to top of next column at bottom. #[derive(Debug)] pub struct EnterAdvance { @@ -804,10 +805,9 @@ impl Cmd for SearchNavigate { return vec![]; } - let layout = GridLayout::new(ctx.model, ctx.model.active_view()); let (cur_row, cur_col) = ctx.selected; - let total_rows = layout.row_count().max(1); - let total_cols = layout.col_count().max(1); + let total_rows = ctx.row_count().max(1); + let total_cols = ctx.col_count().max(1); let total = total_rows * total_cols; let cur_flat = cur_row * total_cols + cur_col; @@ -815,11 +815,11 @@ impl Cmd for SearchNavigate { .filter(|&flat| { let ri = flat / total_cols; let ci = flat % total_cols; - let key = match layout.cell_key(ri, ci) { + let key = match ctx.layout.cell_key(ri, ci) { Some(k) => k, None => return false, }; - let s = match ctx.model.evaluate_aggregated(&key, &layout.none_cats) { + let s = match ctx.model.evaluate_aggregated(&key, ctx.none_cats()) { Some(CellValue::Number(n)) => format!("{n}"), Some(CellValue::Text(t)) => t, None => String::new(), @@ -1018,9 +1018,8 @@ impl Cmd for ToggleGroupUnderCursor { "toggle-group-under-cursor" } fn execute(&self, ctx: &CmdContext) -> Vec> { - let layout = GridLayout::new(ctx.model, ctx.model.active_view()); let sel_row = ctx.selected.0; - let Some((cat, group)) = layout.row_group_for(sel_row) else { + let Some((cat, group)) = ctx.layout.row_group_for(sel_row) else { return vec![]; }; vec![ @@ -1041,9 +1040,8 @@ impl Cmd for ToggleColGroupUnderCursor { "toggle-col-group-under-cursor" } fn execute(&self, ctx: &CmdContext) -> Vec> { - let layout = GridLayout::new(ctx.model, ctx.model.active_view()); let sel_col = ctx.selected.1; - let Some((cat, group)) = layout.col_group_for(sel_col) else { + let Some((cat, group)) = ctx.layout.col_group_for(sel_col) else { return vec![]; }; // After toggling, col_count may shrink — clamp selection @@ -1067,12 +1065,12 @@ impl Cmd for HideSelectedRowItem { "hide-selected-row-item" } fn execute(&self, ctx: &CmdContext) -> Vec> { - let layout = GridLayout::new(ctx.model, ctx.model.active_view()); - let Some(cat_name) = layout.row_cats.first().cloned() else { + let Some(cat_name) = ctx.layout.row_cats.first().cloned() else { return vec![]; }; let sel_row = ctx.selected.0; - let Some(items) = layout + let Some(items) = ctx + .layout .row_items .iter() .filter_map(|e| { @@ -1199,7 +1197,7 @@ impl Cmd for DrillIntoCell { // 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 { + for cat in ctx.none_cats() { if fixed_cats.contains(cat) || cat.starts_with('_') { continue; } @@ -1436,64 +1434,38 @@ impl Cmd for ToggleRecordsMode { "toggle-records-mode" } fn execute(&self, ctx: &CmdContext) -> Vec> { - use crate::view::Axis; - let view = ctx.model.active_view(); - - // Detect current state - let is_records = view - .category_axes - .get("_Index") - .copied() - == Some(Axis::Row) - && view.category_axes.get("_Dim").copied() == Some(Axis::Column); - - let mut effects: Vec> = Vec::new(); + let is_records = ctx.layout.is_records_mode(); if is_records { - // Switch back to pivot: auto-assign axes - // First regular category → Row, second → Column, rest → Page, - // virtuals/labels → None. - let mut row_done = false; - let mut col_done = false; - for (name, cat) in &ctx.model.categories { - let axis = if !cat.kind.is_regular() { - Axis::None - } else if !row_done { - row_done = true; - Axis::Row - } else if !col_done { - col_done = true; - Axis::Column - } else { - Axis::Page - }; + // Navigate back to the previous view (restores original axes) + return vec![Box::new(effect::ViewBack), effect::set_status("Pivot mode")]; + } + + let mut effects: Vec> = Vec::new(); + let records_name = "_Records".to_string(); + + // Create (or replace) a _Records view and switch to it + effects.push(Box::new(effect::CreateView(records_name.clone()))); + effects.push(Box::new(effect::SwitchView(records_name))); + + // _Index on Row, _Dim on Column, everything else → None + 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, + })); + for name in ctx.model.categories.keys() { + if name != "_Index" && name != "_Dim" { effects.push(Box::new(effect::SetAxis { category: name.clone(), - axis, + axis: crate::view::Axis::None, })); } - effects.push(effect::set_status("Pivot mode")); - } else { - // Switch to records mode - effects.push(Box::new(effect::SetAxis { - category: "_Index".to_string(), - axis: Axis::Row, - })); - effects.push(Box::new(effect::SetAxis { - category: "_Dim".to_string(), - axis: Axis::Column, - })); - // Everything else → None - for name in ctx.model.categories.keys() { - if name != "_Index" && name != "_Dim" { - effects.push(Box::new(effect::SetAxis { - category: name.clone(), - axis: Axis::None, - })); - } - } - effects.push(effect::set_status("Records mode")); } + effects.push(effect::set_status("Records mode")); effects } } @@ -1928,14 +1900,34 @@ impl Cmd for PopChar { /// 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. +/// Commit a cell value: for synthetic records keys, stage in drill pending edits +/// or apply directly; for real keys, write to the model. +fn commit_cell_value(key: &CellKey, value: &str, effects: &mut Vec>) { + if let Some((record_idx, col_name)) = crate::view::synthetic_record_info(key) { + effects.push(Box::new(effect::SetDrillPendingEdit { + record_idx, + col_name, + new_value: value.to_string(), + })); + } else if value.is_empty() { + effects.push(Box::new(effect::ClearCell(key.clone()))); + effects.push(effect::mark_dirty()); + } else if let Ok(n) = value.parse::() { + effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Number(n)))); + effects.push(effect::mark_dirty()); + } else { + effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Text(value.to_string())))); + effects.push(effect::mark_dirty()); + } +} + +/// Commit a cell edit: set cell value, advance cursor, return to editing. +/// In records mode with drill, stages the edit in drill_state.pending_edits. +/// In records mode without drill or in pivot mode, writes directly to the model. #[derive(Debug)] pub struct CommitCellEdit { - pub key: crate::model::cell::CellKey, + pub key: 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 { @@ -1944,29 +1936,7 @@ impl Cmd for CommitCellEdit { fn execute(&self, ctx: &CmdContext) -> Vec> { let mut effects: Vec> = Vec::new(); - 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()); - } + commit_cell_value(&self.key, &self.value, &mut effects); // Advance cursor down (typewriter-style) and re-enter edit mode // at the new cell so the user can continue data entry. let adv = EnterAdvance { @@ -1978,6 +1948,36 @@ impl Cmd for CommitCellEdit { } } +/// Tab in editing: commit cell, move right, re-enter edit mode (Excel-style). +#[derive(Debug)] +pub struct CommitAndAdvanceRight { + pub key: CellKey, + pub value: String, + pub cursor: CursorState, +} +impl Cmd for CommitAndAdvanceRight { + fn name(&self) -> &'static str { + "commit-and-advance-right" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + let mut effects: Vec> = Vec::new(); + commit_cell_value(&self.key, &self.value, &mut effects); + // Move right instead of down + let col_max = self.cursor.col_count.saturating_sub(1); + let nc = (self.cursor.col + 1).min(col_max); + effects.extend(viewport_effects( + self.cursor.row, + nc, + self.cursor.row_offset, + self.cursor.col_offset, + self.cursor.visible_rows, + self.cursor.visible_cols, + )); + effects.push(Box::new(effect::EnterEditAtCursor)); + effects + } +} + /// Commit a formula from the formula edit buffer. #[derive(Debug)] pub struct CommitFormula; @@ -2430,7 +2430,7 @@ pub fn default_registry() -> CmdRegistry { })) }, |_args, ctx| { - let key = ctx.cell_key.clone().ok_or("no cell at cursor")?; + let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; Ok(Box::new(ClearCellCommand { key })) }, ); @@ -2482,58 +2482,20 @@ pub fn default_registry() -> CmdRegistry { })) }, ); - r.register( - &JumpToFirstRow { col: 0 }, - |_| Ok(Box::new(JumpToFirstRow { col: 0 })), - |_, ctx| { - Ok(Box::new(JumpToFirstRow { - col: ctx.selected.1, - })) - }, - ); - r.register( - &JumpToLastRow { col: 0, row_count: 0, row_offset: 0 }, - |_| { - Ok(Box::new(JumpToLastRow { - col: 0, - row_count: 0, - row_offset: 0, - })) - }, - |_, ctx| { - Ok(Box::new(JumpToLastRow { - col: ctx.selected.1, - row_count: ctx.row_count, - row_offset: ctx.row_offset, - })) - }, - ); - r.register( - &JumpToFirstCol { row: 0 }, - |_| Ok(Box::new(JumpToFirstCol { row: 0 })), - |_, ctx| { - Ok(Box::new(JumpToFirstCol { - row: ctx.selected.0, - })) - }, - ); - r.register( - &JumpToLastCol { row: 0, col_count: 0, col_offset: 0 }, - |_| { - Ok(Box::new(JumpToLastCol { - row: 0, - col_count: 0, - col_offset: 0, - })) - }, - |_, ctx| { - Ok(Box::new(JumpToLastCol { - row: ctx.selected.0, - col_count: ctx.col_count, - col_offset: ctx.col_offset, - })) - }, - ); + // Jump-to-edge commands: first/last row/col + macro_rules! reg_jump { + ($r:expr, $is_row:expr, $end:expr, $name:expr) => { + $r.register( + &JumpToEdge { cursor: CursorState::default(), is_row: $is_row, end: $end, cmd_name: $name }, + |_| Ok(Box::new(JumpToEdge { cursor: CursorState::default(), is_row: $is_row, end: $end, cmd_name: $name })), + |_, ctx| Ok(Box::new(JumpToEdge { cursor: CursorState::from_ctx(ctx), is_row: $is_row, end: $end, cmd_name: $name })), + ); + }; + } + reg_jump!(r, true, false, "jump-first-row"); + reg_jump!(r, true, true, "jump-last-row"); + reg_jump!(r, false, false, "jump-first-col"); + reg_jump!(r, false, true, "jump-last-col"); r.register( &ScrollRows { delta: 0, cursor: CursorState::default() }, |args| { @@ -2562,6 +2524,25 @@ pub fn default_registry() -> CmdRegistry { })) }, ); + r.register( + &PageScroll { direction: 0, cursor: CursorState::default() }, + |args| { + require_args("page-scroll", args, 1)?; + let dir = args[0].parse::().map_err(|e| e.to_string())?; + Ok(Box::new(PageScroll { + direction: dir, + cursor: CursorState::default(), + })) + }, + |args, ctx| { + require_args("page-scroll", args, 1)?; + let dir = args[0].parse::().map_err(|e| e.to_string())?; + Ok(Box::new(PageScroll { + direction: dir, + cursor: CursorState::from_ctx(ctx), + })) + }, + ); r.register( &EnterAdvance { cursor: CursorState::default() }, |_| { @@ -2597,12 +2578,14 @@ pub fn default_registry() -> CmdRegistry { })) }, |_args, ctx| { - let key = ctx.cell_key.clone().ok_or("no cell at cursor")?; + let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; Ok(Box::new(YankCell { key })) }, ); r.register( - &PasteCell { key: CellKey::new(vec![]) }, + &PasteCell { + key: CellKey::new(vec![]), + }, |args| { if args.is_empty() { return Err("paste requires at least one Cat/Item coordinate".into()); @@ -2612,11 +2595,11 @@ pub fn default_registry() -> CmdRegistry { })) }, |_args, ctx| { - let key = ctx.cell_key.clone().ok_or("no cell at cursor")?; + let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; Ok(Box::new(PasteCell { key })) }, ); - // clear-cell is registered above (unified: ctx.cell_key or explicit coords) + // clear-cell is registered above (unified: ctx.cell_key() or explicit coords) // ── View / page ────────────────────────────────────────────────────── r.register_nullary(|| Box::new(TransposeAxes)); @@ -2663,7 +2646,7 @@ pub fn default_registry() -> CmdRegistry { })) }, |_args, ctx| { - let key = ctx.cell_key.clone().ok_or("no cell at cursor")?; + let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; Ok(Box::new(DrillIntoCell { key })) }, ); @@ -2824,6 +2807,7 @@ pub fn default_registry() -> CmdRegistry { Box::new(DeleteFormulaAtCursor) }); r.register_nullary(|| Box::new(AddRecordRow)); + r.register_nullary(|| Box::new(OpenRecordRow)); r.register_nullary(|| Box::new(TogglePruneEmpty)); r.register_nullary(|| Box::new(ToggleRecordsMode)); r.register_nullary(|| Box::new(CycleAxisAtCursor)); @@ -2877,35 +2861,36 @@ pub fn default_registry() -> CmdRegistry { &CommitCellEdit { key: CellKey::new(vec![]), value: String::new(), - records_edit: None, }, |args| { - // parse: commit-cell-edit ... if args.len() < 2 { return Err("commit-cell-edit requires a value and coords".into()); } Ok(Box::new(CommitCellEdit { key: parse_cell_key_from_args(&args[1..]), value: args[0].clone(), - records_edit: None, })) }, |_args, ctx| { let value = read_buffer(ctx, "edit"); - // 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 { + let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; + Ok(Box::new(CommitCellEdit { key, value })) + }, + ); + r.register( + &CommitAndAdvanceRight { + key: CellKey::new(vec![]), + value: String::new(), + cursor: CursorState::default(), + }, + |_| Err("commit-and-advance-right requires context".into()), + |_args, ctx| { + let value = read_buffer(ctx, "edit"); + let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; + Ok(Box::new(CommitAndAdvanceRight { key, value, - records_edit: None, + cursor: CursorState::from_ctx(ctx), })) }, ); @@ -2951,12 +2936,16 @@ mod tests { static EMPTY_EXPANDED: std::sync::LazyLock> = std::sync::LazyLock::new(std::collections::HashSet::new); - fn make_ctx(model: &Model) -> CmdContext<'_> { + fn make_layout(model: &Model) -> GridLayout { + GridLayout::new(model, model.active_view()) + } + + fn make_ctx<'a>(model: &'a Model, layout: &'a GridLayout) -> CmdContext<'a> { let view = model.active_view(); - let layout = GridLayout::new(model, view); let (sr, sc) = view.selected; CmdContext { model, + layout, mode: &AppMode::Normal, selected: view.selected, row_offset: view.row_offset, diff --git a/src/view/layout.rs b/src/view/layout.rs index 7cf4066..2d5b7b5 100644 --- a/src/view/layout.rs +++ b/src/view/layout.rs @@ -1,7 +1,17 @@ +use std::rc::Rc; + use crate::model::cell::{CellKey, CellValue}; use crate::model::Model; use crate::view::{Axis, View}; +/// Extract (record_index, dim_name) from a synthetic records-mode CellKey. +/// Returns None for normal pivot-mode keys. +pub fn synthetic_record_info(key: &CellKey) -> Option<(usize, String)> { + let idx: usize = key.get("_Index")?.parse().ok()?; + let dim = key.get("_Dim")?.to_string(); + Some((idx, dim)) +} + /// One entry on a grid axis: either a visual group header or a data-item tuple. /// /// `GroupHeader` entries are always visible so the user can see the group label @@ -30,8 +40,8 @@ pub struct GridLayout { /// 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>, + /// None for normal pivot views. Rc for cheap sharing. + pub records: Option>>, } impl GridLayout { @@ -40,12 +50,11 @@ impl GridLayout { pub fn with_frozen_records( model: &Model, view: &View, - frozen_records: Option>, + 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(); @@ -175,7 +184,7 @@ impl GridLayout { row_items, col_items, none_cats, - records: Some(records), + records: Some(Rc::new(records)), } } @@ -220,14 +229,10 @@ impl GridLayout { let mut has_value = vec![vec![false; cc]; rc]; for ri in 0..rc { for ci in 0..cc { - has_value[ri][ci] = if self.is_records_mode() { - let s = self.records_display(ri, ci).unwrap_or_default(); - !s.is_empty() - } else { - self.cell_key(ri, ci) - .and_then(|k| model.evaluate_aggregated(&k, &self.none_cats)) - .is_some() - }; + has_value[ri][ci] = self + .cell_key(ri, ci) + .and_then(|k| model.evaluate_aggregated(&k, &self.none_cats)) + .is_some(); } } @@ -297,7 +302,7 @@ impl GridLayout { .map(|i| AxisEntry::DataItem(vec![i.to_string()])) .collect(); self.row_items = new_row_items; - self.records = Some(new_records); + self.records = Some(Rc::new(new_records)); } } @@ -352,18 +357,57 @@ impl GridLayout { .unwrap_or_default() } + /// Resolve the display string for a synthetic records-mode CellKey. + /// Returns None for non-synthetic (pivot) keys. + pub fn resolve_display(&self, key: &CellKey) -> Option { + let (idx, dim) = synthetic_record_info(key)?; + let records = self.records.as_ref()?; + let (orig_key, value) = records.get(idx)?; + if dim == "Value" { + Some(value.to_string()) + } else { + Some(orig_key.get(&dim).unwrap_or("").to_string()) + } + } + + /// Unified display text for a cell at (row, col). Handles both pivot and + /// records modes. In pivot mode, evaluates and formats the cell value. + /// In records mode, resolves via the frozen records snapshot. + pub fn display_text( + &self, + model: &Model, + row: usize, + col: usize, + fmt_comma: bool, + fmt_decimals: u8, + ) -> String { + if self.is_records_mode() { + self.records_display(row, col).unwrap_or_default() + } else { + self.cell_key(row, col) + .and_then(|key| model.evaluate_aggregated(&key, &self.none_cats)) + .map(|v| crate::format::format_value(Some(&v), fmt_comma, fmt_decimals)) + .unwrap_or_default() + } + } + /// 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). + /// In records mode: returns a synthetic `(_Index, _Dim)` key for every column. 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 { + if self.records.is_some() { + let records = self.records.as_ref().unwrap(); + if row >= records.len() { return None; } + let col_label = self.col_label(col); + if col_label.is_empty() { + return None; + } + return Some(CellKey::new(vec![ + ("_Index".to_string(), row.to_string()), + ("_Dim".to_string(), col_label), + ])); } let row_item = self .row_items @@ -527,7 +571,7 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec #[cfg(test)] mod tests { - use super::{AxisEntry, GridLayout}; + use super::{synthetic_record_info, AxisEntry, GridLayout}; use crate::model::cell::{CellKey, CellValue}; use crate::model::Model; use crate::view::Axis; @@ -592,40 +636,66 @@ mod tests { } #[test] - fn records_mode_cell_key_editable_for_value_column() { + fn records_mode_cell_key_returns_synthetic_for_all_columns() { 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(); + // All columns return synthetic keys 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 key = layout.cell_key(0, value_col).unwrap(); + assert_eq!(key.get("_Index"), Some("0")); + assert_eq!(key.get("_Dim"), Some("Value")); + 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" - ); + let key = layout.cell_key(0, region_col).unwrap(); + assert_eq!(key.get("_Index"), Some("0")); + assert_eq!(key.get("_Dim"), Some("Region")); } #[test] - fn records_mode_cell_key_maps_to_real_cell() { + fn records_mode_resolve_display_returns_values() { 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(); + + // Value column resolves to the cell value 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"); + let display = layout.resolve_display(&key); + assert!(display.is_some(), "Value column should resolve"); + + // Category column resolves to the coordinate value + let region_col = cols.iter().position(|c| c == "Region").unwrap(); + let key = layout.cell_key(0, region_col).unwrap(); + let display = layout.resolve_display(&key).unwrap(); + assert!(!display.is_empty(), "Region column should resolve to a value"); + } + + #[test] + fn synthetic_record_info_returns_none_for_pivot_keys() { + let key = CellKey::new(vec![ + ("Region".to_string(), "East".to_string()), + ("Product".to_string(), "Shoes".to_string()), + ]); + assert!(synthetic_record_info(&key).is_none()); + } + + #[test] + fn synthetic_record_info_extracts_index_and_dim() { + let key = CellKey::new(vec![ + ("_Index".to_string(), "3".to_string()), + ("_Dim".to_string(), "Region".to_string()), + ]); + let (idx, dim) = synthetic_record_info(&key).unwrap(); + assert_eq!(idx, 3); + assert_eq!(dim, "Region"); } fn coord(pairs: &[(&str, &str)]) -> CellKey { diff --git a/src/view/mod.rs b/src/view/mod.rs index 710c870..1ac49c2 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -3,5 +3,5 @@ pub mod layout; pub mod types; pub use axis::Axis; -pub use layout::{AxisEntry, GridLayout}; +pub use layout::{synthetic_record_info, AxisEntry, GridLayout}; pub use types::View;