refactor!(cmd): move CmdContext logic to GridLayout

Refactored CmdContext to delegate row/col counts, cell_key, none_cats, view
stacks, and records handling to GridLayout. Updated all command
implementations to use layout methods. Updated tests to construct
CmdContext with layout. Changed GridLayout to store records as Rc and added
synthetic_record_info helper. Updated view/layout.rs and view/mod.rs
accordingly.

BREAKING CHANGE: CmdContext fields changed; external callers must update to use layout
methods. GridLayout records field changed to Rc.
Co-Authored-By: fiddlerwoaroof/git-smart-commit (bartowski/nvidia_Nemotron-Cascade-2-30B-A3B-GGUF)
This commit is contained in:
Edward Langley
2026-04-07 09:16:25 -07:00
parent 334597d825
commit f8f8f537c3
3 changed files with 387 additions and 328 deletions

View File

@ -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<String, String>,
/// Pre-resolved cell key at the cursor position (None if out of bounds)
pub cell_key: Option<crate::model::cell::CellKey>,
/// 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>,
/// Records-mode info (drill view). None for normal pivot views.
/// When Some, edits stage to drill_state.pending_edits.
pub records_col: Option<String>,
/// The display value at the cursor in records mode (including any
/// pending edit override). None for normal pivot views.
pub records_value: Option<String>,
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<CellKey> {
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<crate::ui::cat_tree::CatTreeEntry> {
@ -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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
let last = self.row_count.saturating_sub(1);
let mut effects: Vec<Box<dyn Effect>> = 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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
let last = self.col_count.saturating_sub(1);
let mut effects: Vec<Box<dyn Effect>> = 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<Box<dyn Effect>> {
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<Box<dyn Effect>> =
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> = 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<Box<dyn Effect>> = 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<Box<dyn Effect>>) {
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::<f64>() {
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<Box<dyn Effect>> {
let mut effects: Vec<Box<dyn Effect>> = 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::<f64>() {
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<Box<dyn Effect>> {
let mut effects: Vec<Box<dyn Effect>> = 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::<i32>().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::<i32>().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 <value> <Cat/Item>...
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::collections::HashSet<String>> =
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,