feat(records): implement records mode for data entry

Implement a new "Records" mode for data entry.
- Add `RecordsNormal` and `RecordsEditing` to `AppMode` and `ModeKey` .
- `DataStore` now uses `IndexMap` and supports `sort_by_key()` to ensure
  deterministic row order.
- `ToggleRecordsMode` command now sorts data and switches to
  `RecordsNormal` .
- `EnterEditMode` command now respects records editing variants.
- `RecordsNormal` mode includes a new `o` keybinding to add a record row.
- `RecordsEditing` mode inherits from `Editing` and adds an `Esc` binding
  to return to `RecordsNormal` .
- Added `SortData` effect to trigger data sorting.
- Updated UI to display "RECORDS" and "RECORDS INSERT" mode names and
  styles.
- Updated keymaps, command registry, and view navigation to support these
  new modes.
- Added comprehensive tests for records mode behavior, including sorting
  and boundary conditions for Tab/Enter.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-26B-A4B-it-UD-Q5_K_XL.gguf)
This commit is contained in:
Edward Langley
2026-04-15 21:32:35 -07:00
parent ded35f705c
commit 030865a0ff
10 changed files with 350 additions and 32 deletions

View File

@ -79,6 +79,12 @@ pub enum AppMode {
},
Help,
Quit,
/// Records-mode normal: inherits from Normal with records-specific bindings.
RecordsNormal,
/// Records-mode editing: inherits from Editing with boundary-aware Tab/Enter.
RecordsEditing {
minibuf: MinibufferConfig,
},
}
impl AppMode {
@ -86,6 +92,7 @@ impl AppMode {
pub fn minibuffer(&self) -> Option<&MinibufferConfig> {
match self {
Self::Editing { minibuf, .. }
| Self::RecordsEditing { minibuf, .. }
| Self::FormulaEdit { minibuf, .. }
| Self::CommandMode { minibuf, .. }
| Self::CategoryAdd { minibuf, .. }
@ -110,6 +117,21 @@ impl AppMode {
}
}
pub fn records_editing() -> Self {
Self::RecordsEditing {
minibuf: MinibufferConfig {
buffer_key: "edit",
prompt: "edit: ".into(),
color: Color::Green,
},
}
}
/// True when this mode is a records-mode variant.
pub fn is_records(&self) -> bool {
matches!(self, Self::RecordsNormal | Self::RecordsEditing { .. })
}
pub fn formula_edit() -> Self {
Self::FormulaEdit {
minibuf: MinibufferConfig {
@ -408,7 +430,12 @@ impl App {
AppMode::Normal => {
"hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd"
}
AppMode::Editing { .. } => "Enter:commit Tab:commit+right Esc:cancel",
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => {
"Enter:commit Tab:commit+right Esc:cancel"
}
AppMode::RecordsNormal => {
"hjkl:nav i:edit o:add-row R:pivot P:prune <:back ::cmd"
}
AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back",
AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
AppMode::CategoryPanel => {
@ -726,6 +753,43 @@ mod tests {
}
/// Regression: pressing `o` in an empty records view should create the
/// Pressing R to enter records mode should sort existing data by CellKey
/// so display order is deterministic regardless of insertion order.
#[test]
fn entering_records_mode_sorts_existing_data() {
use crate::model::cell::{CellKey, CellValue};
let mut m = Model::new("T");
m.add_category("Region").unwrap();
m.category_mut("Region").unwrap().add_item("North");
m.category_mut("Region").unwrap().add_item("East");
// Insert in reverse-alphabetical order
m.set_cell(
CellKey::new(vec![("Region".into(), "North".into())]),
CellValue::Number(1.0),
);
m.set_cell(
CellKey::new(vec![("Region".into(), "East".into())]),
CellValue::Number(2.0),
);
let mut app = App::new(m, None);
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
assert!(app.layout.is_records_mode());
let region_col = (0..app.layout.col_count())
.find(|&c| app.layout.col_label(c) == "Region")
.unwrap();
let row0 = app.layout.records_display(0, region_col).unwrap();
let row1 = app.layout.records_display(1, region_col).unwrap();
assert_eq!(
row0, "East",
"R should sort existing data: first row should be East"
);
assert_eq!(
row1, "North",
"R should sort existing data: second row should be North"
);
}
/// first synthetic row instead of only entering edit mode on empty space.
#[test]
fn add_record_row_in_empty_records_view_creates_first_row() {
@ -748,7 +812,7 @@ mod tests {
"o should create the first record row in an empty records view"
);
assert!(
matches!(app.mode, AppMode::Editing { .. }),
app.mode.is_editing(),
"o should leave the app in edit mode, got {:?}",
app.mode
);
@ -787,14 +851,107 @@ mod tests {
.unwrap();
assert_eq!(
app.model.get_cell(&CellKey::new(vec![
("_Measure".to_string(), "Rev".to_string()),
])),
app.model.get_cell(&CellKey::new(vec![(
"_Measure".to_string(),
"Rev".to_string()
),])),
Some(&crate::model::cell::CellValue::Number(5.0)),
"editing a synthetic row in plain records mode should write the value"
);
}
/// Build a records-mode app with two data rows for testing Tab/Enter
/// behavior at boundaries. Row 0 has _Measure=meas2, row 1 has _Measure=meas1.
fn records_model_with_two_rows() -> App {
use crate::model::cell::{CellKey, CellValue};
let mut m = Model::new("T");
m.add_category("Region").unwrap();
m.category_mut("Region").unwrap().add_item("North");
m.category_mut("_Measure").unwrap().add_item("meas1");
m.category_mut("_Measure").unwrap().add_item("meas2");
m.set_cell(
CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "meas2".into()),
]),
CellValue::Number(10.0),
);
m.set_cell(
CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "meas1".into()),
]),
CellValue::Number(20.0),
);
let mut app = App::new(m, None);
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
assert!(
app.layout.is_records_mode(),
"setup: should be records mode"
);
assert_eq!(app.layout.row_count(), 2, "setup: should have 2 records");
let cols: Vec<String> = (0..app.layout.col_count())
.map(|i| app.layout.col_label(i))
.collect();
assert!(
cols.contains(&"Region".to_string()),
"setup: should have Region column; got {:?}",
cols
);
assert!(
cols.contains(&"_Measure".to_string()),
"setup: should have _Measure column; got {:?}",
cols
);
assert_eq!(
cols.last().unwrap(),
"Value",
"setup: Value must be last column; got {:?}",
cols
);
app
}
/// improvise-hmu: TAB on the bottom-right cell of records view should
/// insert a new record below and move to the first cell of the new row
/// in edit mode.
#[test]
fn tab_on_bottom_right_of_records_inserts_below() {
let mut app = records_model_with_two_rows();
let initial_rows = app.layout.row_count();
assert!(initial_rows >= 1, "setup: need at least 1 record");
let last_row = initial_rows - 1;
let last_col = app.layout.col_count() - 1;
app.model.active_view_mut().selected = (last_row, last_col);
// Enter edit mode on the bottom-right cell
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
assert!(app.mode.is_editing(), "setup: should be editing");
// TAB should commit, insert below, move to first cell of new row
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.layout.row_count(),
initial_rows + 1,
"TAB on bottom-right should insert a record below"
);
assert_eq!(
app.model.active_view().selected,
(initial_rows, 0),
"TAB should move to first cell of the new row"
);
assert!(
app.mode.is_editing(),
"should enter edit mode on the new cell, got {:?}",
app.mode
);
}
/// Drill-view edits should stay staged in drill state until the user
/// navigates back, at which point ApplyAndClearDrill writes them through.
#[test]

View File

@ -59,6 +59,14 @@ impl Effect for AddItemInGroup {
}
}
#[derive(Debug)]
pub struct SortData;
impl Effect for SortData {
fn apply(&self, app: &mut App) {
app.model.data.sort_by_key();
}
}
#[derive(Debug)]
pub struct SetCell(pub CellKey, pub CellValue);
impl Effect for SetCell {
@ -127,7 +135,11 @@ impl Effect for EnterEditAtCursor {
let value = ctx.display_value.clone();
drop(ctx);
app.buffers.insert("edit".to_string(), value);
app.mode = AppMode::editing();
app.mode = if app.mode.is_records() {
AppMode::records_editing()
} else {
AppMode::editing()
};
}
}