Compare commits
4 Commits
gemma-bran
...
nemotron-b
| Author | SHA1 | Date | |
|---|---|---|---|
| 00499fc2bf | |||
| 178983bcbf | |||
| e09ddf71a7 | |||
| f8f8f537c3 |
@ -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;
|
||||
viewport_effects(
|
||||
nr,
|
||||
self.cursor.col,
|
||||
self.cursor.row_offset,
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
// 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
|
||||
};
|
||||
effects.push(Box::new(effect::SetAxis {
|
||||
category: name.clone(),
|
||||
axis,
|
||||
}));
|
||||
// Navigate back to the previous view (restores original axes)
|
||||
return vec![Box::new(effect::ViewBack), effect::set_status("Pivot mode")];
|
||||
}
|
||||
effects.push(effect::set_status("Pivot mode"));
|
||||
} else {
|
||||
// Switch to records 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: Axis::Row,
|
||||
axis: crate::view::Axis::Row,
|
||||
}));
|
||||
effects.push(Box::new(effect::SetAxis {
|
||||
category: "_Dim".to_string(),
|
||||
axis: Axis::Column,
|
||||
axis: crate::view::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,
|
||||
axis: crate::view::Axis::None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
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,
|
||||
@ -2973,14 +2962,15 @@ mod tests {
|
||||
view_panel_cursor: 0,
|
||||
tile_cat_idx: 0,
|
||||
buffers: &EMPTY_BUFFERS,
|
||||
none_cats: layout.none_cats.clone(),
|
||||
view_back_stack: Vec::new(),
|
||||
view_forward_stack: Vec::new(),
|
||||
records_col: None,
|
||||
records_value: None,
|
||||
cell_key: layout.cell_key(sr, sc),
|
||||
row_count: layout.row_count(),
|
||||
col_count: layout.col_count(),
|
||||
view_back_stack: &[],
|
||||
view_forward_stack: &[],
|
||||
display_value: {
|
||||
let key = layout.cell_key(sr, sc);
|
||||
key.as_ref()
|
||||
.and_then(|k| model.get_cell(k).cloned())
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default()
|
||||
},
|
||||
visible_rows: 20,
|
||||
visible_cols: 8,
|
||||
expanded_cats: &EMPTY_EXPANDED,
|
||||
@ -3002,7 +2992,8 @@ mod tests {
|
||||
#[test]
|
||||
fn move_selection_down_produces_set_selected() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = MoveSelection {
|
||||
dr: 1,
|
||||
dc: 0,
|
||||
@ -3016,7 +3007,8 @@ mod tests {
|
||||
#[test]
|
||||
fn move_selection_clamps_to_bounds() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
// Try to move way past the end
|
||||
let cmd = MoveSelection {
|
||||
dr: 100,
|
||||
@ -3032,7 +3024,8 @@ mod tests {
|
||||
let m = two_cat_model();
|
||||
let mut bufs = HashMap::new();
|
||||
bufs.insert("command".to_string(), "q".to_string());
|
||||
let mut ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let mut ctx = make_ctx(&m, &layout);
|
||||
ctx.dirty = true;
|
||||
ctx.buffers = &bufs;
|
||||
let cmd = ExecuteCommand;
|
||||
@ -3046,7 +3039,8 @@ mod tests {
|
||||
let m = two_cat_model();
|
||||
let mut bufs = HashMap::new();
|
||||
bufs.insert("command".to_string(), "q".to_string());
|
||||
let mut ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let mut ctx = make_ctx(&m, &layout);
|
||||
ctx.buffers = &bufs;
|
||||
let cmd = ExecuteCommand;
|
||||
let effects = cmd.execute(&ctx);
|
||||
@ -3065,9 +3059,10 @@ mod tests {
|
||||
("Month".to_string(), "Jan".to_string()),
|
||||
]);
|
||||
m.set_cell(key, CellValue::Number(42.0));
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = ClearCellCommand {
|
||||
key: ctx.cell_key.clone().unwrap(),
|
||||
key: ctx.cell_key().clone().unwrap(),
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 2); // ClearCell + MarkDirty
|
||||
@ -3081,9 +3076,10 @@ mod tests {
|
||||
("Month".to_string(), "Jan".to_string()),
|
||||
]);
|
||||
m.set_cell(key, CellValue::Number(99.0));
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = YankCell {
|
||||
key: ctx.cell_key.clone().unwrap(),
|
||||
key: ctx.cell_key().clone().unwrap(),
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 2); // SetYanked + SetStatus
|
||||
@ -3092,7 +3088,8 @@ mod tests {
|
||||
#[test]
|
||||
fn toggle_panel_open_and_focus() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = TogglePanelAndFocus {
|
||||
panel: effect::Panel::Formula,
|
||||
open: true,
|
||||
@ -3110,7 +3107,8 @@ mod tests {
|
||||
#[test]
|
||||
fn toggle_panel_close_and_unfocus() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = TogglePanelAndFocus {
|
||||
panel: effect::Panel::Formula,
|
||||
open: false,
|
||||
@ -3123,7 +3121,8 @@ mod tests {
|
||||
#[test]
|
||||
fn enter_advance_moves_down() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = EnterAdvance {
|
||||
cursor: CursorState::from_ctx(&ctx),
|
||||
};
|
||||
@ -3139,7 +3138,8 @@ mod tests {
|
||||
#[test]
|
||||
fn search_navigate_with_empty_query_returns_nothing() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = SearchNavigate(true);
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert!(effects.is_empty());
|
||||
@ -3148,7 +3148,8 @@ mod tests {
|
||||
#[test]
|
||||
fn enter_edit_mode_produces_editing_mode() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = EnterEditMode {
|
||||
initial_value: String::new(),
|
||||
};
|
||||
@ -3161,7 +3162,8 @@ mod tests {
|
||||
#[test]
|
||||
fn enter_tile_select_with_categories() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = EnterTileSelect;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode
|
||||
@ -3177,7 +3179,8 @@ mod tests {
|
||||
// Models always have virtual categories (_Index, _Dim), so tile
|
||||
// select always has something to operate on.
|
||||
let m = Model::new("Empty");
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = EnterTileSelect;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode
|
||||
@ -3186,7 +3189,8 @@ mod tests {
|
||||
#[test]
|
||||
fn toggle_group_under_cursor_returns_empty_without_groups() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = ToggleGroupUnderCursor;
|
||||
let effects = cmd.execute(&ctx);
|
||||
// No groups defined, so nothing to toggle
|
||||
@ -3196,7 +3200,8 @@ mod tests {
|
||||
#[test]
|
||||
fn search_or_category_add_without_query_opens_category_add() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = SearchOrCategoryAdd;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode
|
||||
@ -3210,7 +3215,8 @@ mod tests {
|
||||
#[test]
|
||||
fn cycle_panel_focus_with_no_panels_open() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: false,
|
||||
category_open: false,
|
||||
@ -3223,7 +3229,8 @@ mod tests {
|
||||
#[test]
|
||||
fn cycle_panel_focus_with_formula_panel_open() {
|
||||
let m = two_cat_model();
|
||||
let mut ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let mut ctx = make_ctx(&m, &layout);
|
||||
ctx.formula_panel_open = true;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: true,
|
||||
|
||||
@ -266,10 +266,14 @@ impl KeymapSet {
|
||||
normal.bind(KeyCode::Char('G'), none, "jump-last-row");
|
||||
normal.bind(KeyCode::Char('0'), none, "jump-first-col");
|
||||
normal.bind(KeyCode::Char('$'), none, "jump-last-col");
|
||||
normal.bind(KeyCode::Home, none, "jump-first-col");
|
||||
normal.bind(KeyCode::End, none, "jump-last-col");
|
||||
|
||||
// Scroll
|
||||
normal.bind_args(KeyCode::Char('d'), ctrl, "scroll-rows", vec!["5".into()]);
|
||||
normal.bind_args(KeyCode::Char('u'), ctrl, "scroll-rows", vec!["-5".into()]);
|
||||
normal.bind_args(KeyCode::PageDown, none, "page-scroll", vec!["1".into()]);
|
||||
normal.bind_args(KeyCode::PageUp, none, "page-scroll", vec!["-1".into()]);
|
||||
|
||||
// Cell operations
|
||||
normal.bind(KeyCode::Char('x'), none, "clear-cell");
|
||||
@ -357,7 +361,7 @@ impl KeymapSet {
|
||||
// Drill into aggregated cell / view history / add row
|
||||
normal.bind(KeyCode::Char('>'), none, "drill-into-cell");
|
||||
normal.bind(KeyCode::Char('<'), none, "view-back");
|
||||
normal.bind(KeyCode::Char('o'), none, "add-record-row");
|
||||
normal.bind(KeyCode::Char('o'), none, "open-record-row");
|
||||
|
||||
// Records mode toggle and prune toggle
|
||||
normal.bind(KeyCode::Char('R'), none, "toggle-records-mode");
|
||||
@ -560,6 +564,7 @@ impl KeymapSet {
|
||||
let mut ed = Keymap::new();
|
||||
ed.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
|
||||
ed.bind(KeyCode::Enter, none, "commit-cell-edit");
|
||||
ed.bind(KeyCode::Tab, none, "commit-and-advance-right");
|
||||
ed.bind_args(KeyCode::Backspace, none, "pop-char", vec!["edit".into()]);
|
||||
ed.bind_any_char("append-char", vec!["edit".into()]);
|
||||
set.insert(ModeKey::Editing, Arc::new(ed));
|
||||
|
||||
50
src/format.rs
Normal file
50
src/format.rs
Normal file
@ -0,0 +1,50 @@
|
||||
use crate::model::cell::CellValue;
|
||||
|
||||
/// Format a CellValue for display with number formatting options.
|
||||
pub fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String {
|
||||
match v {
|
||||
Some(CellValue::Number(n)) => format_f64(*n, comma, decimals),
|
||||
Some(CellValue::Text(s)) => s.clone(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a number format string like ",.0" into (use_commas, decimal_places).
|
||||
pub fn parse_number_format(fmt: &str) -> (bool, u8) {
|
||||
let comma = fmt.contains(',');
|
||||
let decimals = fmt
|
||||
.rfind('.')
|
||||
.and_then(|i| fmt[i + 1..].parse::<u8>().ok())
|
||||
.unwrap_or(0);
|
||||
(comma, decimals)
|
||||
}
|
||||
|
||||
/// Format an f64 with optional comma grouping and decimal places.
|
||||
pub fn format_f64(n: f64, comma: bool, decimals: u8) -> String {
|
||||
let formatted = format!("{:.prec$}", n, prec = decimals as usize);
|
||||
if !comma {
|
||||
return formatted;
|
||||
}
|
||||
let (int_part, dec_part) = if let Some(dot) = formatted.find('.') {
|
||||
(&formatted[..dot], Some(&formatted[dot..]))
|
||||
} else {
|
||||
(&formatted[..], None)
|
||||
};
|
||||
let is_neg = int_part.starts_with('-');
|
||||
let digits = if is_neg { &int_part[1..] } else { int_part };
|
||||
let mut result = String::new();
|
||||
for (idx, c) in digits.chars().rev().enumerate() {
|
||||
if idx > 0 && idx % 3 == 0 {
|
||||
result.push(',');
|
||||
}
|
||||
result.push(c);
|
||||
}
|
||||
if is_neg {
|
||||
result.push('-');
|
||||
}
|
||||
let mut out: String = result.chars().rev().collect();
|
||||
if let Some(dec) = dec_part {
|
||||
out.push_str(dec);
|
||||
}
|
||||
out
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
mod command;
|
||||
mod draw;
|
||||
mod format;
|
||||
mod formula;
|
||||
mod import;
|
||||
mod model;
|
||||
|
||||
@ -458,17 +458,7 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
|
||||
out.push(',');
|
||||
}
|
||||
let row_values: Vec<String> = (0..layout.col_count())
|
||||
.map(|ci| {
|
||||
if layout.is_records_mode() {
|
||||
layout.records_display(ri, ci).unwrap_or_default()
|
||||
} else {
|
||||
layout
|
||||
.cell_key(ri, ci)
|
||||
.and_then(|key| model.evaluate_aggregated(&key, &layout.none_cats))
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
})
|
||||
.map(|ci| layout.display_text(model, ri, ci, false, 0))
|
||||
.collect();
|
||||
out.push_str(&row_values.join(","));
|
||||
out.push('\n');
|
||||
|
||||
271
src/ui/app.rs
271
src/ui/app.rs
@ -5,23 +5,25 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::command::cmd::CmdContext;
|
||||
use crate::command::keymap::{Keymap, KeymapSet};
|
||||
use crate::import::wizard::ImportWizard;
|
||||
use crate::model::cell::CellValue;
|
||||
use crate::model::Model;
|
||||
use crate::persistence;
|
||||
use crate::ui::grid::{
|
||||
compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format,
|
||||
};
|
||||
use crate::view::GridLayout;
|
||||
|
||||
/// Drill-down state: frozen record snapshot + pending edits that have not
|
||||
/// yet been applied to the model.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DrillState {
|
||||
/// Frozen snapshot of records shown in the drill view.
|
||||
pub records: Vec<(
|
||||
crate::model::cell::CellKey,
|
||||
crate::model::cell::CellValue,
|
||||
)>,
|
||||
/// Frozen snapshot of records shown in the drill view (Rc for cheap cloning).
|
||||
pub records: Rc<Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)>>,
|
||||
/// Pending edits keyed by (record_idx, column_name) → new string value.
|
||||
/// column_name is either "Value" or a category name.
|
||||
pub pending_edits: std::collections::HashMap<(usize, String), String>,
|
||||
@ -100,11 +102,18 @@ pub struct App {
|
||||
pub buffers: HashMap<String, String>,
|
||||
/// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.)
|
||||
pub transient_keymap: Option<Arc<Keymap>>,
|
||||
/// Current grid layout, derived from model + view + drill_state.
|
||||
/// Rebuilt via `rebuild_layout()` after state changes.
|
||||
pub layout: GridLayout,
|
||||
keymap_set: KeymapSet,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(model: Model, file_path: Option<PathBuf>) -> Self {
|
||||
let layout = {
|
||||
let view = model.active_view();
|
||||
GridLayout::with_frozen_records(&model, view, None)
|
||||
};
|
||||
Self {
|
||||
model,
|
||||
file_path,
|
||||
@ -131,17 +140,26 @@ impl App {
|
||||
expanded_cats: std::collections::HashSet::new(),
|
||||
buffers: HashMap::new(),
|
||||
transient_keymap: None,
|
||||
layout,
|
||||
keymap_set: KeymapSet::default_keymaps(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the grid layout from current model, view, and drill state.
|
||||
/// Note: `with_frozen_records` already handles pruning internally.
|
||||
pub fn rebuild_layout(&mut self) {
|
||||
let view = self.model.active_view();
|
||||
let frozen = self.drill_state.as_ref().map(|s| Rc::clone(&s.records));
|
||||
self.layout = GridLayout::with_frozen_records(&self.model, view, frozen);
|
||||
}
|
||||
|
||||
pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> {
|
||||
let view = self.model.active_view();
|
||||
let frozen_records = self.drill_state.as_ref().map(|s| s.records.clone());
|
||||
let layout = GridLayout::with_frozen_records(&self.model, view, frozen_records);
|
||||
let layout = &self.layout;
|
||||
let (sel_row, sel_col) = view.selected;
|
||||
CmdContext {
|
||||
model: &self.model,
|
||||
layout,
|
||||
mode: &self.mode,
|
||||
selected: view.selected,
|
||||
row_offset: view.row_offset,
|
||||
@ -158,34 +176,39 @@ impl App {
|
||||
cat_panel_cursor: self.cat_panel_cursor,
|
||||
view_panel_cursor: self.view_panel_cursor,
|
||||
tile_cat_idx: self.tile_cat_idx,
|
||||
cell_key: layout.cell_key(sel_row, sel_col),
|
||||
row_count: layout.row_count(),
|
||||
col_count: layout.col_count(),
|
||||
none_cats: layout.none_cats.clone(),
|
||||
view_back_stack: self.view_back_stack.clone(),
|
||||
view_forward_stack: self.view_forward_stack.clone(),
|
||||
records_col: if layout.is_records_mode() {
|
||||
Some(layout.col_label(sel_col))
|
||||
view_back_stack: &self.view_back_stack,
|
||||
view_forward_stack: &self.view_forward_stack,
|
||||
display_value: {
|
||||
let key = layout.cell_key(sel_row, sel_col);
|
||||
if let Some(k) = &key {
|
||||
if let Some((idx, dim)) = crate::view::synthetic_record_info(k) {
|
||||
self.drill_state
|
||||
.as_ref()
|
||||
.and_then(|s| s.pending_edits.get(&(idx, dim)).cloned())
|
||||
.or_else(|| layout.resolve_display(k))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
records_value: if layout.is_records_mode() {
|
||||
// Check pending edits first, then fall back to original
|
||||
let col_name = layout.col_label(sel_col);
|
||||
let pending = self.drill_state.as_ref().and_then(|s| {
|
||||
s.pending_edits.get(&(sel_row, col_name.clone())).cloned()
|
||||
});
|
||||
pending.or_else(|| layout.records_display(sel_row, sel_col))
|
||||
self.model
|
||||
.get_cell(k)
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
} else {
|
||||
None
|
||||
String::new()
|
||||
}
|
||||
},
|
||||
// Approximate visible rows/cols from terminal size.
|
||||
// Chrome: title(1) + border(2) + col_headers(n_col_levels) + separator(1)
|
||||
// + tile_bar(1) + status_bar(1) = ~8 rows of chrome.
|
||||
visible_rows: (self.term_height as usize).saturating_sub(8),
|
||||
// Visible cols depends on column widths — use a rough estimate.
|
||||
// The grid renderer does the precise calculation.
|
||||
visible_cols: ((self.term_width as usize).saturating_sub(30) / 12).max(1),
|
||||
visible_cols: {
|
||||
let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format);
|
||||
let col_widths = compute_col_widths(&self.model, layout, fmt_comma, fmt_decimals);
|
||||
let row_header_width = compute_row_header_width(layout);
|
||||
compute_visible_cols(
|
||||
&col_widths,
|
||||
row_header_width,
|
||||
self.term_width,
|
||||
view.col_offset,
|
||||
)
|
||||
},
|
||||
expanded_cats: &self.expanded_cats,
|
||||
key_code: key,
|
||||
}
|
||||
@ -195,6 +218,7 @@ impl App {
|
||||
for effect in effects {
|
||||
effect.apply(self);
|
||||
}
|
||||
self.rebuild_layout();
|
||||
}
|
||||
|
||||
/// True when the model has no categories yet (show welcome screen)
|
||||
@ -203,6 +227,8 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
self.rebuild_layout();
|
||||
|
||||
// Transient keymap (prefix key sequence) takes priority
|
||||
if let Some(transient) = self.transient_keymap.take() {
|
||||
let effects = {
|
||||
@ -247,7 +273,7 @@ impl App {
|
||||
pub fn hint_text(&self) -> &'static str {
|
||||
match &self.mode {
|
||||
AppMode::Normal => "hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd",
|
||||
AppMode::Editing { .. } => "Enter:commit Esc:cancel",
|
||||
AppMode::Editing { .. } => "Enter:commit Tab:commit+right Esc:cancel",
|
||||
AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back",
|
||||
AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
|
||||
AppMode::CategoryPanel => "jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back",
|
||||
@ -371,6 +397,187 @@ mod tests {
|
||||
assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some("q"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn col_offset_scrolls_when_cursor_moves_past_visible_columns() {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
// Create a model with 8 wide columns. Column item names are 30 chars
|
||||
// each → column widths ~31 chars. With term_width=80, row header ~4,
|
||||
// data area ~76 → only ~2 columns actually fit. But the rough estimate
|
||||
// (80−30)/12 = 4 over-counts, so viewport_effects never scrolls.
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Row").unwrap();
|
||||
m.add_category("Col").unwrap();
|
||||
m.category_mut("Row").unwrap().add_item("R1");
|
||||
for i in 0..8 {
|
||||
let name = format!("VeryLongColumnItemName_{i:03}");
|
||||
m.category_mut("Col").unwrap().add_item(&name);
|
||||
}
|
||||
// Populate a value so the model isn't empty
|
||||
let key = CellKey::new(vec![
|
||||
("Row".to_string(), "R1".to_string()),
|
||||
("Col".to_string(), "VeryLongColumnItemName_000".to_string()),
|
||||
]);
|
||||
m.set_cell(key, CellValue::Number(1.0));
|
||||
|
||||
let mut app = App::new(m, None);
|
||||
app.term_width = 80;
|
||||
|
||||
// Press 'l' (right) 3 times to move cursor to column 3.
|
||||
// Only ~2 columns fit in 76 chars of data area (each col ~26 chars wide),
|
||||
// so column 3 is well off-screen. The buggy estimate (80−30)/12 = 4
|
||||
// thinks 4 columns fit, so it won't scroll until col 4.
|
||||
for _ in 0..3 {
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
app.model.active_view().selected.1,
|
||||
3,
|
||||
"cursor should be at column 3"
|
||||
);
|
||||
assert!(
|
||||
app.model.active_view().col_offset > 0,
|
||||
"col_offset should scroll when cursor moves past visible area (only ~2 cols fit \
|
||||
in 80-char terminal with 26-char-wide columns), but col_offset is {}",
|
||||
app.model.active_view().col_offset
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_jumps_to_first_col() {
|
||||
let mut app = two_col_model();
|
||||
app.model.active_view_mut().selected = (1, 1);
|
||||
app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.model.active_view().selected, (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_jumps_to_last_col() {
|
||||
let mut app = two_col_model();
|
||||
app.model.active_view_mut().selected = (1, 0);
|
||||
app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.model.active_view().selected, (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_down_scrolls_by_three_quarters_visible() {
|
||||
let mut app = two_col_model();
|
||||
// Add enough rows
|
||||
for i in 0..30 {
|
||||
app.model
|
||||
.category_mut("Row")
|
||||
.unwrap()
|
||||
.add_item(&format!("R{i}"));
|
||||
}
|
||||
app.term_height = 28; // ~20 visible rows → delta = 15
|
||||
app.model.active_view_mut().selected = (0, 0);
|
||||
app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.model.active_view().selected.1, 0, "column preserved");
|
||||
assert!(
|
||||
app.model.active_view().selected.0 > 0,
|
||||
"row should advance on PageDown"
|
||||
);
|
||||
// 3/4 of ~20 = 15
|
||||
assert_eq!(app.model.active_view().selected.0, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_up_scrolls_backward() {
|
||||
let mut app = two_col_model();
|
||||
for i in 0..30 {
|
||||
app.model
|
||||
.category_mut("Row")
|
||||
.unwrap()
|
||||
.add_item(&format!("R{i}"));
|
||||
}
|
||||
app.term_height = 28;
|
||||
app.model.active_view_mut().selected = (20, 0);
|
||||
app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.model.active_view().selected.0, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_last_row_scrolls_with_small_terminal() {
|
||||
let mut app = two_col_model();
|
||||
// Total rows: A, B, C + R0..R9 = 13 rows. Last row = 12.
|
||||
for i in 0..10 {
|
||||
app.model.category_mut("Row").unwrap().add_item(&format!("R{i}"));
|
||||
}
|
||||
app.term_height = 13; // ~5 visible rows
|
||||
app.model.active_view_mut().selected = (0, 0);
|
||||
// G jumps to last row (row 12)
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
let last = app.model.active_view().selected.0;
|
||||
assert_eq!(last, 12, "should be at last row");
|
||||
// With only ~5 visible rows and 13 rows, offset should scroll.
|
||||
// Bug: hardcoded 20 means `12 >= 0 + 20` is false → no scroll.
|
||||
let offset = app.model.active_view().row_offset;
|
||||
assert!(
|
||||
offset > 0,
|
||||
"row_offset should scroll when last row is beyond visible area, but is {offset}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_d_scrolls_viewport_with_small_terminal() {
|
||||
let mut app = two_col_model();
|
||||
for i in 0..30 {
|
||||
app.model
|
||||
.category_mut("Row")
|
||||
.unwrap()
|
||||
.add_item(&format!("R{i}"));
|
||||
}
|
||||
app.term_height = 13; // ~5 visible rows
|
||||
app.model.active_view_mut().selected = (0, 0);
|
||||
// Ctrl+d scrolls by 5 rows
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
|
||||
.unwrap();
|
||||
assert_eq!(app.model.active_view().selected.0, 5);
|
||||
// Press Ctrl+d again — now at row 10 with only 5 visible rows,
|
||||
// row_offset should have scrolled (not stay at 0 due to hardcoded 20)
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
|
||||
.unwrap();
|
||||
assert_eq!(app.model.active_view().selected.0, 10);
|
||||
assert!(
|
||||
app.model.active_view().row_offset > 0,
|
||||
"row_offset should scroll with small terminal, but is {}",
|
||||
app.model.active_view().row_offset
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_in_edit_mode_commits_and_moves_right() {
|
||||
let mut app = two_col_model();
|
||||
app.model.active_view_mut().selected = (0, 0);
|
||||
// Enter edit mode
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert!(matches!(app.mode, AppMode::Editing { .. }));
|
||||
// Type a digit
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
// Press Tab — should commit, move right, re-enter edit mode
|
||||
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
// Should be in edit mode on column 1
|
||||
assert!(
|
||||
matches!(app.mode, AppMode::Editing { .. }),
|
||||
"should be in edit mode after Tab, but mode is {:?}",
|
||||
app.mode
|
||||
);
|
||||
assert_eq!(
|
||||
app.model.active_view().selected.1,
|
||||
1,
|
||||
"should have moved to column 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_mode_buffer_cleared_on_reentry() {
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
@ -49,7 +49,7 @@ impl<'a> Widget for CategoryPanel<'a> {
|
||||
let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add;
|
||||
|
||||
let (border_color, title) = if is_active {
|
||||
(Color::Cyan, " Categories n:new d:del Space:axis ")
|
||||
(Color::Cyan, " Categories ")
|
||||
} else {
|
||||
(Color::DarkGray, " Categories ")
|
||||
};
|
||||
|
||||
@ -97,15 +97,7 @@ pub struct EnterEditAtCursor;
|
||||
impl Effect for EnterEditAtCursor {
|
||||
fn apply(&self, app: &mut App) {
|
||||
let ctx = app.cmd_context(crossterm::event::KeyCode::Null, crossterm::event::KeyModifiers::NONE);
|
||||
let 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()
|
||||
};
|
||||
let value = ctx.display_value.clone();
|
||||
drop(ctx);
|
||||
app.buffers.insert("edit".to_string(), value);
|
||||
app.mode = AppMode::Editing {
|
||||
@ -406,7 +398,7 @@ pub struct StartDrill(pub Vec<(CellKey, CellValue)>);
|
||||
impl Effect for StartDrill {
|
||||
fn apply(&self, app: &mut App) {
|
||||
app.drill_state = Some(super::app::DrillState {
|
||||
records: self.0.clone(),
|
||||
records: std::rc::Rc::new(self.0.clone()),
|
||||
pending_edits: std::collections::HashMap::new(),
|
||||
});
|
||||
}
|
||||
@ -838,6 +830,16 @@ pub enum Panel {
|
||||
View,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
pub fn mode(self) -> AppMode {
|
||||
match self {
|
||||
Panel::Formula => AppMode::FormulaPanel,
|
||||
Panel::Category => AppMode::CategoryPanel,
|
||||
Panel::View => AppMode::ViewPanel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Effect for SetPanelOpen {
|
||||
fn apply(&self, app: &mut App) {
|
||||
match self.panel {
|
||||
|
||||
238
src/ui/grid.rs
238
src/ui/grid.rs
@ -6,7 +6,6 @@ use ratatui::{
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::model::cell::CellValue;
|
||||
use crate::model::Model;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::view::{AxisEntry, GridLayout};
|
||||
@ -23,6 +22,7 @@ const GROUP_COLLAPSED: &str = "▶";
|
||||
|
||||
pub struct GridWidget<'a> {
|
||||
pub model: &'a Model,
|
||||
pub layout: &'a GridLayout,
|
||||
pub mode: &'a AppMode,
|
||||
pub search_query: &'a str,
|
||||
pub buffers: &'a std::collections::HashMap<String, String>,
|
||||
@ -32,6 +32,7 @@ pub struct GridWidget<'a> {
|
||||
impl<'a> GridWidget<'a> {
|
||||
pub fn new(
|
||||
model: &'a Model,
|
||||
layout: &'a GridLayout,
|
||||
mode: &'a AppMode,
|
||||
search_query: &'a str,
|
||||
buffers: &'a std::collections::HashMap<String, String>,
|
||||
@ -39,6 +40,7 @@ impl<'a> GridWidget<'a> {
|
||||
) -> Self {
|
||||
Self {
|
||||
model,
|
||||
layout,
|
||||
mode,
|
||||
search_query,
|
||||
buffers,
|
||||
@ -46,23 +48,9 @@ impl<'a> GridWidget<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// In records mode, get the display text for (row, col): pending edit if
|
||||
/// staged, otherwise the underlying record's value for that column.
|
||||
fn records_cell_text(&self, layout: &GridLayout, row: usize, col: usize) -> String {
|
||||
let col_name = layout.col_label(col);
|
||||
let pending = self
|
||||
.drill_state
|
||||
.and_then(|s| s.pending_edits.get(&(row, col_name.clone())).cloned());
|
||||
pending
|
||||
.or_else(|| layout.records_display(row, col))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
|
||||
let view = self.model.active_view();
|
||||
|
||||
let frozen = self.drill_state.map(|s| s.records.clone());
|
||||
let layout = GridLayout::with_frozen_records(self.model, view, frozen);
|
||||
let layout = self.layout;
|
||||
let (sel_row, sel_col) = view.selected;
|
||||
let row_offset = view.row_offset;
|
||||
let col_offset = view.col_offset;
|
||||
@ -71,56 +59,9 @@ impl<'a> GridWidget<'a> {
|
||||
let n_col_levels = layout.col_cats.len().max(1);
|
||||
let n_row_levels = layout.row_cats.len().max(1);
|
||||
|
||||
// ── Adaptive column widths ────────────────────────────────────
|
||||
// Size each column to fit its widest content (header + cell values)
|
||||
// plus 1 char gap. Minimum MIN_COL_WIDTH, capped at MAX_COL_WIDTH.
|
||||
let col_widths: Vec<u16> = {
|
||||
let n = layout.col_count();
|
||||
let mut widths = vec![0u16; n];
|
||||
// Measure column header labels
|
||||
for ci in 0..n {
|
||||
let header = layout.col_label(ci);
|
||||
let w = header.width() as u16;
|
||||
if w > widths[ci] {
|
||||
widths[ci] = w;
|
||||
}
|
||||
}
|
||||
// Measure cell content
|
||||
if layout.is_records_mode() {
|
||||
for ri in 0..layout.row_count() {
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
let s = self.records_cell_text(&layout, ri, ci);
|
||||
let w = s.width() as u16;
|
||||
if w > *wref {
|
||||
*wref = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Pivot mode: measure formatted cell values
|
||||
for ri in 0..layout.row_count() {
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
if let Some(key) = layout.cell_key(ri, ci) {
|
||||
let value =
|
||||
self.model.evaluate_aggregated(&key, &layout.none_cats);
|
||||
let s = format_value(value.as_ref(), fmt_comma, fmt_decimals);
|
||||
let w = s.width() as u16;
|
||||
if w > *wref {
|
||||
*wref = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// +1 for gap between columns
|
||||
widths
|
||||
.into_iter()
|
||||
.map(|w| (w + 1).max(MIN_COL_WIDTH).min(MAX_COL_WIDTH))
|
||||
.collect()
|
||||
};
|
||||
let col_widths = compute_col_widths(self.model, &layout, fmt_comma, fmt_decimals);
|
||||
|
||||
// ── Adaptive row header widths ───────────────────────────────
|
||||
// Measure the widest label at each row-header level.
|
||||
let data_row_items: Vec<&Vec<String>> = layout
|
||||
.row_items
|
||||
.iter()
|
||||
@ -410,23 +351,15 @@ impl<'a> GridWidget<'a> {
|
||||
}
|
||||
let cw = col_w_at(ci) as usize;
|
||||
|
||||
let (cell_str, value) = if layout.is_records_mode() {
|
||||
let s = self.records_cell_text(&layout, ri, ci);
|
||||
// In records mode the value is a string, not aggregated
|
||||
let v = if !s.is_empty() {
|
||||
Some(crate::model::cell::CellValue::Text(s.clone()))
|
||||
// Check pending drill edits first, then use display_text
|
||||
let cell_str = if let Some(ds) = self.drill_state {
|
||||
let col_name = layout.col_label(ci);
|
||||
ds.pending_edits
|
||||
.get(&(ri, col_name))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(s, v)
|
||||
} else {
|
||||
let key = match layout.cell_key(ri, ci) {
|
||||
Some(k) => k,
|
||||
None => continue,
|
||||
};
|
||||
let value = self.model.evaluate_aggregated(&key, &layout.none_cats);
|
||||
let s = format_value(value.as_ref(), fmt_comma, fmt_decimals);
|
||||
(s, value)
|
||||
layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals)
|
||||
};
|
||||
let is_selected = ri == sel_row && ci == sel_col;
|
||||
let is_search_match = !self.search_query.is_empty()
|
||||
@ -453,13 +386,13 @@ impl<'a> GridWidget<'a> {
|
||||
} else if is_search_match {
|
||||
Style::default().fg(Color::Black).bg(Color::Yellow)
|
||||
} else if is_sel_row {
|
||||
let fg = if value.is_none() {
|
||||
let fg = if cell_str.is_empty() {
|
||||
Color::DarkGray
|
||||
} else {
|
||||
Color::White
|
||||
};
|
||||
Style::default().fg(fg).bg(ROW_HIGHLIGHT_BG)
|
||||
} else if value.is_none() {
|
||||
} else if cell_str.is_empty() {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default()
|
||||
@ -588,52 +521,110 @@ impl<'a> Widget for GridWidget<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String {
|
||||
match v {
|
||||
Some(CellValue::Number(n)) => format_f64(*n, comma, decimals),
|
||||
Some(CellValue::Text(s)) => s.clone(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_number_format(fmt: &str) -> (bool, u8) {
|
||||
let comma = fmt.contains(',');
|
||||
let decimals = fmt
|
||||
.rfind('.')
|
||||
.and_then(|i| fmt[i + 1..].parse::<u8>().ok())
|
||||
.unwrap_or(0);
|
||||
(comma, decimals)
|
||||
}
|
||||
|
||||
pub fn format_f64(n: f64, comma: bool, decimals: u8) -> String {
|
||||
let formatted = format!("{:.prec$}", n, prec = decimals as usize);
|
||||
if !comma {
|
||||
return formatted;
|
||||
}
|
||||
// Split integer and decimal parts
|
||||
let (int_part, dec_part) = if let Some(dot) = formatted.find('.') {
|
||||
(&formatted[..dot], Some(&formatted[dot..]))
|
||||
/// Compute adaptive column widths for pivot mode (header labels + cell values).
|
||||
/// Header widths use the widest *individual* level label (not the joined
|
||||
/// multi-level string), matching how the grid renderer draws each level on
|
||||
/// its own row with repeat-suppression.
|
||||
pub fn compute_col_widths(model: &Model, layout: &GridLayout, fmt_comma: bool, fmt_decimals: u8) -> Vec<u16> {
|
||||
let n = layout.col_count();
|
||||
let mut widths = vec![0u16; n];
|
||||
// Measure individual header level labels
|
||||
let data_col_items: Vec<&Vec<String>> = layout
|
||||
.col_items
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let AxisEntry::DataItem(v) = e {
|
||||
Some(v)
|
||||
} else {
|
||||
(&formatted[..], None)
|
||||
};
|
||||
let is_neg = int_part.starts_with('-');
|
||||
let digits = if is_neg { &int_part[1..] } else { int_part };
|
||||
let mut result = String::new();
|
||||
for (idx, c) in digits.chars().rev().enumerate() {
|
||||
if idx > 0 && idx % 3 == 0 {
|
||||
result.push(',');
|
||||
None
|
||||
}
|
||||
result.push(c);
|
||||
})
|
||||
.collect();
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
if let Some(levels) = data_col_items.get(ci) {
|
||||
let max_level_w = levels.iter().map(|s| s.width() as u16).max().unwrap_or(0);
|
||||
if max_level_w > *wref {
|
||||
*wref = max_level_w;
|
||||
}
|
||||
if is_neg {
|
||||
result.push('-');
|
||||
}
|
||||
let mut out: String = result.chars().rev().collect();
|
||||
if let Some(dec) = dec_part {
|
||||
out.push_str(dec);
|
||||
}
|
||||
out
|
||||
// Measure cell content widths (works for both pivot and records modes)
|
||||
for ri in 0..layout.row_count() {
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
let s = layout.display_text(model, ri, ci, fmt_comma, fmt_decimals);
|
||||
let w = s.width() as u16;
|
||||
if w > *wref {
|
||||
*wref = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Measure total row (column sums) — pivot mode only
|
||||
if !layout.is_records_mode() && layout.row_count() > 0 {
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
let total: f64 = (0..layout.row_count())
|
||||
.filter_map(|ri| layout.cell_key(ri, ci))
|
||||
.map(|key| model.evaluate_aggregated_f64(&key, &layout.none_cats))
|
||||
.sum();
|
||||
let s = format_f64(total, fmt_comma, fmt_decimals);
|
||||
let w = s.width() as u16;
|
||||
if w > *wref {
|
||||
*wref = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
widths
|
||||
.into_iter()
|
||||
.map(|w| (w + 1).max(MIN_COL_WIDTH).min(MAX_COL_WIDTH))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compute the total row header width from the layout's row items.
|
||||
pub fn compute_row_header_width(layout: &GridLayout) -> u16 {
|
||||
let n_row_levels = layout.row_cats.len().max(1);
|
||||
let data_row_items: Vec<&Vec<String>> = layout
|
||||
.row_items
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let AxisEntry::DataItem(v) = e {
|
||||
Some(v)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sub_widths: Vec<u16> = (0..n_row_levels)
|
||||
.map(|d| {
|
||||
let max_label = data_row_items
|
||||
.iter()
|
||||
.filter_map(|v| v.get(d))
|
||||
.map(|s| s.width() as u16)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
(max_label + 1).max(MIN_ROW_HEADER_W).min(MAX_ROW_HEADER_W)
|
||||
})
|
||||
.collect();
|
||||
sub_widths.iter().sum()
|
||||
}
|
||||
|
||||
/// Count how many columns fit starting from `col_offset` given the available width.
|
||||
pub fn compute_visible_cols(col_widths: &[u16], row_header_width: u16, term_width: u16, col_offset: usize) -> usize {
|
||||
// Account for grid border (2 chars)
|
||||
let data_area_width = term_width.saturating_sub(2).saturating_sub(row_header_width);
|
||||
let mut acc = 0u16;
|
||||
let mut count = 0usize;
|
||||
for ci in col_offset..col_widths.len() {
|
||||
let w = col_widths[ci];
|
||||
if acc + w > data_area_width {
|
||||
break;
|
||||
}
|
||||
acc += w;
|
||||
count += 1;
|
||||
}
|
||||
count.max(1)
|
||||
}
|
||||
|
||||
// Re-export shared formatting functions
|
||||
pub use crate::format::{format_f64, parse_number_format};
|
||||
|
||||
fn truncate(s: &str, max_width: usize) -> String {
|
||||
let w = s.width();
|
||||
@ -674,7 +665,8 @@ mod tests {
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let bufs = std::collections::HashMap::new();
|
||||
GridWidget::new(model, &AppMode::Normal, "", &bufs, None).render(area, &mut buf);
|
||||
let layout = GridLayout::new(model, model.active_view());
|
||||
GridWidget::new(model, &layout, &AppMode::Normal, "", &bufs, None).render(area, &mut buf);
|
||||
buf
|
||||
}
|
||||
|
||||
|
||||
@ -36,7 +36,7 @@ impl<'a> Widget for ViewPanel<'a> {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style)
|
||||
.title(" Views [Enter] switch [n]ew [d]elete ");
|
||||
.title(" Views ");
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
|
||||
@ -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)
|
||||
has_value[ri][ci] = self
|
||||
.cell_key(ri, ci)
|
||||
.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()]))
|
||||
.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