diff --git a/src/command/cmd.rs b/src/command/cmd.rs index b282f9f..fff927f 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, @@ -2973,14 +2962,15 @@ 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(), - records_col: None, - records_value: None, - cell_key: layout.cell_key(sr, sc), - row_count: layout.row_count(), - col_count: layout.col_count(), + view_back_stack: &[], + view_forward_stack: &[], + display_value: { + let key = layout.cell_key(sr, sc); + key.as_ref() + .and_then(|k| model.get_cell(k).cloned()) + .map(|v| v.to_string()) + .unwrap_or_default() + }, visible_rows: 20, visible_cols: 8, expanded_cats: &EMPTY_EXPANDED, @@ -3002,7 +2992,8 @@ mod tests { #[test] fn move_selection_down_produces_set_selected() { let m = two_cat_model(); - let ctx = make_ctx(&m); + let layout = make_layout(&m); + let ctx = make_ctx(&m, &layout); let cmd = MoveSelection { dr: 1, dc: 0, @@ -3016,7 +3007,8 @@ mod tests { #[test] fn move_selection_clamps_to_bounds() { let m = two_cat_model(); - let ctx = make_ctx(&m); + let layout = make_layout(&m); + let ctx = make_ctx(&m, &layout); // Try to move way past the end let cmd = MoveSelection { dr: 100, @@ -3032,7 +3024,8 @@ mod tests { let m = two_cat_model(); let mut bufs = HashMap::new(); bufs.insert("command".to_string(), "q".to_string()); - let mut ctx = make_ctx(&m); + let layout = make_layout(&m); + let mut ctx = make_ctx(&m, &layout); ctx.dirty = true; ctx.buffers = &bufs; let cmd = ExecuteCommand; @@ -3046,7 +3039,8 @@ mod tests { let m = two_cat_model(); let mut bufs = HashMap::new(); bufs.insert("command".to_string(), "q".to_string()); - let mut ctx = make_ctx(&m); + let layout = make_layout(&m); + let mut ctx = make_ctx(&m, &layout); ctx.buffers = &bufs; let cmd = ExecuteCommand; let effects = cmd.execute(&ctx); @@ -3065,9 +3059,10 @@ mod tests { ("Month".to_string(), "Jan".to_string()), ]); m.set_cell(key, CellValue::Number(42.0)); - let ctx = make_ctx(&m); + let layout = make_layout(&m); + let ctx = make_ctx(&m, &layout); let cmd = ClearCellCommand { - key: ctx.cell_key.clone().unwrap(), + key: ctx.cell_key().clone().unwrap(), }; let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 2); // ClearCell + MarkDirty @@ -3081,9 +3076,10 @@ mod tests { ("Month".to_string(), "Jan".to_string()), ]); m.set_cell(key, CellValue::Number(99.0)); - let ctx = make_ctx(&m); + let layout = make_layout(&m); + let ctx = make_ctx(&m, &layout); let cmd = YankCell { - key: ctx.cell_key.clone().unwrap(), + key: ctx.cell_key().clone().unwrap(), }; let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 2); // SetYanked + SetStatus @@ -3092,7 +3088,8 @@ mod tests { #[test] fn toggle_panel_open_and_focus() { let m = two_cat_model(); - let ctx = make_ctx(&m); + let layout = make_layout(&m); + let ctx = make_ctx(&m, &layout); let cmd = TogglePanelAndFocus { panel: effect::Panel::Formula, open: true, @@ -3110,7 +3107,8 @@ mod tests { #[test] fn toggle_panel_close_and_unfocus() { let m = two_cat_model(); - let ctx = make_ctx(&m); + let layout = make_layout(&m); + let ctx = make_ctx(&m, &layout); let cmd = TogglePanelAndFocus { panel: effect::Panel::Formula, open: false, @@ -3123,7 +3121,8 @@ mod tests { #[test] fn enter_advance_moves_down() { let m = two_cat_model(); - let ctx = make_ctx(&m); + let layout = make_layout(&m); + let ctx = make_ctx(&m, &layout); let cmd = EnterAdvance { cursor: CursorState::from_ctx(&ctx), }; @@ -3139,7 +3138,8 @@ mod tests { #[test] fn search_navigate_with_empty_query_returns_nothing() { let m = two_cat_model(); - let ctx = make_ctx(&m); + let layout = make_layout(&m); + let ctx = make_ctx(&m, &layout); let cmd = SearchNavigate(true); let effects = cmd.execute(&ctx); assert!(effects.is_empty()); @@ -3148,7 +3148,8 @@ mod tests { #[test] fn enter_edit_mode_produces_editing_mode() { let m = two_cat_model(); - let ctx = make_ctx(&m); + let layout = make_layout(&m); + let ctx = make_ctx(&m, &layout); let cmd = EnterEditMode { initial_value: String::new(), }; @@ -3161,7 +3162,8 @@ mod tests { #[test] fn enter_tile_select_with_categories() { let m = two_cat_model(); - let ctx = make_ctx(&m); + let layout = make_layout(&m); + let ctx = make_ctx(&m, &layout); let cmd = EnterTileSelect; let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode @@ -3177,7 +3179,8 @@ mod tests { // 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 layout = make_layout(&m); + let ctx = make_ctx(&m, &layout); let cmd = EnterTileSelect; let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode @@ -3186,7 +3189,8 @@ mod tests { #[test] fn toggle_group_under_cursor_returns_empty_without_groups() { let m = two_cat_model(); - let ctx = make_ctx(&m); + let layout = make_layout(&m); + let ctx = make_ctx(&m, &layout); let cmd = ToggleGroupUnderCursor; let effects = cmd.execute(&ctx); // No groups defined, so nothing to toggle @@ -3196,7 +3200,8 @@ mod tests { #[test] fn search_or_category_add_without_query_opens_category_add() { let m = two_cat_model(); - let ctx = make_ctx(&m); + let layout = make_layout(&m); + let ctx = make_ctx(&m, &layout); let cmd = SearchOrCategoryAdd; let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode @@ -3210,7 +3215,8 @@ mod tests { #[test] fn cycle_panel_focus_with_no_panels_open() { let m = two_cat_model(); - let ctx = make_ctx(&m); + let layout = make_layout(&m); + let ctx = make_ctx(&m, &layout); let cmd = CyclePanelFocus { formula_open: false, category_open: false, @@ -3223,7 +3229,8 @@ mod tests { #[test] fn cycle_panel_focus_with_formula_panel_open() { let m = two_cat_model(); - let mut ctx = make_ctx(&m); + let layout = make_layout(&m); + let mut ctx = make_ctx(&m, &layout); ctx.formula_panel_open = true; let cmd = CyclePanelFocus { formula_open: true,