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. /// Read-only context available to commands for decision-making.
pub struct CmdContext<'a> { pub struct CmdContext<'a> {
pub model: &'a Model, pub model: &'a Model,
pub layout: &'a GridLayout,
pub mode: &'a AppMode, pub mode: &'a AppMode,
pub selected: (usize, usize), pub selected: (usize, usize),
pub row_offset: usize, pub row_offset: usize,
@ -31,24 +32,12 @@ pub struct CmdContext<'a> {
pub tile_cat_idx: usize, pub tile_cat_idx: usize,
/// Named text buffers /// Named text buffers
pub buffers: &'a HashMap<String, String>, 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) /// View navigation stacks (for drill back/forward)
pub view_back_stack: Vec<String>, pub view_back_stack: &'a [String],
pub view_forward_stack: Vec<String>, pub view_forward_stack: &'a [String],
/// Records-mode info (drill view). None for normal pivot views. /// Display value at the cursor — works uniformly for pivot and records mode.
/// When Some, edits stage to drill_state.pending_edits. pub display_value: String,
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>,
/// How many data rows/cols fit on screen (for viewport scrolling). /// How many data rows/cols fit on screen (for viewport scrolling).
/// Defaults to generous fallbacks when unknown.
pub visible_rows: usize, pub visible_rows: usize,
pub visible_cols: usize, pub visible_cols: usize,
/// Expanded categories in the tree panel /// Expanded categories in the tree panel
@ -57,6 +46,21 @@ pub struct CmdContext<'a> {
pub key_code: KeyCode, 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> { impl<'a> CmdContext<'a> {
/// Resolve the category panel tree entry at the current cursor. /// Resolve the category panel tree entry at the current cursor.
pub fn cat_tree_entry(&self) -> Option<crate::ui::cat_tree::CatTreeEntry> { pub fn cat_tree_entry(&self) -> Option<crate::ui::cat_tree::CatTreeEntry> {
@ -251,8 +255,8 @@ impl CursorState {
Self { Self {
row: ctx.selected.0, row: ctx.selected.0,
col: ctx.selected.1, col: ctx.selected.1,
row_count: ctx.row_count, row_count: ctx.row_count(),
col_count: ctx.col_count, col_count: ctx.col_count(),
row_offset: ctx.row_offset, row_offset: ctx.row_offset,
col_offset: ctx.col_offset, col_offset: ctx.col_offset,
visible_rows: ctx.visible_rows, 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)] #[derive(Debug)]
pub struct JumpToFirstRow { pub struct JumpToEdge {
pub col: usize, 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 { fn name(&self) -> &'static str {
"jump-first-row" self.cmd_name
} }
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
vec![ let (nr, nc) = if self.is_row {
Box::new(effect::SetSelected(0, self.col)), let r = if self.end { self.cursor.row_count.saturating_sub(1) } else { 0 };
Box::new(effect::SetRowOffset(0)), (r, self.cursor.col)
] } else {
} let c = if self.end { self.cursor.col_count.saturating_sub(1) } else { 0 };
} (self.cursor.row, c)
};
#[derive(Debug)] viewport_effects(
pub struct JumpToLastRow { nr, nc,
pub col: usize, self.cursor.row_offset, self.cursor.col_offset,
pub row_count: usize, self.cursor.visible_rows, self.cursor.visible_cols,
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
} }
} }
@ -401,19 +362,38 @@ impl Cmd for ScrollRows {
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let row_max = self.cursor.row_count.saturating_sub(1) as i32; 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 nr = (self.cursor.row as i32 + self.delta).clamp(0, row_max) as usize;
let mut effects: Vec<Box<dyn Effect>> = viewport_effects(
vec![Box::new(effect::SetSelected(nr, self.cursor.col))]; nr,
let mut row_offset = self.cursor.row_offset; self.cursor.col,
if nr < row_offset { self.cursor.row_offset,
row_offset = nr; self.cursor.col_offset,
self.cursor.visible_rows,
self.cursor.visible_cols,
)
} }
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))); #[derive(Debug)]
pub struct PageScroll {
pub direction: i32, // 1 for down, -1 for up
pub cursor: CursorState,
} }
effects 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 ────────────────────────────────────────────────────────── // ── Cell operations ──────────────────────────────────────────────────────────
// All cell commands take an explicit CellKey. The interactive spec fills it // 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. /// Clear a cell.
#[derive(Debug)] #[derive(Debug)]
@ -500,8 +480,7 @@ impl Cmd for YankCell {
"yank" "yank"
} }
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> { 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, ctx.none_cats());
let value = ctx.model.evaluate_aggregated(&self.key, &layout.none_cats);
vec![ vec![
Box::new(effect::SetYanked(value)), Box::new(effect::SetYanked(value)),
effect::set_status("Yanked"), effect::set_status("Yanked"),
@ -590,12 +569,7 @@ impl Cmd for TogglePanelAndFocus {
open: self.open, open: self.open,
})); }));
if self.focused { if self.focused {
let mode = match self.panel { effects.push(effect::change_mode(self.panel.mode()));
Panel::Formula => AppMode::FormulaPanel,
Panel::Category => AppMode::CategoryPanel,
Panel::View => AppMode::ViewPanel,
};
effects.push(effect::change_mode(mode));
} else { } else {
effects.push(effect::change_mode(AppMode::Normal)); 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 // Only consider regular (non-virtual, non-label) categories on None
// as true aggregation. Virtuals like _Index/_Dim are always None in // as true aggregation. Virtuals like _Index/_Dim are always None in
// pivot mode and don't imply aggregation. // 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 ctx.model
.category(c) .category(c)
.map(|cat| cat.kind.is_regular()) .map(|cat| cat.kind.is_regular())
.unwrap_or(false) .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 { if is_aggregated {
let Some(key) = ctx.cell_key.clone() else { let Some(key) = ctx.cell_key().clone() else {
return vec![effect::set_status( return vec![effect::set_status("cannot drill — no cell at cursor")];
"cannot drill — no cell at cursor",
)];
}; };
return DrillIntoCell { key }.execute(ctx); return DrillIntoCell { key }.execute(ctx);
} }
// Edit path: prefer records display value (includes pending edits), EnterEditMode {
// else the underlying cell's stored value. initial_value: ctx.display_value.clone(),
let initial_value = if let Some(v) = &ctx.records_value { }
v.clone() .execute(ctx)
} 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)
} }
} }
@ -721,8 +691,15 @@ impl Cmd for AddRecordRow {
"add-record-row" "add-record-row"
} }
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
if ctx.records_col.is_none() { let is_records = ctx
return vec![effect::set_status("add-record-row only works in records mode")]; .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 // Build a CellKey from the current page filters
let view = ctx.model.active_view(); 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. /// Typewriter-style advance: move down, wrap to top of next column at bottom.
#[derive(Debug)] #[derive(Debug)]
pub struct EnterAdvance { pub struct EnterAdvance {
@ -804,10 +805,9 @@ impl Cmd for SearchNavigate {
return vec![]; return vec![];
} }
let layout = GridLayout::new(ctx.model, ctx.model.active_view());
let (cur_row, cur_col) = ctx.selected; let (cur_row, cur_col) = ctx.selected;
let total_rows = layout.row_count().max(1); let total_rows = ctx.row_count().max(1);
let total_cols = layout.col_count().max(1); let total_cols = ctx.col_count().max(1);
let total = total_rows * total_cols; let total = total_rows * total_cols;
let cur_flat = cur_row * total_cols + cur_col; let cur_flat = cur_row * total_cols + cur_col;
@ -815,11 +815,11 @@ impl Cmd for SearchNavigate {
.filter(|&flat| { .filter(|&flat| {
let ri = flat / total_cols; let ri = flat / total_cols;
let ci = 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, Some(k) => k,
None => return false, 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::Number(n)) => format!("{n}"),
Some(CellValue::Text(t)) => t, Some(CellValue::Text(t)) => t,
None => String::new(), None => String::new(),
@ -1018,9 +1018,8 @@ impl Cmd for ToggleGroupUnderCursor {
"toggle-group-under-cursor" "toggle-group-under-cursor"
} }
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> { 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 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![]; return vec![];
}; };
vec![ vec![
@ -1041,9 +1040,8 @@ impl Cmd for ToggleColGroupUnderCursor {
"toggle-col-group-under-cursor" "toggle-col-group-under-cursor"
} }
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> { 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 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![]; return vec![];
}; };
// After toggling, col_count may shrink — clamp selection // After toggling, col_count may shrink — clamp selection
@ -1067,12 +1065,12 @@ impl Cmd for HideSelectedRowItem {
"hide-selected-row-item" "hide-selected-row-item"
} }
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let layout = GridLayout::new(ctx.model, ctx.model.active_view()); let Some(cat_name) = ctx.layout.row_cats.first().cloned() else {
let Some(cat_name) = layout.row_cats.first().cloned() else {
return vec![]; return vec![];
}; };
let sel_row = ctx.selected.0; let sel_row = ctx.selected.0;
let Some(items) = layout let Some(items) = ctx
.layout
.row_items .row_items
.iter() .iter()
.filter_map(|e| { .filter_map(|e| {
@ -1199,7 +1197,7 @@ impl Cmd for DrillIntoCell {
// Previously-aggregated categories (none_cats) stay on Axis::None so // Previously-aggregated categories (none_cats) stay on Axis::None so
// they don't filter records; they'll appear as columns in records mode. // they don't filter records; they'll appear as columns in records mode.
// Skip virtual categories — we already set _Index/_Dim above. // 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('_') { if fixed_cats.contains(cat) || cat.starts_with('_') {
continue; continue;
} }
@ -1436,64 +1434,38 @@ impl Cmd for ToggleRecordsMode {
"toggle-records-mode" "toggle-records-mode"
} }
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
use crate::view::Axis; let is_records = ctx.layout.is_records_mode();
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();
if is_records { if is_records {
// Switch back to pivot: auto-assign axes // Navigate back to the previous view (restores original axes)
// First regular category → Row, second → Column, rest → Page, return vec![Box::new(effect::ViewBack), effect::set_status("Pivot mode")];
// 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
};
effects.push(Box::new(effect::SetAxis {
category: name.clone(),
axis,
}));
} }
effects.push(effect::set_status("Pivot mode"));
} else { let mut effects: Vec<Box<dyn Effect>> = Vec::new();
// Switch to records mode 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 { effects.push(Box::new(effect::SetAxis {
category: "_Index".to_string(), category: "_Index".to_string(),
axis: Axis::Row, axis: crate::view::Axis::Row,
})); }));
effects.push(Box::new(effect::SetAxis { effects.push(Box::new(effect::SetAxis {
category: "_Dim".to_string(), category: "_Dim".to_string(),
axis: Axis::Column, axis: crate::view::Axis::Column,
})); }));
// Everything else → None
for name in ctx.model.categories.keys() { for name in ctx.model.categories.keys() {
if name != "_Index" && name != "_Dim" { if name != "_Index" && name != "_Dim" {
effects.push(Box::new(effect::SetAxis { effects.push(Box::new(effect::SetAxis {
category: name.clone(), category: name.clone(),
axis: Axis::None, axis: crate::view::Axis::None,
})); }));
} }
} }
effects.push(effect::set_status("Records mode")); effects.push(effect::set_status("Records mode"));
}
effects effects
} }
} }
@ -1928,14 +1900,34 @@ impl Cmd for PopChar {
/// Commit a cell edit: set cell value, advance cursor, return to Normal. /// 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 /// 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)] #[derive(Debug)]
pub struct CommitCellEdit { pub struct CommitCellEdit {
pub key: crate::model::cell::CellKey, pub key: CellKey,
pub value: String, 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 { impl Cmd for CommitCellEdit {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
@ -1944,29 +1936,7 @@ impl Cmd for CommitCellEdit {
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let mut effects: Vec<Box<dyn Effect>> = Vec::new(); let mut effects: Vec<Box<dyn Effect>> = Vec::new();
if let Some((record_idx, col_name)) = &self.records_edit { commit_cell_value(&self.key, &self.value, &mut effects);
// 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());
}
// Advance cursor down (typewriter-style) and re-enter edit mode // Advance cursor down (typewriter-style) and re-enter edit mode
// at the new cell so the user can continue data entry. // at the new cell so the user can continue data entry.
let adv = EnterAdvance { 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. /// Commit a formula from the formula edit buffer.
#[derive(Debug)] #[derive(Debug)]
pub struct CommitFormula; pub struct CommitFormula;
@ -2430,7 +2430,7 @@ pub fn default_registry() -> CmdRegistry {
})) }))
}, },
|_args, ctx| { |_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 })) Ok(Box::new(ClearCellCommand { key }))
}, },
); );
@ -2482,58 +2482,20 @@ pub fn default_registry() -> CmdRegistry {
})) }))
}, },
); );
r.register( // Jump-to-edge commands: first/last row/col
&JumpToFirstRow { col: 0 }, macro_rules! reg_jump {
|_| Ok(Box::new(JumpToFirstRow { col: 0 })), ($r:expr, $is_row:expr, $end:expr, $name:expr) => {
|_, ctx| { $r.register(
Ok(Box::new(JumpToFirstRow { &JumpToEdge { cursor: CursorState::default(), is_row: $is_row, end: $end, cmd_name: $name },
col: ctx.selected.1, |_| 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 })),
},
);
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,
}))
},
); );
};
}
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( r.register(
&ScrollRows { delta: 0, cursor: CursorState::default() }, &ScrollRows { delta: 0, cursor: CursorState::default() },
|args| { |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( r.register(
&EnterAdvance { cursor: CursorState::default() }, &EnterAdvance { cursor: CursorState::default() },
|_| { |_| {
@ -2597,12 +2578,14 @@ pub fn default_registry() -> CmdRegistry {
})) }))
}, },
|_args, ctx| { |_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 })) Ok(Box::new(YankCell { key }))
}, },
); );
r.register( r.register(
&PasteCell { key: CellKey::new(vec![]) }, &PasteCell {
key: CellKey::new(vec![]),
},
|args| { |args| {
if args.is_empty() { if args.is_empty() {
return Err("paste requires at least one Cat/Item coordinate".into()); return Err("paste requires at least one Cat/Item coordinate".into());
@ -2612,11 +2595,11 @@ pub fn default_registry() -> CmdRegistry {
})) }))
}, },
|_args, ctx| { |_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 })) 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 ────────────────────────────────────────────────────── // ── View / page ──────────────────────────────────────────────────────
r.register_nullary(|| Box::new(TransposeAxes)); r.register_nullary(|| Box::new(TransposeAxes));
@ -2663,7 +2646,7 @@ pub fn default_registry() -> CmdRegistry {
})) }))
}, },
|_args, ctx| { |_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 })) Ok(Box::new(DrillIntoCell { key }))
}, },
); );
@ -2824,6 +2807,7 @@ pub fn default_registry() -> CmdRegistry {
Box::new(DeleteFormulaAtCursor) Box::new(DeleteFormulaAtCursor)
}); });
r.register_nullary(|| Box::new(AddRecordRow)); r.register_nullary(|| Box::new(AddRecordRow));
r.register_nullary(|| Box::new(OpenRecordRow));
r.register_nullary(|| Box::new(TogglePruneEmpty)); r.register_nullary(|| Box::new(TogglePruneEmpty));
r.register_nullary(|| Box::new(ToggleRecordsMode)); r.register_nullary(|| Box::new(ToggleRecordsMode));
r.register_nullary(|| Box::new(CycleAxisAtCursor)); r.register_nullary(|| Box::new(CycleAxisAtCursor));
@ -2877,35 +2861,36 @@ pub fn default_registry() -> CmdRegistry {
&CommitCellEdit { &CommitCellEdit {
key: CellKey::new(vec![]), key: CellKey::new(vec![]),
value: String::new(), value: String::new(),
records_edit: None,
}, },
|args| { |args| {
// parse: commit-cell-edit <value> <Cat/Item>...
if args.len() < 2 { if args.len() < 2 {
return Err("commit-cell-edit requires a value and coords".into()); return Err("commit-cell-edit requires a value and coords".into());
} }
Ok(Box::new(CommitCellEdit { Ok(Box::new(CommitCellEdit {
key: parse_cell_key_from_args(&args[1..]), key: parse_cell_key_from_args(&args[1..]),
value: args[0].clone(), value: args[0].clone(),
records_edit: None,
})) }))
}, },
|_args, ctx| { |_args, ctx| {
let value = read_buffer(ctx, "edit"); let value = read_buffer(ctx, "edit");
// In records mode, stage the edit instead of writing to the model let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
if let Some(col_name) = &ctx.records_col { Ok(Box::new(CommitCellEdit { key, value }))
let record_idx = ctx.selected.0; },
return Ok(Box::new(CommitCellEdit { );
key: CellKey::new(vec![]), // ignored in records mode r.register(
value, &CommitAndAdvanceRight {
records_edit: Some((record_idx, col_name.clone())), key: CellKey::new(vec![]),
})); value: String::new(),
} cursor: CursorState::default(),
let key = ctx.cell_key.clone().ok_or("no cell at cursor")?; },
Ok(Box::new(CommitCellEdit { |_| 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, key,
value, 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>> = static EMPTY_EXPANDED: std::sync::LazyLock<std::collections::HashSet<String>> =
std::sync::LazyLock::new(std::collections::HashSet::new); 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 view = model.active_view();
let layout = GridLayout::new(model, view);
let (sr, sc) = view.selected; let (sr, sc) = view.selected;
CmdContext { CmdContext {
model, model,
layout,
mode: &AppMode::Normal, mode: &AppMode::Normal,
selected: view.selected, selected: view.selected,
row_offset: view.row_offset, row_offset: view.row_offset,

View File

@ -1,7 +1,17 @@
use std::rc::Rc;
use crate::model::cell::{CellKey, CellValue}; use crate::model::cell::{CellKey, CellValue};
use crate::model::Model; use crate::model::Model;
use crate::view::{Axis, View}; 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. /// 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 /// `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. /// Categories on `Axis::None` — hidden, implicitly aggregated.
pub none_cats: Vec<String>, pub none_cats: Vec<String>,
/// In records mode: the filtered cell list, one per row. /// In records mode: the filtered cell list, one per row.
/// None for normal pivot views. /// None for normal pivot views. Rc for cheap sharing.
pub records: Option<Vec<(CellKey, CellValue)>>, pub records: Option<Rc<Vec<(CellKey, CellValue)>>>,
} }
impl GridLayout { impl GridLayout {
@ -40,12 +50,11 @@ impl GridLayout {
pub fn with_frozen_records( pub fn with_frozen_records(
model: &Model, model: &Model,
view: &View, view: &View,
frozen_records: Option<Vec<(CellKey, CellValue)>>, frozen_records: Option<Rc<Vec<(CellKey, CellValue)>>>,
) -> Self { ) -> Self {
let mut layout = Self::new(model, view); let mut layout = Self::new(model, view);
if layout.is_records_mode() { if layout.is_records_mode() {
if let Some(records) = frozen_records { if let Some(records) = frozen_records {
// Re-build with the frozen records instead
let row_items: Vec<AxisEntry> = (0..records.len()) let row_items: Vec<AxisEntry> = (0..records.len())
.map(|i| AxisEntry::DataItem(vec![i.to_string()])) .map(|i| AxisEntry::DataItem(vec![i.to_string()]))
.collect(); .collect();
@ -175,7 +184,7 @@ impl GridLayout {
row_items, row_items,
col_items, col_items,
none_cats, 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]; let mut has_value = vec![vec![false; cc]; rc];
for ri in 0..rc { for ri in 0..rc {
for ci in 0..cc { for ci in 0..cc {
has_value[ri][ci] = if self.is_records_mode() { has_value[ri][ci] = self
let s = self.records_display(ri, ci).unwrap_or_default(); .cell_key(ri, ci)
!s.is_empty()
} else {
self.cell_key(ri, ci)
.and_then(|k| model.evaluate_aggregated(&k, &self.none_cats)) .and_then(|k| model.evaluate_aggregated(&k, &self.none_cats))
.is_some() .is_some();
};
} }
} }
@ -297,7 +302,7 @@ impl GridLayout {
.map(|i| AxisEntry::DataItem(vec![i.to_string()])) .map(|i| AxisEntry::DataItem(vec![i.to_string()]))
.collect(); .collect();
self.row_items = new_row_items; 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() .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<String> {
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 /// 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. /// page-axis filter. Returns None if row or col is out of bounds.
/// In records mode: returns the real underlying CellKey when the column /// In records mode: returns a synthetic `(_Index, _Dim)` key for every column.
/// is "Value" (editable); returns None for coord columns (read-only).
pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> { pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> {
if let Some(records) = &self.records { if self.records.is_some() {
// Records mode: only the Value column maps to a real, editable cell. let records = self.records.as_ref().unwrap();
if self.col_label(col) == "Value" { if row >= records.len() {
return records.get(row).map(|(k, _)| k.clone());
} else {
return None; 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 let row_item = self
.row_items .row_items
@ -527,7 +571,7 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry>
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{AxisEntry, GridLayout}; use super::{synthetic_record_info, AxisEntry, GridLayout};
use crate::model::cell::{CellKey, CellValue}; use crate::model::cell::{CellKey, CellValue};
use crate::model::Model; use crate::model::Model;
use crate::view::Axis; use crate::view::Axis;
@ -592,40 +636,66 @@ mod tests {
} }
#[test] #[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 mut m = records_model();
let v = m.active_view_mut(); let v = m.active_view_mut();
v.set_axis("_Index", Axis::Row); v.set_axis("_Index", Axis::Row);
v.set_axis("_Dim", Axis::Column); v.set_axis("_Dim", Axis::Column);
let layout = GridLayout::new(&m, m.active_view()); let layout = GridLayout::new(&m, m.active_view());
assert!(layout.is_records_mode()); assert!(layout.is_records_mode());
// Find the "Value" column index
let cols: Vec<String> = (0..layout.col_count()).map(|i| layout.col_label(i)).collect(); let cols: Vec<String> = (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(); 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).unwrap();
let key = layout.cell_key(0, value_col); assert_eq!(key.get("_Index"), Some("0"));
assert!(key.is_some(), "Value column should be editable"); assert_eq!(key.get("_Dim"), Some("Value"));
// cell_key should be None for coord columns
let region_col = cols.iter().position(|c| c == "Region").unwrap(); let region_col = cols.iter().position(|c| c == "Region").unwrap();
assert!( let key = layout.cell_key(0, region_col).unwrap();
layout.cell_key(0, region_col).is_none(), assert_eq!(key.get("_Index"), Some("0"));
"Region column should not be editable" assert_eq!(key.get("_Dim"), Some("Region"));
);
} }
#[test] #[test]
fn records_mode_cell_key_maps_to_real_cell() { fn records_mode_resolve_display_returns_values() {
let mut m = records_model(); let mut m = records_model();
let v = m.active_view_mut(); let v = m.active_view_mut();
v.set_axis("_Index", Axis::Row); v.set_axis("_Index", Axis::Row);
v.set_axis("_Dim", Axis::Column); v.set_axis("_Dim", Axis::Column);
let layout = GridLayout::new(&m, m.active_view()); let layout = GridLayout::new(&m, m.active_view());
let cols: Vec<String> = (0..layout.col_count()).map(|i| layout.col_label(i)).collect(); let cols: Vec<String> = (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(); 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 key = layout.cell_key(0, value_col).unwrap();
let val = m.evaluate(&key); let display = layout.resolve_display(&key);
assert!(val.is_some(), "cell_key should resolve to a real cell"); 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 { fn coord(pairs: &[(&str, &str)]) -> CellKey {

View File

@ -3,5 +3,5 @@ pub mod layout;
pub mod types; pub mod types;
pub use axis::Axis; pub use axis::Axis;
pub use layout::{AxisEntry, GridLayout}; pub use layout::{synthetic_record_info, AxisEntry, GridLayout};
pub use types::View; pub use types::View;