4 Commits

Author SHA1 Message Date
00499fc2bf test(cmd): update tests for layout refactor
Updated unit tests in src/command/cmd.rs to use the new GridLayout-based
CmdContext and layout accessors. Tests now construct CmdContext with a
layout argument and verify behavior of navigation and selection commands
under the refactored layout logic. No functional changes to command logic;
only test updates.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (bartowski/nvidia_Nemotron-Cascade-2-30B-A3B-GGUF)
2026-04-07 09:16:25 -07:00
178983bcbf feat(ui): add new edge/jump commands and keymap
Introduced new commands: JumpToEdge (first/last row/col), PageScroll, and
OpenRecordRow. Updated command registry to use these commands and unified
key handling. Added format module for formatting functions. Updated main.rs
to include format module. Updated keymap to bind new commands and page
scroll.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (bartowski/nvidia_Nemotron-Cascade-2-30B-A3B-GGUF)
2026-04-07 09:16:25 -07:00
e09ddf71a7 refactor!(ui): use GridLayout for layout and display
Rebuilt App to hold a GridLayout and recompute it on state changes. Updated
cmd_context to use layout and display_value. Replaced manual width
calculations with compute_col_widths and compute_visible_cols. Updated
GridWidget to use layout and drill_state. Added Panel::mode helper and
updated UI titles. Fixed display logic for records mode using
layout.display_text.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (bartowski/nvidia_Nemotron-Cascade-2-30B-A3B-GGUF)
2026-04-07 09:16:25 -07:00
f8f8f537c3 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)
2026-04-07 09:16:25 -07:00
12 changed files with 861 additions and 537 deletions

View File

@ -12,6 +12,7 @@ use crate::view::{Axis, AxisEntry, GridLayout};
/// Read-only context available to commands for decision-making. /// Read-only context available to commands for decision-making.
pub struct CmdContext<'a> { pub struct CmdContext<'a> {
pub model: &'a Model, pub model: &'a Model,
pub layout: &'a GridLayout,
pub mode: &'a AppMode, pub mode: &'a AppMode,
pub selected: (usize, usize), pub selected: (usize, usize),
pub row_offset: usize, pub row_offset: usize,
@ -31,24 +32,12 @@ pub struct CmdContext<'a> {
pub tile_cat_idx: usize, pub tile_cat_idx: usize,
/// Named text buffers /// Named text buffers
pub buffers: &'a HashMap<String, String>, pub buffers: &'a HashMap<String, String>,
/// Pre-resolved cell key at the cursor position (None if out of bounds)
pub cell_key: Option<crate::model::cell::CellKey>,
/// Grid dimensions (so commands don't need GridLayout)
pub row_count: usize,
pub col_count: usize,
/// Categories on Axis::None — aggregated away in the current view
pub none_cats: Vec<String>,
/// View navigation stacks (for drill back/forward) /// View navigation stacks (for drill back/forward)
pub view_back_stack: Vec<String>, pub view_back_stack: &'a [String],
pub view_forward_stack: Vec<String>, pub view_forward_stack: &'a [String],
/// Records-mode info (drill view). None for normal pivot views. /// Display value at the cursor — works uniformly for pivot and records mode.
/// When Some, edits stage to drill_state.pending_edits. pub display_value: String,
pub records_col: Option<String>,
/// The display value at the cursor in records mode (including any
/// pending edit override). None for normal pivot views.
pub records_value: Option<String>,
/// How many data rows/cols fit on screen (for viewport scrolling). /// How many data rows/cols fit on screen (for viewport scrolling).
/// Defaults to generous fallbacks when unknown.
pub visible_rows: usize, pub visible_rows: usize,
pub visible_cols: usize, pub visible_cols: usize,
/// Expanded categories in the tree panel /// Expanded categories in the tree panel
@ -57,6 +46,21 @@ pub struct CmdContext<'a> {
pub key_code: KeyCode, pub key_code: KeyCode,
} }
impl<'a> CmdContext<'a> {
pub fn cell_key(&self) -> Option<CellKey> {
self.layout.cell_key(self.selected.0, self.selected.1)
}
pub fn row_count(&self) -> usize {
self.layout.row_count()
}
pub fn col_count(&self) -> usize {
self.layout.col_count()
}
pub fn none_cats(&self) -> &[String] {
&self.layout.none_cats
}
}
impl<'a> CmdContext<'a> { impl<'a> CmdContext<'a> {
/// Resolve the category panel tree entry at the current cursor. /// Resolve the category panel tree entry at the current cursor.
pub fn cat_tree_entry(&self) -> Option<crate::ui::cat_tree::CatTreeEntry> { pub fn cat_tree_entry(&self) -> Option<crate::ui::cat_tree::CatTreeEntry> {
@ -251,8 +255,8 @@ impl CursorState {
Self { Self {
row: ctx.selected.0, row: ctx.selected.0,
col: ctx.selected.1, col: ctx.selected.1,
row_count: ctx.row_count, row_count: ctx.row_count(),
col_count: ctx.col_count, col_count: ctx.col_count(),
row_offset: ctx.row_offset, row_offset: ctx.row_offset,
col_offset: ctx.col_offset, col_offset: ctx.col_offset,
visible_rows: ctx.visible_rows, visible_rows: ctx.visible_rows,
@ -317,75 +321,32 @@ impl Cmd for MoveSelection {
} }
} }
/// Unified jump-to-edge: jump to first/last row or column.
/// `is_row` selects the axis; `end` selects first (false) or last (true).
#[derive(Debug)] #[derive(Debug)]
pub struct JumpToFirstRow { pub struct JumpToEdge {
pub col: usize, pub cursor: CursorState,
pub is_row: bool,
pub end: bool,
pub cmd_name: &'static str,
} }
impl Cmd for JumpToFirstRow { impl Cmd for JumpToEdge {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"jump-first-row" self.cmd_name
} }
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
vec![ let (nr, nc) = if self.is_row {
Box::new(effect::SetSelected(0, self.col)), let r = if self.end { self.cursor.row_count.saturating_sub(1) } else { 0 };
Box::new(effect::SetRowOffset(0)), (r, self.cursor.col)
] } else {
} let c = if self.end { self.cursor.col_count.saturating_sub(1) } else { 0 };
} (self.cursor.row, c)
};
#[derive(Debug)] viewport_effects(
pub struct JumpToLastRow { nr, nc,
pub col: usize, self.cursor.row_offset, self.cursor.col_offset,
pub row_count: usize, self.cursor.visible_rows, self.cursor.visible_cols,
pub row_offset: usize, )
}
impl Cmd for JumpToLastRow {
fn name(&self) -> &'static str {
"jump-last-row"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let last = self.row_count.saturating_sub(1);
let mut effects: Vec<Box<dyn Effect>> = vec![Box::new(effect::SetSelected(last, self.col))];
if last >= self.row_offset + 20 {
effects.push(Box::new(effect::SetRowOffset(last.saturating_sub(19))));
}
effects
}
}
#[derive(Debug)]
pub struct JumpToFirstCol {
pub row: usize,
}
impl Cmd for JumpToFirstCol {
fn name(&self) -> &'static str {
"jump-first-col"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
vec![
Box::new(effect::SetSelected(self.row, 0)),
Box::new(effect::SetColOffset(0)),
]
}
}
#[derive(Debug)]
pub struct JumpToLastCol {
pub row: usize,
pub col_count: usize,
pub col_offset: usize,
}
impl Cmd for JumpToLastCol {
fn name(&self) -> &'static str {
"jump-last-col"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let last = self.col_count.saturating_sub(1);
let mut effects: Vec<Box<dyn Effect>> = vec![Box::new(effect::SetSelected(self.row, last))];
if last >= self.col_offset + 8 {
effects.push(Box::new(effect::SetColOffset(last.saturating_sub(7))));
}
effects
} }
} }
@ -401,19 +362,38 @@ impl Cmd for ScrollRows {
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let row_max = self.cursor.row_count.saturating_sub(1) as i32; let row_max = self.cursor.row_count.saturating_sub(1) as i32;
let nr = (self.cursor.row as i32 + self.delta).clamp(0, row_max) as usize; let nr = (self.cursor.row as i32 + self.delta).clamp(0, row_max) as usize;
let mut effects: Vec<Box<dyn Effect>> = viewport_effects(
vec![Box::new(effect::SetSelected(nr, self.cursor.col))]; nr,
let mut row_offset = self.cursor.row_offset; self.cursor.col,
if nr < row_offset { self.cursor.row_offset,
row_offset = nr; self.cursor.col_offset,
self.cursor.visible_rows,
self.cursor.visible_cols,
)
} }
if nr >= row_offset + 20 { }
row_offset = nr.saturating_sub(19);
#[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"
} }
if row_offset != self.cursor.row_offset { fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
effects.push(Box::new(effect::SetRowOffset(row_offset))); 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;
effects let nr = (self.cursor.row as i32 + delta).clamp(0, row_max) as usize;
viewport_effects(
nr,
self.cursor.col,
self.cursor.row_offset,
self.cursor.col_offset,
self.cursor.visible_rows,
self.cursor.visible_cols,
)
} }
} }
@ -471,7 +451,7 @@ impl Cmd for SaveAndQuit {
// ── Cell operations ────────────────────────────────────────────────────────── // ── Cell operations ──────────────────────────────────────────────────────────
// All cell commands take an explicit CellKey. The interactive spec fills it // All cell commands take an explicit CellKey. The interactive spec fills it
// from ctx.cell_key; the parser fills it from Cat/Item coordinate args. // from ctx.cell_key(); the parser fills it from Cat/Item coordinate args.
/// Clear a cell. /// Clear a cell.
#[derive(Debug)] #[derive(Debug)]
@ -500,8 +480,7 @@ impl Cmd for YankCell {
"yank" "yank"
} }
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let layout = GridLayout::new(ctx.model, ctx.model.active_view()); let value = ctx.model.evaluate_aggregated(&self.key, ctx.none_cats());
let value = ctx.model.evaluate_aggregated(&self.key, &layout.none_cats);
vec![ vec![
Box::new(effect::SetYanked(value)), Box::new(effect::SetYanked(value)),
effect::set_status("Yanked"), effect::set_status("Yanked"),
@ -590,12 +569,7 @@ impl Cmd for TogglePanelAndFocus {
open: self.open, open: self.open,
})); }));
if self.focused { if self.focused {
let mode = match self.panel { effects.push(effect::change_mode(self.panel.mode()));
Panel::Formula => AppMode::FormulaPanel,
Panel::Category => AppMode::CategoryPanel,
Panel::View => AppMode::ViewPanel,
};
effects.push(effect::change_mode(mode));
} else { } else {
effects.push(effect::change_mode(AppMode::Normal)); effects.push(effect::change_mode(AppMode::Normal));
} }
@ -682,33 +656,29 @@ impl Cmd for EditOrDrill {
// Only consider regular (non-virtual, non-label) categories on None // Only consider regular (non-virtual, non-label) categories on None
// as true aggregation. Virtuals like _Index/_Dim are always None in // as true aggregation. Virtuals like _Index/_Dim are always None in
// pivot mode and don't imply aggregation. // pivot mode and don't imply aggregation.
let regular_none = ctx.none_cats.iter().any(|c| { let regular_none = ctx.none_cats().iter().any(|c| {
ctx.model ctx.model
.category(c) .category(c)
.map(|cat| cat.kind.is_regular()) .map(|cat| cat.kind.is_regular())
.unwrap_or(false) .unwrap_or(false)
}); });
let is_aggregated = ctx.records_col.is_none() && regular_none; // In records mode (synthetic key), always edit directly — no drilling.
let is_synthetic = ctx
.cell_key()
.as_ref()
.and_then(|k| crate::view::synthetic_record_info(k))
.is_some();
let is_aggregated = !is_synthetic && regular_none;
if is_aggregated { if is_aggregated {
let Some(key) = ctx.cell_key.clone() else { let Some(key) = ctx.cell_key().clone() else {
return vec![effect::set_status( return vec![effect::set_status("cannot drill — no cell at cursor")];
"cannot drill — no cell at cursor",
)];
}; };
return DrillIntoCell { key }.execute(ctx); return DrillIntoCell { key }.execute(ctx);
} }
// Edit path: prefer records display value (includes pending edits), EnterEditMode {
// else the underlying cell's stored value. initial_value: ctx.display_value.clone(),
let initial_value = if let Some(v) = &ctx.records_value { }
v.clone() .execute(ctx)
} else {
ctx.cell_key
.as_ref()
.and_then(|k| ctx.model.get_cell(k).cloned())
.map(|v| v.to_string())
.unwrap_or_default()
};
EnterEditMode { initial_value }.execute(ctx)
} }
} }
@ -721,8 +691,15 @@ impl Cmd for AddRecordRow {
"add-record-row" "add-record-row"
} }
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
if ctx.records_col.is_none() { let is_records = ctx
return vec![effect::set_status("add-record-row only works in records mode")]; .cell_key()
.as_ref()
.and_then(|k| crate::view::synthetic_record_info(k))
.is_some();
if !is_records {
return vec![effect::set_status(
"add-record-row only works in records mode",
)];
} }
// Build a CellKey from the current page filters // Build a CellKey from the current page filters
let view = ctx.model.active_view(); let view = ctx.model.active_view();
@ -751,6 +728,30 @@ impl Cmd for AddRecordRow {
} }
} }
/// Vim-style 'o': add a new record row below cursor and enter edit mode.
#[derive(Debug)]
pub struct OpenRecordRow;
impl Cmd for OpenRecordRow {
fn name(&self) -> &'static str {
"open-record-row"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let is_records = ctx
.cell_key()
.as_ref()
.and_then(|k| crate::view::synthetic_record_info(k))
.is_some();
if !is_records {
return vec![effect::set_status(
"open-record-row only works in records mode",
)];
}
let mut effects = AddRecordRow.execute(ctx);
effects.push(Box::new(effect::EnterEditAtCursor));
effects
}
}
/// Typewriter-style advance: move down, wrap to top of next column at bottom. /// Typewriter-style advance: move down, wrap to top of next column at bottom.
#[derive(Debug)] #[derive(Debug)]
pub struct EnterAdvance { pub struct EnterAdvance {
@ -804,10 +805,9 @@ impl Cmd for SearchNavigate {
return vec![]; return vec![];
} }
let layout = GridLayout::new(ctx.model, ctx.model.active_view());
let (cur_row, cur_col) = ctx.selected; let (cur_row, cur_col) = ctx.selected;
let total_rows = layout.row_count().max(1); let total_rows = ctx.row_count().max(1);
let total_cols = layout.col_count().max(1); let total_cols = ctx.col_count().max(1);
let total = total_rows * total_cols; let total = total_rows * total_cols;
let cur_flat = cur_row * total_cols + cur_col; let cur_flat = cur_row * total_cols + cur_col;
@ -815,11 +815,11 @@ impl Cmd for SearchNavigate {
.filter(|&flat| { .filter(|&flat| {
let ri = flat / total_cols; let ri = flat / total_cols;
let ci = flat % total_cols; let ci = flat % total_cols;
let key = match layout.cell_key(ri, ci) { let key = match ctx.layout.cell_key(ri, ci) {
Some(k) => k, Some(k) => k,
None => return false, None => return false,
}; };
let s = match ctx.model.evaluate_aggregated(&key, &layout.none_cats) { let s = match ctx.model.evaluate_aggregated(&key, ctx.none_cats()) {
Some(CellValue::Number(n)) => format!("{n}"), Some(CellValue::Number(n)) => format!("{n}"),
Some(CellValue::Text(t)) => t, Some(CellValue::Text(t)) => t,
None => String::new(), None => String::new(),
@ -1018,9 +1018,8 @@ impl Cmd for ToggleGroupUnderCursor {
"toggle-group-under-cursor" "toggle-group-under-cursor"
} }
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let layout = GridLayout::new(ctx.model, ctx.model.active_view());
let sel_row = ctx.selected.0; let sel_row = ctx.selected.0;
let Some((cat, group)) = layout.row_group_for(sel_row) else { let Some((cat, group)) = ctx.layout.row_group_for(sel_row) else {
return vec![]; return vec![];
}; };
vec![ vec![
@ -1041,9 +1040,8 @@ impl Cmd for ToggleColGroupUnderCursor {
"toggle-col-group-under-cursor" "toggle-col-group-under-cursor"
} }
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let layout = GridLayout::new(ctx.model, ctx.model.active_view());
let sel_col = ctx.selected.1; let sel_col = ctx.selected.1;
let Some((cat, group)) = layout.col_group_for(sel_col) else { let Some((cat, group)) = ctx.layout.col_group_for(sel_col) else {
return vec![]; return vec![];
}; };
// After toggling, col_count may shrink — clamp selection // After toggling, col_count may shrink — clamp selection
@ -1067,12 +1065,12 @@ impl Cmd for HideSelectedRowItem {
"hide-selected-row-item" "hide-selected-row-item"
} }
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let layout = GridLayout::new(ctx.model, ctx.model.active_view()); let Some(cat_name) = ctx.layout.row_cats.first().cloned() else {
let Some(cat_name) = layout.row_cats.first().cloned() else {
return vec![]; return vec![];
}; };
let sel_row = ctx.selected.0; let sel_row = ctx.selected.0;
let Some(items) = layout let Some(items) = ctx
.layout
.row_items .row_items
.iter() .iter()
.filter_map(|e| { .filter_map(|e| {
@ -1199,7 +1197,7 @@ impl Cmd for DrillIntoCell {
// Previously-aggregated categories (none_cats) stay on Axis::None so // Previously-aggregated categories (none_cats) stay on Axis::None so
// they don't filter records; they'll appear as columns in records mode. // they don't filter records; they'll appear as columns in records mode.
// Skip virtual categories — we already set _Index/_Dim above. // Skip virtual categories — we already set _Index/_Dim above.
for cat in &ctx.none_cats { for cat in ctx.none_cats() {
if fixed_cats.contains(cat) || cat.starts_with('_') { if fixed_cats.contains(cat) || cat.starts_with('_') {
continue; continue;
} }
@ -1436,64 +1434,38 @@ impl Cmd for ToggleRecordsMode {
"toggle-records-mode" "toggle-records-mode"
} }
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
use crate::view::Axis; let is_records = ctx.layout.is_records_mode();
let view = ctx.model.active_view();
// Detect current state
let is_records = view
.category_axes
.get("_Index")
.copied()
== Some(Axis::Row)
&& view.category_axes.get("_Dim").copied() == Some(Axis::Column);
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
if is_records { if is_records {
// Switch back to pivot: auto-assign axes // Navigate back to the previous view (restores original axes)
// First regular category → Row, second → Column, rest → Page, return vec![Box::new(effect::ViewBack), effect::set_status("Pivot mode")];
// virtuals/labels → None.
let mut row_done = false;
let mut col_done = false;
for (name, cat) in &ctx.model.categories {
let axis = if !cat.kind.is_regular() {
Axis::None
} else if !row_done {
row_done = true;
Axis::Row
} else if !col_done {
col_done = true;
Axis::Column
} else {
Axis::Page
};
effects.push(Box::new(effect::SetAxis {
category: name.clone(),
axis,
}));
} }
effects.push(effect::set_status("Pivot mode"));
} else { let mut effects: Vec<Box<dyn Effect>> = Vec::new();
// Switch to records mode let records_name = "_Records".to_string();
// Create (or replace) a _Records view and switch to it
effects.push(Box::new(effect::CreateView(records_name.clone())));
effects.push(Box::new(effect::SwitchView(records_name)));
// _Index on Row, _Dim on Column, everything else → None
effects.push(Box::new(effect::SetAxis { effects.push(Box::new(effect::SetAxis {
category: "_Index".to_string(), category: "_Index".to_string(),
axis: Axis::Row, axis: crate::view::Axis::Row,
})); }));
effects.push(Box::new(effect::SetAxis { effects.push(Box::new(effect::SetAxis {
category: "_Dim".to_string(), category: "_Dim".to_string(),
axis: Axis::Column, axis: crate::view::Axis::Column,
})); }));
// Everything else → None
for name in ctx.model.categories.keys() { for name in ctx.model.categories.keys() {
if name != "_Index" && name != "_Dim" { if name != "_Index" && name != "_Dim" {
effects.push(Box::new(effect::SetAxis { effects.push(Box::new(effect::SetAxis {
category: name.clone(), category: name.clone(),
axis: Axis::None, axis: crate::view::Axis::None,
})); }));
} }
} }
effects.push(effect::set_status("Records mode")); effects.push(effect::set_status("Records mode"));
}
effects effects
} }
} }
@ -1928,14 +1900,34 @@ impl Cmd for PopChar {
/// Commit a cell edit: set cell value, advance cursor, return to Normal. /// Commit a cell edit: set cell value, advance cursor, return to Normal.
/// In records mode, stages the edit in drill_state.pending_edits instead of /// In records mode, stages the edit in drill_state.pending_edits instead of
/// writing directly to the model. /// Commit a cell value: for synthetic records keys, stage in drill pending edits
/// or apply directly; for real keys, write to the model.
fn commit_cell_value(key: &CellKey, value: &str, effects: &mut Vec<Box<dyn Effect>>) {
if let Some((record_idx, col_name)) = crate::view::synthetic_record_info(key) {
effects.push(Box::new(effect::SetDrillPendingEdit {
record_idx,
col_name,
new_value: value.to_string(),
}));
} else if value.is_empty() {
effects.push(Box::new(effect::ClearCell(key.clone())));
effects.push(effect::mark_dirty());
} else if let Ok(n) = value.parse::<f64>() {
effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Number(n))));
effects.push(effect::mark_dirty());
} else {
effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Text(value.to_string()))));
effects.push(effect::mark_dirty());
}
}
/// Commit a cell edit: set cell value, advance cursor, return to editing.
/// In records mode with drill, stages the edit in drill_state.pending_edits.
/// In records mode without drill or in pivot mode, writes directly to the model.
#[derive(Debug)] #[derive(Debug)]
pub struct CommitCellEdit { pub struct CommitCellEdit {
pub key: crate::model::cell::CellKey, pub key: CellKey,
pub value: String, pub value: String,
/// Records-mode edit: (record_idx, column_name). When Some, stage in
/// pending_edits; otherwise write to the model directly.
pub records_edit: Option<(usize, String)>,
} }
impl Cmd for CommitCellEdit { impl Cmd for CommitCellEdit {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
@ -1944,29 +1936,7 @@ impl Cmd for CommitCellEdit {
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let mut effects: Vec<Box<dyn Effect>> = Vec::new(); let mut effects: Vec<Box<dyn Effect>> = Vec::new();
if let Some((record_idx, col_name)) = &self.records_edit { commit_cell_value(&self.key, &self.value, &mut effects);
// Stage the edit in drill_state.pending_edits
effects.push(Box::new(effect::SetDrillPendingEdit {
record_idx: *record_idx,
col_name: col_name.clone(),
new_value: self.value.clone(),
}));
} else if self.value.is_empty() {
effects.push(Box::new(effect::ClearCell(self.key.clone())));
effects.push(effect::mark_dirty());
} else if let Ok(n) = self.value.parse::<f64>() {
effects.push(Box::new(effect::SetCell(
self.key.clone(),
CellValue::Number(n),
)));
effects.push(effect::mark_dirty());
} else {
effects.push(Box::new(effect::SetCell(
self.key.clone(),
CellValue::Text(self.value.clone()),
)));
effects.push(effect::mark_dirty());
}
// Advance cursor down (typewriter-style) and re-enter edit mode // Advance cursor down (typewriter-style) and re-enter edit mode
// at the new cell so the user can continue data entry. // at the new cell so the user can continue data entry.
let adv = EnterAdvance { let adv = EnterAdvance {
@ -1978,6 +1948,36 @@ impl Cmd for CommitCellEdit {
} }
} }
/// Tab in editing: commit cell, move right, re-enter edit mode (Excel-style).
#[derive(Debug)]
pub struct CommitAndAdvanceRight {
pub key: CellKey,
pub value: String,
pub cursor: CursorState,
}
impl Cmd for CommitAndAdvanceRight {
fn name(&self) -> &'static str {
"commit-and-advance-right"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
commit_cell_value(&self.key, &self.value, &mut effects);
// Move right instead of down
let col_max = self.cursor.col_count.saturating_sub(1);
let nc = (self.cursor.col + 1).min(col_max);
effects.extend(viewport_effects(
self.cursor.row,
nc,
self.cursor.row_offset,
self.cursor.col_offset,
self.cursor.visible_rows,
self.cursor.visible_cols,
));
effects.push(Box::new(effect::EnterEditAtCursor));
effects
}
}
/// Commit a formula from the formula edit buffer. /// Commit a formula from the formula edit buffer.
#[derive(Debug)] #[derive(Debug)]
pub struct CommitFormula; pub struct CommitFormula;
@ -2430,7 +2430,7 @@ pub fn default_registry() -> CmdRegistry {
})) }))
}, },
|_args, ctx| { |_args, ctx| {
let key = ctx.cell_key.clone().ok_or("no cell at cursor")?; let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
Ok(Box::new(ClearCellCommand { key })) Ok(Box::new(ClearCellCommand { key }))
}, },
); );
@ -2482,58 +2482,20 @@ pub fn default_registry() -> CmdRegistry {
})) }))
}, },
); );
r.register( // Jump-to-edge commands: first/last row/col
&JumpToFirstRow { col: 0 }, macro_rules! reg_jump {
|_| Ok(Box::new(JumpToFirstRow { col: 0 })), ($r:expr, $is_row:expr, $end:expr, $name:expr) => {
|_, ctx| { $r.register(
Ok(Box::new(JumpToFirstRow { &JumpToEdge { cursor: CursorState::default(), is_row: $is_row, end: $end, cmd_name: $name },
col: ctx.selected.1, |_| Ok(Box::new(JumpToEdge { cursor: CursorState::default(), is_row: $is_row, end: $end, cmd_name: $name })),
})) |_, ctx| Ok(Box::new(JumpToEdge { cursor: CursorState::from_ctx(ctx), is_row: $is_row, end: $end, cmd_name: $name })),
},
);
r.register(
&JumpToLastRow { col: 0, row_count: 0, row_offset: 0 },
|_| {
Ok(Box::new(JumpToLastRow {
col: 0,
row_count: 0,
row_offset: 0,
}))
},
|_, ctx| {
Ok(Box::new(JumpToLastRow {
col: ctx.selected.1,
row_count: ctx.row_count,
row_offset: ctx.row_offset,
}))
},
);
r.register(
&JumpToFirstCol { row: 0 },
|_| Ok(Box::new(JumpToFirstCol { row: 0 })),
|_, ctx| {
Ok(Box::new(JumpToFirstCol {
row: ctx.selected.0,
}))
},
);
r.register(
&JumpToLastCol { row: 0, col_count: 0, col_offset: 0 },
|_| {
Ok(Box::new(JumpToLastCol {
row: 0,
col_count: 0,
col_offset: 0,
}))
},
|_, ctx| {
Ok(Box::new(JumpToLastCol {
row: ctx.selected.0,
col_count: ctx.col_count,
col_offset: ctx.col_offset,
}))
},
); );
};
}
reg_jump!(r, true, false, "jump-first-row");
reg_jump!(r, true, true, "jump-last-row");
reg_jump!(r, false, false, "jump-first-col");
reg_jump!(r, false, true, "jump-last-col");
r.register( r.register(
&ScrollRows { delta: 0, cursor: CursorState::default() }, &ScrollRows { delta: 0, cursor: CursorState::default() },
|args| { |args| {
@ -2562,6 +2524,25 @@ pub fn default_registry() -> CmdRegistry {
})) }))
}, },
); );
r.register(
&PageScroll { direction: 0, cursor: CursorState::default() },
|args| {
require_args("page-scroll", args, 1)?;
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(PageScroll {
direction: dir,
cursor: CursorState::default(),
}))
},
|args, ctx| {
require_args("page-scroll", args, 1)?;
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(PageScroll {
direction: dir,
cursor: CursorState::from_ctx(ctx),
}))
},
);
r.register( r.register(
&EnterAdvance { cursor: CursorState::default() }, &EnterAdvance { cursor: CursorState::default() },
|_| { |_| {
@ -2597,12 +2578,14 @@ pub fn default_registry() -> CmdRegistry {
})) }))
}, },
|_args, ctx| { |_args, ctx| {
let key = ctx.cell_key.clone().ok_or("no cell at cursor")?; let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
Ok(Box::new(YankCell { key })) Ok(Box::new(YankCell { key }))
}, },
); );
r.register( r.register(
&PasteCell { key: CellKey::new(vec![]) }, &PasteCell {
key: CellKey::new(vec![]),
},
|args| { |args| {
if args.is_empty() { if args.is_empty() {
return Err("paste requires at least one Cat/Item coordinate".into()); return Err("paste requires at least one Cat/Item coordinate".into());
@ -2612,11 +2595,11 @@ pub fn default_registry() -> CmdRegistry {
})) }))
}, },
|_args, ctx| { |_args, ctx| {
let key = ctx.cell_key.clone().ok_or("no cell at cursor")?; let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
Ok(Box::new(PasteCell { key })) Ok(Box::new(PasteCell { key }))
}, },
); );
// clear-cell is registered above (unified: ctx.cell_key or explicit coords) // clear-cell is registered above (unified: ctx.cell_key() or explicit coords)
// ── View / page ────────────────────────────────────────────────────── // ── View / page ──────────────────────────────────────────────────────
r.register_nullary(|| Box::new(TransposeAxes)); r.register_nullary(|| Box::new(TransposeAxes));
@ -2663,7 +2646,7 @@ pub fn default_registry() -> CmdRegistry {
})) }))
}, },
|_args, ctx| { |_args, ctx| {
let key = ctx.cell_key.clone().ok_or("no cell at cursor")?; let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
Ok(Box::new(DrillIntoCell { key })) Ok(Box::new(DrillIntoCell { key }))
}, },
); );
@ -2824,6 +2807,7 @@ pub fn default_registry() -> CmdRegistry {
Box::new(DeleteFormulaAtCursor) Box::new(DeleteFormulaAtCursor)
}); });
r.register_nullary(|| Box::new(AddRecordRow)); r.register_nullary(|| Box::new(AddRecordRow));
r.register_nullary(|| Box::new(OpenRecordRow));
r.register_nullary(|| Box::new(TogglePruneEmpty)); r.register_nullary(|| Box::new(TogglePruneEmpty));
r.register_nullary(|| Box::new(ToggleRecordsMode)); r.register_nullary(|| Box::new(ToggleRecordsMode));
r.register_nullary(|| Box::new(CycleAxisAtCursor)); r.register_nullary(|| Box::new(CycleAxisAtCursor));
@ -2877,35 +2861,36 @@ pub fn default_registry() -> CmdRegistry {
&CommitCellEdit { &CommitCellEdit {
key: CellKey::new(vec![]), key: CellKey::new(vec![]),
value: String::new(), value: String::new(),
records_edit: None,
}, },
|args| { |args| {
// parse: commit-cell-edit <value> <Cat/Item>...
if args.len() < 2 { if args.len() < 2 {
return Err("commit-cell-edit requires a value and coords".into()); return Err("commit-cell-edit requires a value and coords".into());
} }
Ok(Box::new(CommitCellEdit { Ok(Box::new(CommitCellEdit {
key: parse_cell_key_from_args(&args[1..]), key: parse_cell_key_from_args(&args[1..]),
value: args[0].clone(), value: args[0].clone(),
records_edit: None,
})) }))
}, },
|_args, ctx| { |_args, ctx| {
let value = read_buffer(ctx, "edit"); let value = read_buffer(ctx, "edit");
// In records mode, stage the edit instead of writing to the model let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
if let Some(col_name) = &ctx.records_col { Ok(Box::new(CommitCellEdit { key, value }))
let record_idx = ctx.selected.0; },
return Ok(Box::new(CommitCellEdit { );
key: CellKey::new(vec![]), // ignored in records mode r.register(
value, &CommitAndAdvanceRight {
records_edit: Some((record_idx, col_name.clone())), key: CellKey::new(vec![]),
})); value: String::new(),
} cursor: CursorState::default(),
let key = ctx.cell_key.clone().ok_or("no cell at cursor")?; },
Ok(Box::new(CommitCellEdit { |_| Err("commit-and-advance-right requires context".into()),
|_args, ctx| {
let value = read_buffer(ctx, "edit");
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
Ok(Box::new(CommitAndAdvanceRight {
key, key,
value, value,
records_edit: None, cursor: CursorState::from_ctx(ctx),
})) }))
}, },
); );
@ -2951,12 +2936,16 @@ mod tests {
static EMPTY_EXPANDED: std::sync::LazyLock<std::collections::HashSet<String>> = static EMPTY_EXPANDED: std::sync::LazyLock<std::collections::HashSet<String>> =
std::sync::LazyLock::new(std::collections::HashSet::new); std::sync::LazyLock::new(std::collections::HashSet::new);
fn make_ctx(model: &Model) -> CmdContext<'_> { fn make_layout(model: &Model) -> GridLayout {
GridLayout::new(model, model.active_view())
}
fn make_ctx<'a>(model: &'a Model, layout: &'a GridLayout) -> CmdContext<'a> {
let view = model.active_view(); let view = model.active_view();
let layout = GridLayout::new(model, view);
let (sr, sc) = view.selected; let (sr, sc) = view.selected;
CmdContext { CmdContext {
model, model,
layout,
mode: &AppMode::Normal, mode: &AppMode::Normal,
selected: view.selected, selected: view.selected,
row_offset: view.row_offset, row_offset: view.row_offset,
@ -2973,14 +2962,15 @@ mod tests {
view_panel_cursor: 0, view_panel_cursor: 0,
tile_cat_idx: 0, tile_cat_idx: 0,
buffers: &EMPTY_BUFFERS, buffers: &EMPTY_BUFFERS,
none_cats: layout.none_cats.clone(), view_back_stack: &[],
view_back_stack: Vec::new(), view_forward_stack: &[],
view_forward_stack: Vec::new(), display_value: {
records_col: None, let key = layout.cell_key(sr, sc);
records_value: None, key.as_ref()
cell_key: layout.cell_key(sr, sc), .and_then(|k| model.get_cell(k).cloned())
row_count: layout.row_count(), .map(|v| v.to_string())
col_count: layout.col_count(), .unwrap_or_default()
},
visible_rows: 20, visible_rows: 20,
visible_cols: 8, visible_cols: 8,
expanded_cats: &EMPTY_EXPANDED, expanded_cats: &EMPTY_EXPANDED,
@ -3002,7 +2992,8 @@ mod tests {
#[test] #[test]
fn move_selection_down_produces_set_selected() { fn move_selection_down_produces_set_selected() {
let m = two_cat_model(); let m = two_cat_model();
let ctx = make_ctx(&m); let layout = make_layout(&m);
let ctx = make_ctx(&m, &layout);
let cmd = MoveSelection { let cmd = MoveSelection {
dr: 1, dr: 1,
dc: 0, dc: 0,
@ -3016,7 +3007,8 @@ mod tests {
#[test] #[test]
fn move_selection_clamps_to_bounds() { fn move_selection_clamps_to_bounds() {
let m = two_cat_model(); 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 // Try to move way past the end
let cmd = MoveSelection { let cmd = MoveSelection {
dr: 100, dr: 100,
@ -3032,7 +3024,8 @@ mod tests {
let m = two_cat_model(); let m = two_cat_model();
let mut bufs = HashMap::new(); let mut bufs = HashMap::new();
bufs.insert("command".to_string(), "q".to_string()); 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.dirty = true;
ctx.buffers = &bufs; ctx.buffers = &bufs;
let cmd = ExecuteCommand; let cmd = ExecuteCommand;
@ -3046,7 +3039,8 @@ mod tests {
let m = two_cat_model(); let m = two_cat_model();
let mut bufs = HashMap::new(); let mut bufs = HashMap::new();
bufs.insert("command".to_string(), "q".to_string()); 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; ctx.buffers = &bufs;
let cmd = ExecuteCommand; let cmd = ExecuteCommand;
let effects = cmd.execute(&ctx); let effects = cmd.execute(&ctx);
@ -3065,9 +3059,10 @@ mod tests {
("Month".to_string(), "Jan".to_string()), ("Month".to_string(), "Jan".to_string()),
]); ]);
m.set_cell(key, CellValue::Number(42.0)); 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 { let cmd = ClearCellCommand {
key: ctx.cell_key.clone().unwrap(), key: ctx.cell_key().clone().unwrap(),
}; };
let effects = cmd.execute(&ctx); let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 2); // ClearCell + MarkDirty assert_eq!(effects.len(), 2); // ClearCell + MarkDirty
@ -3081,9 +3076,10 @@ mod tests {
("Month".to_string(), "Jan".to_string()), ("Month".to_string(), "Jan".to_string()),
]); ]);
m.set_cell(key, CellValue::Number(99.0)); 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 { let cmd = YankCell {
key: ctx.cell_key.clone().unwrap(), key: ctx.cell_key().clone().unwrap(),
}; };
let effects = cmd.execute(&ctx); let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 2); // SetYanked + SetStatus assert_eq!(effects.len(), 2); // SetYanked + SetStatus
@ -3092,7 +3088,8 @@ mod tests {
#[test] #[test]
fn toggle_panel_open_and_focus() { fn toggle_panel_open_and_focus() {
let m = two_cat_model(); let m = two_cat_model();
let ctx = make_ctx(&m); let layout = make_layout(&m);
let ctx = make_ctx(&m, &layout);
let cmd = TogglePanelAndFocus { let cmd = TogglePanelAndFocus {
panel: effect::Panel::Formula, panel: effect::Panel::Formula,
open: true, open: true,
@ -3110,7 +3107,8 @@ mod tests {
#[test] #[test]
fn toggle_panel_close_and_unfocus() { fn toggle_panel_close_and_unfocus() {
let m = two_cat_model(); let m = two_cat_model();
let ctx = make_ctx(&m); let layout = make_layout(&m);
let ctx = make_ctx(&m, &layout);
let cmd = TogglePanelAndFocus { let cmd = TogglePanelAndFocus {
panel: effect::Panel::Formula, panel: effect::Panel::Formula,
open: false, open: false,
@ -3123,7 +3121,8 @@ mod tests {
#[test] #[test]
fn enter_advance_moves_down() { fn enter_advance_moves_down() {
let m = two_cat_model(); let m = two_cat_model();
let ctx = make_ctx(&m); let layout = make_layout(&m);
let ctx = make_ctx(&m, &layout);
let cmd = EnterAdvance { let cmd = EnterAdvance {
cursor: CursorState::from_ctx(&ctx), cursor: CursorState::from_ctx(&ctx),
}; };
@ -3139,7 +3138,8 @@ mod tests {
#[test] #[test]
fn search_navigate_with_empty_query_returns_nothing() { fn search_navigate_with_empty_query_returns_nothing() {
let m = two_cat_model(); 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 cmd = SearchNavigate(true);
let effects = cmd.execute(&ctx); let effects = cmd.execute(&ctx);
assert!(effects.is_empty()); assert!(effects.is_empty());
@ -3148,7 +3148,8 @@ mod tests {
#[test] #[test]
fn enter_edit_mode_produces_editing_mode() { fn enter_edit_mode_produces_editing_mode() {
let m = two_cat_model(); let m = two_cat_model();
let ctx = make_ctx(&m); let layout = make_layout(&m);
let ctx = make_ctx(&m, &layout);
let cmd = EnterEditMode { let cmd = EnterEditMode {
initial_value: String::new(), initial_value: String::new(),
}; };
@ -3161,7 +3162,8 @@ mod tests {
#[test] #[test]
fn enter_tile_select_with_categories() { fn enter_tile_select_with_categories() {
let m = two_cat_model(); 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 cmd = EnterTileSelect;
let effects = cmd.execute(&ctx); let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode
@ -3177,7 +3179,8 @@ mod tests {
// Models always have virtual categories (_Index, _Dim), so tile // Models always have virtual categories (_Index, _Dim), so tile
// select always has something to operate on. // select always has something to operate on.
let m = Model::new("Empty"); 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 cmd = EnterTileSelect;
let effects = cmd.execute(&ctx); let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode
@ -3186,7 +3189,8 @@ mod tests {
#[test] #[test]
fn toggle_group_under_cursor_returns_empty_without_groups() { fn toggle_group_under_cursor_returns_empty_without_groups() {
let m = two_cat_model(); 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 cmd = ToggleGroupUnderCursor;
let effects = cmd.execute(&ctx); let effects = cmd.execute(&ctx);
// No groups defined, so nothing to toggle // No groups defined, so nothing to toggle
@ -3196,7 +3200,8 @@ mod tests {
#[test] #[test]
fn search_or_category_add_without_query_opens_category_add() { fn search_or_category_add_without_query_opens_category_add() {
let m = two_cat_model(); 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 cmd = SearchOrCategoryAdd;
let effects = cmd.execute(&ctx); let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode
@ -3210,7 +3215,8 @@ mod tests {
#[test] #[test]
fn cycle_panel_focus_with_no_panels_open() { fn cycle_panel_focus_with_no_panels_open() {
let m = two_cat_model(); let m = two_cat_model();
let ctx = make_ctx(&m); let layout = make_layout(&m);
let ctx = make_ctx(&m, &layout);
let cmd = CyclePanelFocus { let cmd = CyclePanelFocus {
formula_open: false, formula_open: false,
category_open: false, category_open: false,
@ -3223,7 +3229,8 @@ mod tests {
#[test] #[test]
fn cycle_panel_focus_with_formula_panel_open() { fn cycle_panel_focus_with_formula_panel_open() {
let m = two_cat_model(); 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; ctx.formula_panel_open = true;
let cmd = CyclePanelFocus { let cmd = CyclePanelFocus {
formula_open: true, formula_open: true,

View File

@ -266,10 +266,14 @@ impl KeymapSet {
normal.bind(KeyCode::Char('G'), none, "jump-last-row"); normal.bind(KeyCode::Char('G'), none, "jump-last-row");
normal.bind(KeyCode::Char('0'), none, "jump-first-col"); normal.bind(KeyCode::Char('0'), none, "jump-first-col");
normal.bind(KeyCode::Char('$'), none, "jump-last-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 // Scroll
normal.bind_args(KeyCode::Char('d'), ctrl, "scroll-rows", vec!["5".into()]); 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::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 // Cell operations
normal.bind(KeyCode::Char('x'), none, "clear-cell"); normal.bind(KeyCode::Char('x'), none, "clear-cell");
@ -357,7 +361,7 @@ impl KeymapSet {
// Drill into aggregated cell / view history / add row // Drill into aggregated cell / view history / add row
normal.bind(KeyCode::Char('>'), none, "drill-into-cell"); normal.bind(KeyCode::Char('>'), none, "drill-into-cell");
normal.bind(KeyCode::Char('<'), none, "view-back"); 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 // Records mode toggle and prune toggle
normal.bind(KeyCode::Char('R'), none, "toggle-records-mode"); normal.bind(KeyCode::Char('R'), none, "toggle-records-mode");
@ -560,6 +564,7 @@ impl KeymapSet {
let mut ed = Keymap::new(); let mut ed = Keymap::new();
ed.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]); ed.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
ed.bind(KeyCode::Enter, none, "commit-cell-edit"); 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_args(KeyCode::Backspace, none, "pop-char", vec!["edit".into()]);
ed.bind_any_char("append-char", vec!["edit".into()]); ed.bind_any_char("append-char", vec!["edit".into()]);
set.insert(ModeKey::Editing, Arc::new(ed)); set.insert(ModeKey::Editing, Arc::new(ed));

50
src/format.rs Normal file
View 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
}

View File

@ -1,5 +1,6 @@
mod command; mod command;
mod draw; mod draw;
mod format;
mod formula; mod formula;
mod import; mod import;
mod model; mod model;

View File

@ -458,17 +458,7 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
out.push(','); out.push(',');
} }
let row_values: Vec<String> = (0..layout.col_count()) let row_values: Vec<String> = (0..layout.col_count())
.map(|ci| { .map(|ci| layout.display_text(model, ri, ci, false, 0))
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()
}
})
.collect(); .collect();
out.push_str(&row_values.join(",")); out.push_str(&row_values.join(","));
out.push('\n'); out.push('\n');

View File

@ -5,23 +5,25 @@ use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use std::rc::Rc;
use crate::command::cmd::CmdContext; use crate::command::cmd::CmdContext;
use crate::command::keymap::{Keymap, KeymapSet}; use crate::command::keymap::{Keymap, KeymapSet};
use crate::import::wizard::ImportWizard; use crate::import::wizard::ImportWizard;
use crate::model::cell::CellValue; use crate::model::cell::CellValue;
use crate::model::Model; use crate::model::Model;
use crate::persistence; use crate::persistence;
use crate::ui::grid::{
compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format,
};
use crate::view::GridLayout; use crate::view::GridLayout;
/// Drill-down state: frozen record snapshot + pending edits that have not /// Drill-down state: frozen record snapshot + pending edits that have not
/// yet been applied to the model. /// yet been applied to the model.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct DrillState { pub struct DrillState {
/// Frozen snapshot of records shown in the drill view. /// Frozen snapshot of records shown in the drill view (Rc for cheap cloning).
pub records: Vec<( pub records: Rc<Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)>>,
crate::model::cell::CellKey,
crate::model::cell::CellValue,
)>,
/// Pending edits keyed by (record_idx, column_name) → new string value. /// Pending edits keyed by (record_idx, column_name) → new string value.
/// column_name is either "Value" or a category name. /// column_name is either "Value" or a category name.
pub pending_edits: std::collections::HashMap<(usize, String), String>, pub pending_edits: std::collections::HashMap<(usize, String), String>,
@ -100,11 +102,18 @@ pub struct App {
pub buffers: HashMap<String, String>, pub buffers: HashMap<String, String>,
/// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.) /// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.)
pub transient_keymap: Option<Arc<Keymap>>, 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, keymap_set: KeymapSet,
} }
impl App { impl App {
pub fn new(model: Model, file_path: Option<PathBuf>) -> Self { 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 { Self {
model, model,
file_path, file_path,
@ -131,17 +140,26 @@ impl App {
expanded_cats: std::collections::HashSet::new(), expanded_cats: std::collections::HashSet::new(),
buffers: HashMap::new(), buffers: HashMap::new(),
transient_keymap: None, transient_keymap: None,
layout,
keymap_set: KeymapSet::default_keymaps(), 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<'_> { pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> {
let view = self.model.active_view(); let view = self.model.active_view();
let frozen_records = self.drill_state.as_ref().map(|s| s.records.clone()); let layout = &self.layout;
let layout = GridLayout::with_frozen_records(&self.model, view, frozen_records);
let (sel_row, sel_col) = view.selected; let (sel_row, sel_col) = view.selected;
CmdContext { CmdContext {
model: &self.model, model: &self.model,
layout,
mode: &self.mode, mode: &self.mode,
selected: view.selected, selected: view.selected,
row_offset: view.row_offset, row_offset: view.row_offset,
@ -158,34 +176,39 @@ impl App {
cat_panel_cursor: self.cat_panel_cursor, cat_panel_cursor: self.cat_panel_cursor,
view_panel_cursor: self.view_panel_cursor, view_panel_cursor: self.view_panel_cursor,
tile_cat_idx: self.tile_cat_idx, tile_cat_idx: self.tile_cat_idx,
cell_key: layout.cell_key(sel_row, sel_col), view_back_stack: &self.view_back_stack,
row_count: layout.row_count(), view_forward_stack: &self.view_forward_stack,
col_count: layout.col_count(), display_value: {
none_cats: layout.none_cats.clone(), let key = layout.cell_key(sel_row, sel_col);
view_back_stack: self.view_back_stack.clone(), if let Some(k) = &key {
view_forward_stack: self.view_forward_stack.clone(), if let Some((idx, dim)) = crate::view::synthetic_record_info(k) {
records_col: if layout.is_records_mode() { self.drill_state
Some(layout.col_label(sel_col)) .as_ref()
.and_then(|s| s.pending_edits.get(&(idx, dim)).cloned())
.or_else(|| layout.resolve_display(k))
.unwrap_or_default()
} else { } else {
None self.model
}, .get_cell(k)
records_value: if layout.is_records_mode() { .map(|v| v.to_string())
// Check pending edits first, then fall back to original .unwrap_or_default()
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))
} else { } 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_rows: (self.term_height as usize).saturating_sub(8),
// Visible cols depends on column widths — use a rough estimate. visible_cols: {
// The grid renderer does the precise calculation. let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format);
visible_cols: ((self.term_width as usize).saturating_sub(30) / 12).max(1), 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, expanded_cats: &self.expanded_cats,
key_code: key, key_code: key,
} }
@ -195,6 +218,7 @@ impl App {
for effect in effects { for effect in effects {
effect.apply(self); effect.apply(self);
} }
self.rebuild_layout();
} }
/// True when the model has no categories yet (show welcome screen) /// 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<()> { pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
self.rebuild_layout();
// Transient keymap (prefix key sequence) takes priority // Transient keymap (prefix key sequence) takes priority
if let Some(transient) = self.transient_keymap.take() { if let Some(transient) = self.transient_keymap.take() {
let effects = { let effects = {
@ -247,7 +273,7 @@ impl App {
pub fn hint_text(&self) -> &'static str { pub fn hint_text(&self) -> &'static str {
match &self.mode { match &self.mode {
AppMode::Normal => "hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd", 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::FormulaPanel => "n:new d:delete jk:nav Esc:back",
AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression", 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", 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")); 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
// (8030)/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 (8030)/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] #[test]
fn command_mode_buffer_cleared_on_reentry() { fn command_mode_buffer_cleared_on_reentry() {
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;

View File

@ -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 is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add;
let (border_color, title) = if is_active { let (border_color, title) = if is_active {
(Color::Cyan, " Categories n:new d:del Space:axis ") (Color::Cyan, " Categories ")
} else { } else {
(Color::DarkGray, " Categories ") (Color::DarkGray, " Categories ")
}; };

View File

@ -97,15 +97,7 @@ pub struct EnterEditAtCursor;
impl Effect for EnterEditAtCursor { impl Effect for EnterEditAtCursor {
fn apply(&self, app: &mut App) { fn apply(&self, app: &mut App) {
let ctx = app.cmd_context(crossterm::event::KeyCode::Null, crossterm::event::KeyModifiers::NONE); let ctx = app.cmd_context(crossterm::event::KeyCode::Null, crossterm::event::KeyModifiers::NONE);
let value = if let Some(v) = &ctx.records_value { let value = ctx.display_value.clone();
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()
};
drop(ctx); drop(ctx);
app.buffers.insert("edit".to_string(), value); app.buffers.insert("edit".to_string(), value);
app.mode = AppMode::Editing { app.mode = AppMode::Editing {
@ -406,7 +398,7 @@ pub struct StartDrill(pub Vec<(CellKey, CellValue)>);
impl Effect for StartDrill { impl Effect for StartDrill {
fn apply(&self, app: &mut App) { fn apply(&self, app: &mut App) {
app.drill_state = Some(super::app::DrillState { 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(), pending_edits: std::collections::HashMap::new(),
}); });
} }
@ -838,6 +830,16 @@ pub enum Panel {
View, 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 { impl Effect for SetPanelOpen {
fn apply(&self, app: &mut App) { fn apply(&self, app: &mut App) {
match self.panel { match self.panel {

View File

@ -6,7 +6,6 @@ use ratatui::{
}; };
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use crate::model::cell::CellValue;
use crate::model::Model; use crate::model::Model;
use crate::ui::app::AppMode; use crate::ui::app::AppMode;
use crate::view::{AxisEntry, GridLayout}; use crate::view::{AxisEntry, GridLayout};
@ -23,6 +22,7 @@ const GROUP_COLLAPSED: &str = "▶";
pub struct GridWidget<'a> { pub struct GridWidget<'a> {
pub model: &'a Model, pub model: &'a Model,
pub layout: &'a GridLayout,
pub mode: &'a AppMode, pub mode: &'a AppMode,
pub search_query: &'a str, pub search_query: &'a str,
pub buffers: &'a std::collections::HashMap<String, String>, pub buffers: &'a std::collections::HashMap<String, String>,
@ -32,6 +32,7 @@ pub struct GridWidget<'a> {
impl<'a> GridWidget<'a> { impl<'a> GridWidget<'a> {
pub fn new( pub fn new(
model: &'a Model, model: &'a Model,
layout: &'a GridLayout,
mode: &'a AppMode, mode: &'a AppMode,
search_query: &'a str, search_query: &'a str,
buffers: &'a std::collections::HashMap<String, String>, buffers: &'a std::collections::HashMap<String, String>,
@ -39,6 +40,7 @@ impl<'a> GridWidget<'a> {
) -> Self { ) -> Self {
Self { Self {
model, model,
layout,
mode, mode,
search_query, search_query,
buffers, 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) { fn render_grid(&self, area: Rect, buf: &mut Buffer) {
let view = self.model.active_view(); let view = self.model.active_view();
let layout = self.layout;
let frozen = self.drill_state.map(|s| s.records.clone());
let layout = GridLayout::with_frozen_records(self.model, view, frozen);
let (sel_row, sel_col) = view.selected; let (sel_row, sel_col) = view.selected;
let row_offset = view.row_offset; let row_offset = view.row_offset;
let col_offset = view.col_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_col_levels = layout.col_cats.len().max(1);
let n_row_levels = layout.row_cats.len().max(1); let n_row_levels = layout.row_cats.len().max(1);
// ── Adaptive column widths ──────────────────────────────────── let col_widths = compute_col_widths(self.model, &layout, fmt_comma, fmt_decimals);
// 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()
};
// ── Adaptive row header widths ─────────────────────────────── // ── Adaptive row header widths ───────────────────────────────
// Measure the widest label at each row-header level.
let data_row_items: Vec<&Vec<String>> = layout let data_row_items: Vec<&Vec<String>> = layout
.row_items .row_items
.iter() .iter()
@ -410,23 +351,15 @@ impl<'a> GridWidget<'a> {
} }
let cw = col_w_at(ci) as usize; let cw = col_w_at(ci) as usize;
let (cell_str, value) = if layout.is_records_mode() { // Check pending drill edits first, then use display_text
let s = self.records_cell_text(&layout, ri, ci); let cell_str = if let Some(ds) = self.drill_state {
// In records mode the value is a string, not aggregated let col_name = layout.col_label(ci);
let v = if !s.is_empty() { ds.pending_edits
Some(crate::model::cell::CellValue::Text(s.clone())) .get(&(ri, col_name))
.cloned()
.unwrap_or_else(|| layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals))
} else { } else {
None layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals)
};
(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)
}; };
let is_selected = ri == sel_row && ci == sel_col; let is_selected = ri == sel_row && ci == sel_col;
let is_search_match = !self.search_query.is_empty() let is_search_match = !self.search_query.is_empty()
@ -453,13 +386,13 @@ impl<'a> GridWidget<'a> {
} else if is_search_match { } else if is_search_match {
Style::default().fg(Color::Black).bg(Color::Yellow) Style::default().fg(Color::Black).bg(Color::Yellow)
} else if is_sel_row { } else if is_sel_row {
let fg = if value.is_none() { let fg = if cell_str.is_empty() {
Color::DarkGray Color::DarkGray
} else { } else {
Color::White Color::White
}; };
Style::default().fg(fg).bg(ROW_HIGHLIGHT_BG) Style::default().fg(fg).bg(ROW_HIGHLIGHT_BG)
} else if value.is_none() { } else if cell_str.is_empty() {
Style::default().fg(Color::DarkGray) Style::default().fg(Color::DarkGray)
} else { } else {
Style::default() Style::default()
@ -588,53 +521,111 @@ impl<'a> Widget for GridWidget<'a> {
} }
} }
fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String { /// Compute adaptive column widths for pivot mode (header labels + cell values).
match v { /// Header widths use the widest *individual* level label (not the joined
Some(CellValue::Number(n)) => format_f64(*n, comma, decimals), /// multi-level string), matching how the grid renderer draws each level on
Some(CellValue::Text(s)) => s.clone(), /// its own row with repeat-suppression.
None => String::new(), 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
pub fn parse_number_format(fmt: &str) -> (bool, u8) { let data_col_items: Vec<&Vec<String>> = layout
let comma = fmt.contains(','); .col_items
let decimals = fmt .iter()
.rfind('.') .filter_map(|e| {
.and_then(|i| fmt[i + 1..].parse::<u8>().ok()) if let AxisEntry::DataItem(v) = e {
.unwrap_or(0); Some(v)
(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..]))
} else { } else {
(&formatted[..], None) 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); })
.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 { fn truncate(s: &str, max_width: usize) -> String {
let w = s.width(); let w = s.width();
if w <= max_width { if w <= max_width {
@ -674,7 +665,8 @@ mod tests {
let area = Rect::new(0, 0, width, height); let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area); let mut buf = Buffer::empty(area);
let bufs = std::collections::HashMap::new(); 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 buf
} }

View File

@ -36,7 +36,7 @@ impl<'a> Widget for ViewPanel<'a> {
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(border_style) .border_style(border_style)
.title(" Views [Enter] switch [n]ew [d]elete "); .title(" Views ");
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);

View File

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

View File

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