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:
@ -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,
|
||||
|
||||
@ -1,7 +1,17 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::view::{Axis, View};
|
||||
|
||||
/// Extract (record_index, dim_name) from a synthetic records-mode CellKey.
|
||||
/// Returns None for normal pivot-mode keys.
|
||||
pub fn synthetic_record_info(key: &CellKey) -> Option<(usize, String)> {
|
||||
let idx: usize = key.get("_Index")?.parse().ok()?;
|
||||
let dim = key.get("_Dim")?.to_string();
|
||||
Some((idx, dim))
|
||||
}
|
||||
|
||||
/// One entry on a grid axis: either a visual group header or a data-item tuple.
|
||||
///
|
||||
/// `GroupHeader` entries are always visible so the user can see the group label
|
||||
@ -30,8 +40,8 @@ pub struct GridLayout {
|
||||
/// Categories on `Axis::None` — hidden, implicitly aggregated.
|
||||
pub none_cats: Vec<String>,
|
||||
/// In records mode: the filtered cell list, one per row.
|
||||
/// None for normal pivot views.
|
||||
pub records: Option<Vec<(CellKey, CellValue)>>,
|
||||
/// None for normal pivot views. Rc for cheap sharing.
|
||||
pub records: Option<Rc<Vec<(CellKey, CellValue)>>>,
|
||||
}
|
||||
|
||||
impl GridLayout {
|
||||
@ -40,12 +50,11 @@ impl GridLayout {
|
||||
pub fn with_frozen_records(
|
||||
model: &Model,
|
||||
view: &View,
|
||||
frozen_records: Option<Vec<(CellKey, CellValue)>>,
|
||||
frozen_records: Option<Rc<Vec<(CellKey, CellValue)>>>,
|
||||
) -> Self {
|
||||
let mut layout = Self::new(model, view);
|
||||
if layout.is_records_mode() {
|
||||
if let Some(records) = frozen_records {
|
||||
// Re-build with the frozen records instead
|
||||
let row_items: Vec<AxisEntry> = (0..records.len())
|
||||
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
||||
.collect();
|
||||
@ -175,7 +184,7 @@ impl GridLayout {
|
||||
row_items,
|
||||
col_items,
|
||||
none_cats,
|
||||
records: Some(records),
|
||||
records: Some(Rc::new(records)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -220,14 +229,10 @@ impl GridLayout {
|
||||
let mut has_value = vec![vec![false; cc]; rc];
|
||||
for ri in 0..rc {
|
||||
for ci in 0..cc {
|
||||
has_value[ri][ci] = if self.is_records_mode() {
|
||||
let s = self.records_display(ri, ci).unwrap_or_default();
|
||||
!s.is_empty()
|
||||
} else {
|
||||
self.cell_key(ri, ci)
|
||||
.and_then(|k| model.evaluate_aggregated(&k, &self.none_cats))
|
||||
.is_some()
|
||||
};
|
||||
has_value[ri][ci] = self
|
||||
.cell_key(ri, ci)
|
||||
.and_then(|k| model.evaluate_aggregated(&k, &self.none_cats))
|
||||
.is_some();
|
||||
}
|
||||
}
|
||||
|
||||
@ -297,7 +302,7 @@ impl GridLayout {
|
||||
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
||||
.collect();
|
||||
self.row_items = new_row_items;
|
||||
self.records = Some(new_records);
|
||||
self.records = Some(Rc::new(new_records));
|
||||
}
|
||||
}
|
||||
|
||||
@ -352,18 +357,57 @@ impl GridLayout {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Resolve the display string for a synthetic records-mode CellKey.
|
||||
/// Returns None for non-synthetic (pivot) keys.
|
||||
pub fn resolve_display(&self, key: &CellKey) -> Option<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
|
||||
/// page-axis filter. Returns None if row or col is out of bounds.
|
||||
/// In records mode: returns the real underlying CellKey when the column
|
||||
/// is "Value" (editable); returns None for coord columns (read-only).
|
||||
/// In records mode: returns a synthetic `(_Index, _Dim)` key for every column.
|
||||
pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> {
|
||||
if let Some(records) = &self.records {
|
||||
// Records mode: only the Value column maps to a real, editable cell.
|
||||
if self.col_label(col) == "Value" {
|
||||
return records.get(row).map(|(k, _)| k.clone());
|
||||
} else {
|
||||
if self.records.is_some() {
|
||||
let records = self.records.as_ref().unwrap();
|
||||
if row >= records.len() {
|
||||
return None;
|
||||
}
|
||||
let col_label = self.col_label(col);
|
||||
if col_label.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(CellKey::new(vec![
|
||||
("_Index".to_string(), row.to_string()),
|
||||
("_Dim".to_string(), col_label),
|
||||
]));
|
||||
}
|
||||
let row_item = self
|
||||
.row_items
|
||||
@ -527,7 +571,7 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry>
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AxisEntry, GridLayout};
|
||||
use super::{synthetic_record_info, AxisEntry, GridLayout};
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::view::Axis;
|
||||
@ -592,40 +636,66 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_mode_cell_key_editable_for_value_column() {
|
||||
fn records_mode_cell_key_returns_synthetic_for_all_columns() {
|
||||
let mut m = records_model();
|
||||
let v = m.active_view_mut();
|
||||
v.set_axis("_Index", Axis::Row);
|
||||
v.set_axis("_Dim", Axis::Column);
|
||||
let layout = GridLayout::new(&m, m.active_view());
|
||||
assert!(layout.is_records_mode());
|
||||
// Find the "Value" column index
|
||||
let cols: Vec<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();
|
||||
// cell_key should be Some for Value column
|
||||
let key = layout.cell_key(0, value_col);
|
||||
assert!(key.is_some(), "Value column should be editable");
|
||||
// cell_key should be None for coord columns
|
||||
let key = layout.cell_key(0, value_col).unwrap();
|
||||
assert_eq!(key.get("_Index"), Some("0"));
|
||||
assert_eq!(key.get("_Dim"), Some("Value"));
|
||||
|
||||
let region_col = cols.iter().position(|c| c == "Region").unwrap();
|
||||
assert!(
|
||||
layout.cell_key(0, region_col).is_none(),
|
||||
"Region column should not be editable"
|
||||
);
|
||||
let key = layout.cell_key(0, region_col).unwrap();
|
||||
assert_eq!(key.get("_Index"), Some("0"));
|
||||
assert_eq!(key.get("_Dim"), Some("Region"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_mode_cell_key_maps_to_real_cell() {
|
||||
fn records_mode_resolve_display_returns_values() {
|
||||
let mut m = records_model();
|
||||
let v = m.active_view_mut();
|
||||
v.set_axis("_Index", Axis::Row);
|
||||
v.set_axis("_Dim", Axis::Column);
|
||||
let layout = GridLayout::new(&m, m.active_view());
|
||||
let cols: Vec<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();
|
||||
// The CellKey at (0, Value) should look up a real cell value
|
||||
let key = layout.cell_key(0, value_col).unwrap();
|
||||
let val = m.evaluate(&key);
|
||||
assert!(val.is_some(), "cell_key should resolve to a real cell");
|
||||
let display = layout.resolve_display(&key);
|
||||
assert!(display.is_some(), "Value column should resolve");
|
||||
|
||||
// Category column resolves to the coordinate value
|
||||
let region_col = cols.iter().position(|c| c == "Region").unwrap();
|
||||
let key = layout.cell_key(0, region_col).unwrap();
|
||||
let display = layout.resolve_display(&key).unwrap();
|
||||
assert!(!display.is_empty(), "Region column should resolve to a value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_record_info_returns_none_for_pivot_keys() {
|
||||
let key = CellKey::new(vec![
|
||||
("Region".to_string(), "East".to_string()),
|
||||
("Product".to_string(), "Shoes".to_string()),
|
||||
]);
|
||||
assert!(synthetic_record_info(&key).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_record_info_extracts_index_and_dim() {
|
||||
let key = CellKey::new(vec![
|
||||
("_Index".to_string(), "3".to_string()),
|
||||
("_Dim".to_string(), "Region".to_string()),
|
||||
]);
|
||||
let (idx, dim) = synthetic_record_info(&key).unwrap();
|
||||
assert_eq!(idx, 3);
|
||||
assert_eq!(dim, "Region");
|
||||
}
|
||||
|
||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||
|
||||
@ -3,5 +3,5 @@ pub mod layout;
|
||||
pub mod types;
|
||||
|
||||
pub use axis::Axis;
|
||||
pub use layout::{AxisEntry, GridLayout};
|
||||
pub use layout::{synthetic_record_info, AxisEntry, GridLayout};
|
||||
pub use types::View;
|
||||
|
||||
Reference in New Issue
Block a user