Merge branch 'main' into worktree-improvise-ewi-formula-crate
# Conflicts: # src/ui/app.rs # src/ui/effect.rs # src/view/layout.rs
This commit is contained in:
183
src/ui/app.rs
183
src/ui/app.rs
@ -20,6 +20,13 @@ use crate::ui::grid::{
|
||||
use crate::view::GridLayout;
|
||||
use crate::workbook::Workbook;
|
||||
|
||||
/// A saved view+mode pair for the navigation stack.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ViewFrame {
|
||||
pub view_name: String,
|
||||
pub mode: AppMode,
|
||||
}
|
||||
|
||||
/// Drill-down state: frozen record snapshot + pending edits that have not
|
||||
/// yet been applied to the model.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@ -72,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 {
|
||||
@ -79,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, .. }
|
||||
@ -88,6 +102,11 @@ impl AppMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// True for any cell-editing mode (normal or records).
|
||||
pub fn is_editing(&self) -> bool {
|
||||
matches!(self, Self::Editing { .. } | Self::RecordsEditing { .. })
|
||||
}
|
||||
|
||||
pub fn editing() -> Self {
|
||||
Self::Editing {
|
||||
minibuf: MinibufferConfig {
|
||||
@ -98,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 {
|
||||
@ -173,9 +207,9 @@ pub struct App {
|
||||
pub tile_cat_idx: usize,
|
||||
/// View navigation history: views visited before the current one.
|
||||
/// Pushed on SwitchView, popped by `<` (back).
|
||||
pub view_back_stack: Vec<String>,
|
||||
pub view_back_stack: Vec<ViewFrame>,
|
||||
/// Views that were "back-ed" from, available for forward navigation (`>`).
|
||||
pub view_forward_stack: Vec<String>,
|
||||
pub view_forward_stack: Vec<ViewFrame>,
|
||||
/// Frozen records list for the drill view. When present, this is the
|
||||
/// snapshot that records-mode layouts iterate — records don't disappear
|
||||
/// when filters would change. Pending edits are stored alongside and
|
||||
@ -389,7 +423,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 => {
|
||||
@ -710,6 +749,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 wb = Workbook::new("T");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.model.category_mut("Region").unwrap().add_item("North");
|
||||
wb.model.category_mut("Region").unwrap().add_item("East");
|
||||
// Insert in reverse-alphabetical order
|
||||
wb.model.set_cell(
|
||||
CellKey::new(vec![("Region".into(), "North".into())]),
|
||||
CellValue::Number(1.0),
|
||||
);
|
||||
wb.model.set_cell(
|
||||
CellKey::new(vec![("Region".into(), "East".into())]),
|
||||
CellValue::Number(2.0),
|
||||
);
|
||||
let mut app = App::new(wb, 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() {
|
||||
@ -732,7 +808,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
|
||||
);
|
||||
@ -771,14 +847,107 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
app.workbook.model.get_cell(&CellKey::new(vec![
|
||||
("_Measure".to_string(), "Rev".to_string()),
|
||||
])),
|
||||
app.workbook.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 wb = Workbook::new("T");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.model.category_mut("Region").unwrap().add_item("North");
|
||||
wb.model.category_mut("_Measure").unwrap().add_item("meas1");
|
||||
wb.model.category_mut("_Measure").unwrap().add_item("meas2");
|
||||
wb.model.set_cell(
|
||||
CellKey::new(vec![
|
||||
("Region".into(), "North".into()),
|
||||
("_Measure".into(), "meas2".into()),
|
||||
]),
|
||||
CellValue::Number(10.0),
|
||||
);
|
||||
wb.model.set_cell(
|
||||
CellKey::new(vec![
|
||||
("Region".into(), "North".into()),
|
||||
("_Measure".into(), "meas1".into()),
|
||||
]),
|
||||
CellValue::Number(20.0),
|
||||
);
|
||||
let mut app = App::new(wb, 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.workbook.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.workbook.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]
|
||||
|
||||
@ -4,7 +4,7 @@ use std::path::PathBuf;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::view::Axis;
|
||||
|
||||
use super::app::{App, AppMode};
|
||||
use super::app::{App, AppMode, ViewFrame};
|
||||
|
||||
pub(crate) const RECORD_COORDS_CANNOT_BE_EMPTY: &str = "Record coordinates cannot be empty";
|
||||
|
||||
@ -59,6 +59,14 @@ impl Effect for AddItemInGroup {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SortData;
|
||||
impl Effect for SortData {
|
||||
fn apply(&self, app: &mut App) {
|
||||
app.workbook.model.data.sort_by_key();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SetCell(pub CellKey, pub CellValue);
|
||||
impl Effect for SetCell {
|
||||
@ -128,7 +136,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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -194,7 +206,10 @@ impl Effect for SwitchView {
|
||||
fn apply(&self, app: &mut App) {
|
||||
let current = app.workbook.active_view.clone();
|
||||
if current != self.0 {
|
||||
app.view_back_stack.push(current);
|
||||
app.view_back_stack.push(ViewFrame {
|
||||
view_name: current,
|
||||
mode: app.mode.clone(),
|
||||
});
|
||||
app.view_forward_stack.clear();
|
||||
}
|
||||
let _ = app.workbook.switch_view(&self.0);
|
||||
@ -206,10 +221,14 @@ impl Effect for SwitchView {
|
||||
pub struct ViewBack;
|
||||
impl Effect for ViewBack {
|
||||
fn apply(&self, app: &mut App) {
|
||||
if let Some(prev) = app.view_back_stack.pop() {
|
||||
if let Some(frame) = app.view_back_stack.pop() {
|
||||
let current = app.workbook.active_view.clone();
|
||||
app.view_forward_stack.push(current);
|
||||
let _ = app.workbook.switch_view(&prev);
|
||||
app.view_forward_stack.push(ViewFrame {
|
||||
view_name: current,
|
||||
mode: app.mode.clone(),
|
||||
});
|
||||
let _ = app.workbook.switch_view(&frame.view_name);
|
||||
app.mode = frame.mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -219,10 +238,14 @@ impl Effect for ViewBack {
|
||||
pub struct ViewForward;
|
||||
impl Effect for ViewForward {
|
||||
fn apply(&self, app: &mut App) {
|
||||
if let Some(next) = app.view_forward_stack.pop() {
|
||||
if let Some(frame) = app.view_forward_stack.pop() {
|
||||
let current = app.workbook.active_view.clone();
|
||||
app.view_back_stack.push(current);
|
||||
let _ = app.workbook.switch_view(&next);
|
||||
app.view_back_stack.push(ViewFrame {
|
||||
view_name: current,
|
||||
mode: app.mode.clone(),
|
||||
});
|
||||
let _ = app.workbook.switch_view(&frame.view_name);
|
||||
app.mode = frame.mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1137,7 +1160,8 @@ mod tests {
|
||||
|
||||
SwitchView("View 2".to_string()).apply(&mut app);
|
||||
assert_eq!(app.workbook.active_view.as_str(), "View 2");
|
||||
assert_eq!(app.view_back_stack, vec!["Default".to_string()]);
|
||||
assert_eq!(app.view_back_stack.len(), 1);
|
||||
assert_eq!(app.view_back_stack[0].view_name, "Default");
|
||||
// Forward stack should be cleared
|
||||
assert!(app.view_forward_stack.is_empty());
|
||||
}
|
||||
@ -1159,13 +1183,15 @@ mod tests {
|
||||
// Go back
|
||||
ViewBack.apply(&mut app);
|
||||
assert_eq!(app.workbook.active_view.as_str(), "Default");
|
||||
assert_eq!(app.view_forward_stack, vec!["View 2".to_string()]);
|
||||
assert_eq!(app.view_forward_stack.len(), 1);
|
||||
assert_eq!(app.view_forward_stack[0].view_name, "View 2");
|
||||
assert!(app.view_back_stack.is_empty());
|
||||
|
||||
// Go forward
|
||||
ViewForward.apply(&mut app);
|
||||
assert_eq!(app.workbook.active_view.as_str(), "View 2");
|
||||
assert_eq!(app.view_back_stack, vec!["Default".to_string()]);
|
||||
assert_eq!(app.view_back_stack.len(), 1);
|
||||
assert_eq!(app.view_back_stack[0].view_name, "Default");
|
||||
assert!(app.view_forward_stack.is_empty());
|
||||
}
|
||||
|
||||
|
||||
@ -429,7 +429,7 @@ impl<'a> GridWidget<'a> {
|
||||
}
|
||||
|
||||
// Edit indicator
|
||||
if matches!(self.mode, AppMode::Editing { .. }) && ri == sel_row {
|
||||
if self.mode.is_editing() && ri == sel_row {
|
||||
{
|
||||
let buffer = self.buffers.get("edit").map(|s| s.as_str()).unwrap_or("");
|
||||
let edit_x = col_x_at(sel_col);
|
||||
|
||||
Reference in New Issue
Block a user