refactor(ui): move UI session fields into ViewState (improvise-ew0)

Step 3 of vb4. Populates ViewState with the 20 UI session fields (mode,
status_msg, wizard, search_query, search_mode, three panel-open flags,
three panel cursors, formula_cursor, yanked, tile_cat_idx, two view nav
stacks, drill_state, help_page, expanded_cats, buffers, transient_keymap)
and routes every read/write site through app.view_state.X. App now
contains only model_state, view_state, and the runtime/derived residue
(term dims, layout, last_autosave, abort_effects, keymap_set).

ViewState gets a manual Default impl mirroring the previous App::new
field initialisers; AppMode has no Default of its own so AppMode::Normal
is the explicit baseline. Effect::apply still takes &mut App; narrowing
remains step 5 (improvise-drg).

A structural test (app_view_state_owns_ui_session_fields) locks in the
20-field layout. 624 tests pass workspace-wide (+1 new). cargo clippy
--workspace --tests clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-04-28 23:19:31 -07:00
parent 917b928759
commit 9ad8abd8a5
4 changed files with 320 additions and 282 deletions
+1 -1
View File
@@ -276,7 +276,7 @@ pub struct SetTransientKeymap(pub Arc<Keymap>);
impl Effect for SetTransientKeymap {
fn apply(&self, app: &mut crate::ui::app::App) {
app.transient_keymap = Some(self.0.clone());
app.view_state.transient_keymap = Some(self.0.clone());
}
}
+35 -34
View File
@@ -61,7 +61,7 @@ pub fn run_tui(
if let Some(json) = import_value {
app.start_import_wizard(json);
} else if app.is_empty_model() {
app.mode = AppMode::Help;
app.view_state.mode = AppMode::Help;
}
loop {
@@ -82,7 +82,7 @@ pub fn run_tui(
app.autosave_if_needed();
if matches!(app.mode, AppMode::Quit) {
if matches!(app.view_state.mode, AppMode::Quit) {
break;
}
}
@@ -167,21 +167,21 @@ fn draw(f: &mut Frame, app: &App) {
draw_bottom_bar(f, main_chunks[3], app);
// Overlays (rendered last so they appear on top)
if matches!(app.mode, AppMode::Help) {
f.render_widget(HelpWidget::new(app.help_page), size);
if matches!(app.view_state.mode, AppMode::Help) {
f.render_widget(HelpWidget::new(app.view_state.help_page), size);
}
if matches!(app.mode, AppMode::ImportWizard)
&& let Some(wizard) = &app.wizard
if matches!(app.view_state.mode, AppMode::ImportWizard)
&& let Some(wizard) = &app.view_state.wizard
{
f.render_widget(ImportWizardWidget::new(wizard), size);
}
// ExportPrompt now uses the minibuffer at the bottom bar.
if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
if app.is_empty_model() && matches!(app.view_state.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
draw_welcome(f, main_chunks[1]);
}
// Which-key popup: show available completions after a prefix key
if let Some(ref km) = app.transient_keymap {
if let Some(ref km) = app.view_state.transient_keymap {
let hints = km.binding_hints();
f.render_widget(WhichKeyWidget::new(&hints), size);
}
@@ -215,7 +215,7 @@ fn draw_title(f: &mut Frame, area: Rect, app: &App) {
}
fn draw_content(f: &mut Frame, area: Rect, app: &App) {
let side_open = app.formula_panel_open || app.category_panel_open || app.view_panel_open;
let side_open = app.view_state.formula_panel_open || app.view_state.category_panel_open || app.view_state.view_panel_open;
let grid_area;
if side_open {
@@ -229,9 +229,9 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
let side = chunks[1];
let panel_count = [
app.formula_panel_open,
app.category_panel_open,
app.view_panel_open,
app.view_state.formula_panel_open,
app.view_state.category_panel_open,
app.view_state.view_panel_open,
]
.iter()
.filter(|&&b| b)
@@ -239,26 +239,26 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
let ph = side.height / panel_count.max(1);
let mut y = side.y;
if app.formula_panel_open {
if app.view_state.formula_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
let content = FormulaContent::new(&app.model_state.workbook.model, &app.mode);
f.render_widget(Panel::new(content, &app.mode, app.formula_cursor), a);
let content = FormulaContent::new(&app.model_state.workbook.model, &app.view_state.mode);
f.render_widget(Panel::new(content, &app.view_state.mode, app.view_state.formula_cursor), a);
y += ph;
}
if app.category_panel_open {
if app.view_state.category_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
let content = CategoryContent::new(
&app.model_state.workbook.model,
app.model_state.workbook.active_view(),
&app.expanded_cats,
&app.view_state.expanded_cats,
);
f.render_widget(Panel::new(content, &app.mode, app.cat_panel_cursor), a);
f.render_widget(Panel::new(content, &app.view_state.mode, app.view_state.cat_panel_cursor), a);
y += ph;
}
if app.view_panel_open {
if app.view_state.view_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
let content = ViewContent::new(&app.model_state.workbook);
f.render_widget(Panel::new(content, &app.mode, app.view_panel_cursor), a);
f.render_widget(Panel::new(content, &app.view_state.mode, app.view_state.view_panel_cursor), a);
}
} else {
grid_area = area;
@@ -270,10 +270,10 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
app.model_state.workbook.active_view(),
&app.model_state.workbook.active_view,
&app.layout,
&app.mode,
&app.search_query,
&app.buffers,
app.drill_state.as_ref(),
&app.view_state.mode,
&app.view_state.search_query,
&app.view_state.buffers,
app.view_state.drill_state.as_ref(),
),
grid_area,
);
@@ -284,16 +284,17 @@ fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
TileBar::new(
&app.model_state.workbook.model,
app.model_state.workbook.active_view(),
&app.mode,
app.tile_cat_idx,
&app.view_state.mode,
app.view_state.tile_cat_idx,
),
area,
);
}
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
if let Some(mb) = app.mode.minibuffer() {
if let Some(mb) = app.view_state.mode.minibuffer() {
let buf = app
.view_state
.buffers
.get(mb.buffer_key)
.map(|s| s.as_str())
@@ -314,25 +315,25 @@ fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
}
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let search_part = if app.search_mode {
format!(" /{}", app.search_query)
let search_part = if app.view_state.search_mode {
format!(" /{}", app.view_state.search_query)
} else {
String::new()
};
let msg = if !app.status_msg.is_empty() {
app.status_msg.as_str()
let msg = if !app.view_state.status_msg.is_empty() {
app.view_state.status_msg.as_str()
} else {
app.hint_text()
};
let yank_indicator = if app.yanked.is_some() { " [yank]" } else { "" };
let yank_indicator = if app.view_state.yanked.is_some() { " [yank]" } else { "" };
let view_badge = format!(" {}{} ", app.model_state.workbook.active_view, yank_indicator);
let left = format!(" {}{search_part} {msg}", mode_name(&app.mode));
let left = format!(" {}{search_part} {msg}", mode_name(&app.view_state.mode));
let line = fill_line(left, &view_badge, area.width);
f.render_widget(Paragraph::new(line).style(mode_style(&app.mode)), area);
f.render_widget(Paragraph::new(line).style(mode_style(&app.view_state.mode)), area);
}
fn draw_welcome(f: &mut Frame, area: Rect) {
+149 -112
View File
@@ -206,17 +206,12 @@ impl Default for ModelState {
}
/// UI session-state slice: mode, cursors, panels, buffers, navigation stacks,
/// and other per-session state that does not persist to disk. Filled in by
/// improvise-ew0 (vb4 step 3).
#[derive(Debug, Default)]
pub struct ViewState {}
pub struct App {
pub model_state: ModelState,
/// and other per-session state that does not persist to disk.
#[derive(Debug)]
pub struct ViewState {
pub mode: AppMode,
pub status_msg: String,
pub wizard: Option<ImportWizard>,
pub last_autosave: Instant,
pub search_query: String,
pub search_mode: bool,
pub formula_panel_open: bool,
@@ -241,15 +236,48 @@ pub struct App {
pub drill_state: Option<DrillState>,
/// Current page index in the Help screen (0-based).
pub help_page: usize,
/// Terminal dimensions (updated on resize and at startup).
pub term_width: u16,
pub term_height: u16,
/// Categories expanded in the category panel tree view.
pub expanded_cats: std::collections::HashSet<String>,
/// Named text buffers for text-entry modes
pub buffers: HashMap<String, String>,
/// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.)
pub transient_keymap: Option<Arc<Keymap>>,
}
impl Default for ViewState {
fn default() -> Self {
Self {
mode: AppMode::Normal,
status_msg: String::new(),
wizard: None,
search_query: String::new(),
search_mode: false,
formula_panel_open: false,
category_panel_open: false,
view_panel_open: false,
cat_panel_cursor: 0,
view_panel_cursor: 0,
formula_cursor: 0,
yanked: None,
tile_cat_idx: 0,
view_back_stack: Vec::new(),
view_forward_stack: Vec::new(),
drill_state: None,
help_page: 0,
expanded_cats: std::collections::HashSet::new(),
buffers: HashMap::new(),
transient_keymap: None,
}
}
}
pub struct App {
pub model_state: ModelState,
pub view_state: ViewState,
pub last_autosave: Instant,
/// Terminal dimensions (updated on resize and at startup).
pub term_width: u16,
pub term_height: u16,
/// Current grid layout, derived from model + view + drill_state.
/// Rebuilt via `rebuild_layout()` after state changes.
pub layout: GridLayout,
@@ -280,29 +308,10 @@ impl App {
file_path,
dirty: false,
},
mode: AppMode::Normal,
status_msg: String::new(),
wizard: None,
view_state: ViewState::default(),
last_autosave: Instant::now(),
search_query: String::new(),
search_mode: false,
formula_panel_open: false,
category_panel_open: false,
view_panel_open: false,
cat_panel_cursor: 0,
view_panel_cursor: 0,
formula_cursor: 0,
yanked: None,
tile_cat_idx: 0,
view_back_stack: Vec::new(),
view_forward_stack: Vec::new(),
drill_state: None,
help_page: 0,
term_width: crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80),
term_height: crossterm::terminal::size().map(|(_, h)| h).unwrap_or(24),
expanded_cats: std::collections::HashSet::new(),
buffers: HashMap::new(),
transient_keymap: None,
layout,
abort_effects: false,
keymap_set: KeymapSet::default_keymaps(),
@@ -315,7 +324,7 @@ impl App {
let none_cats = self.model_state.workbook.active_view().none_cats();
self.model_state.workbook.model.recompute_formulas(&none_cats);
let view = self.model_state.workbook.active_view();
let frozen = self.drill_state.as_ref().map(|s| Rc::clone(&s.records));
let frozen = self.view_state.drill_state.as_ref().map(|s| Rc::clone(&s.records));
self.layout = GridLayout::with_frozen_records(&self.model_state.workbook.model, view, frozen);
}
@@ -329,30 +338,30 @@ impl App {
view,
layout,
registry: self.keymap_set.registry(),
mode: &self.mode,
mode: &self.view_state.mode,
selected: view.selected,
row_offset: view.row_offset,
col_offset: view.col_offset,
search_query: &self.search_query,
yanked: &self.yanked,
search_query: &self.view_state.search_query,
yanked: &self.view_state.yanked,
dirty: self.model_state.dirty,
search_mode: self.search_mode,
formula_panel_open: self.formula_panel_open,
category_panel_open: self.category_panel_open,
view_panel_open: self.view_panel_open,
buffers: &self.buffers,
formula_cursor: self.formula_cursor,
cat_panel_cursor: self.cat_panel_cursor,
view_panel_cursor: self.view_panel_cursor,
tile_cat_idx: self.tile_cat_idx,
view_back_stack: &self.view_back_stack,
view_forward_stack: &self.view_forward_stack,
has_drill_state: self.drill_state.is_some(),
search_mode: self.view_state.search_mode,
formula_panel_open: self.view_state.formula_panel_open,
category_panel_open: self.view_state.category_panel_open,
view_panel_open: self.view_state.view_panel_open,
buffers: &self.view_state.buffers,
formula_cursor: self.view_state.formula_cursor,
cat_panel_cursor: self.view_state.cat_panel_cursor,
view_panel_cursor: self.view_state.view_panel_cursor,
tile_cat_idx: self.view_state.tile_cat_idx,
view_back_stack: &self.view_state.view_back_stack,
view_forward_stack: &self.view_state.view_forward_stack,
has_drill_state: self.view_state.drill_state.is_some(),
display_value: {
let key = layout.cell_key(sel_row, sel_col);
if let Some(k) = &key {
if let Some((idx, dim)) = crate::view::synthetic_record_info(k) {
self.drill_state
self.view_state.drill_state
.as_ref()
.and_then(|s| s.pending_edits.get(&(idx, dim)).cloned())
.or_else(|| layout.resolve_display(k))
@@ -381,7 +390,7 @@ impl App {
view.col_offset,
)
},
expanded_cats: &self.expanded_cats,
expanded_cats: &self.view_state.expanded_cats,
key_code: key,
}
}
@@ -419,7 +428,7 @@ impl App {
self.rebuild_layout();
// Transient keymap (prefix key sequence) takes priority
if let Some(transient) = self.transient_keymap.take() {
if let Some(transient) = self.view_state.transient_keymap.take() {
let effects = {
let ctx = self.cmd_context(key.code, key.modifiers);
self.keymap_set
@@ -455,13 +464,13 @@ impl App {
}
pub fn start_import_wizard(&mut self, json: serde_json::Value) {
self.wizard = Some(ImportWizard::new(json));
self.mode = AppMode::ImportWizard;
self.view_state.wizard = Some(ImportWizard::new(json));
self.view_state.mode = AppMode::ImportWizard;
}
/// Hint text for the status bar (context-sensitive)
pub fn hint_text(&self) -> &'static str {
match &self.mode {
match &self.view_state.mode {
AppMode::Normal => {
"hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd"
}
@@ -517,6 +526,34 @@ mod tests {
let _: bool = app.model_state.dirty;
}
/// improvise-ew0: ViewState owns the UI session slice — mode, status,
/// search, panels, navigation, drill, yanked, buffers, etc. App
/// accesses them through view_state.
#[test]
fn app_view_state_owns_ui_session_fields() {
let app = App::new(Workbook::new("T"), None);
let _: &AppMode = &app.view_state.mode;
let _: &str = &app.view_state.status_msg;
let _: &Option<ImportWizard> = &app.view_state.wizard;
let _: &str = &app.view_state.search_query;
let _: bool = app.view_state.search_mode;
let _: bool = app.view_state.formula_panel_open;
let _: bool = app.view_state.category_panel_open;
let _: bool = app.view_state.view_panel_open;
let _: usize = app.view_state.cat_panel_cursor;
let _: usize = app.view_state.view_panel_cursor;
let _: usize = app.view_state.formula_cursor;
let _: &Option<CellValue> = &app.view_state.yanked;
let _: usize = app.view_state.tile_cat_idx;
let _: &Vec<ViewFrame> = &app.view_state.view_back_stack;
let _: &Vec<ViewFrame> = &app.view_state.view_forward_stack;
let _: &Option<DrillState> = &app.view_state.drill_state;
let _: usize = app.view_state.help_page;
let _: &std::collections::HashSet<String> = &app.view_state.expanded_cats;
let _: &HashMap<String, String> = &app.view_state.buffers;
let _: &Option<Arc<Keymap>> = &app.view_state.transient_keymap;
}
fn two_col_model() -> App {
let mut wb = Workbook::new("T");
wb.add_category("Row").unwrap(); // → Row axis
@@ -588,7 +625,7 @@ mod tests {
let json: serde_json::Value = serde_json::json!([{"cat": "A", "val": 1}]);
app.start_import_wizard(json);
assert!(
matches!(app.mode, AppMode::ImportWizard),
matches!(app.view_state.mode, AppMode::ImportWizard),
"mode should be ImportWizard after start_import_wizard"
);
}
@@ -600,7 +637,7 @@ mod tests {
app.start_import_wizard(serde_json::json!([{"x": 1}]));
// After the command the mode must NOT be reset to Normal
assert!(
!matches!(app.mode, AppMode::Normal),
!matches!(app.view_state.mode, AppMode::Normal),
"mode must not be Normal after import wizard is opened"
);
}
@@ -612,13 +649,13 @@ mod tests {
// Enter command mode with ':'
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
.unwrap();
assert!(matches!(app.mode, AppMode::CommandMode { .. }));
assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some(""));
assert!(matches!(app.view_state.mode, AppMode::CommandMode { .. }));
assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some(""));
// Type 'q'
app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
.unwrap();
assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some("q"));
assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some("q"));
}
#[test]
@@ -789,7 +826,7 @@ mod tests {
// Enter edit mode
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
assert!(matches!(app.mode, AppMode::Editing { .. }));
assert!(matches!(app.view_state.mode, AppMode::Editing { .. }));
// Type a digit
app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE))
.unwrap();
@@ -798,9 +835,9 @@ mod tests {
.unwrap();
// Should be in edit mode on column 1
assert!(
matches!(app.mode, AppMode::Editing { .. }),
matches!(app.view_state.mode, AppMode::Editing { .. }),
"should be in edit mode after Tab, but mode is {:?}",
app.mode
app.view_state.mode
);
assert_eq!(
app.model_state.workbook.active_view().selected.1,
@@ -869,9 +906,9 @@ mod tests {
"o should create the first record row in an empty records view"
);
assert!(
app.mode.is_editing(),
app.view_state.mode.is_editing(),
"o should leave the app in edit mode, got {:?}",
app.mode
app.view_state.mode
);
}
@@ -1021,19 +1058,19 @@ mod tests {
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
assert!(app.mode.is_editing(), "setup: should be editing");
assert!(app.view_state.mode.is_editing(), "setup: should be editing");
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert!(
!app.mode.is_editing(),
!app.view_state.mode.is_editing(),
"Enter at bottom-right should exit editing, got {:?}",
app.mode
app.view_state.mode
);
assert!(
matches!(app.mode, AppMode::RecordsNormal),
matches!(app.view_state.mode, AppMode::RecordsNormal),
"should return to RecordsNormal, got {:?}",
app.mode
app.view_state.mode
);
}
@@ -1053,7 +1090,7 @@ mod tests {
// 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");
assert!(app.view_state.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))
@@ -1070,9 +1107,9 @@ mod tests {
"TAB should move to first cell of the new row"
);
assert!(
app.mode.is_editing(),
app.view_state.mode.is_editing(),
"should enter edit mode on the new cell, got {:?}",
app.mode
app.view_state.mode
);
}
@@ -1097,7 +1134,7 @@ mod tests {
app.handle_key(KeyEvent::new(KeyCode::Char('>'), KeyModifiers::NONE))
.unwrap();
assert!(app.drill_state.is_some(), "drill should create drill state");
assert!(app.view_state.drill_state.is_some(), "drill should create drill state");
let value_col = (0..app.layout.col_count())
.find(|&col| app.layout.col_label(col) == "Value")
.expect("drill view should include a Value column");
@@ -1117,7 +1154,7 @@ mod tests {
"drill edit should remain staged until leaving the drill view"
);
assert_eq!(
app.drill_state
app.view_state.drill_state
.as_ref()
.and_then(|s| s.pending_edits.get(&(0, "Value".to_string()))),
Some(&"9".to_string()),
@@ -1182,14 +1219,14 @@ mod tests {
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
.unwrap();
assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some("x"));
assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some("x"));
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.unwrap();
// Re-enter command mode — buffer should be cleared
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
.unwrap();
assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some(""));
assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some(""));
}
// ── is_empty_model ──────────────────────────────────────────────────
@@ -1219,34 +1256,34 @@ mod tests {
#[test]
fn help_page_next_advances_page() {
let mut app = App::new(Workbook::new("T"), None);
app.mode = AppMode::Help;
app.help_page = 0;
app.view_state.mode = AppMode::Help;
app.view_state.help_page = 0;
app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
.unwrap();
assert_eq!(app.help_page, 1, "l should advance to page 1");
assert_eq!(app.view_state.help_page, 1, "l should advance to page 1");
}
#[test]
fn help_page_prev_goes_back() {
let mut app = App::new(Workbook::new("T"), None);
app.mode = AppMode::Help;
app.help_page = 2;
app.view_state.mode = AppMode::Help;
app.view_state.help_page = 2;
app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))
.unwrap();
assert_eq!(app.help_page, 1, "h should go back to page 1");
assert_eq!(app.view_state.help_page, 1, "h should go back to page 1");
}
#[test]
fn help_page_clamps_at_zero() {
let mut app = App::new(Workbook::new("T"), None);
app.mode = AppMode::Help;
app.help_page = 0;
app.view_state.mode = AppMode::Help;
app.view_state.help_page = 0;
app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))
.unwrap();
assert_eq!(app.help_page, 0, "page should not go below 0");
assert_eq!(app.view_state.help_page, 0, "page should not go below 0");
}
#[test]
@@ -1254,13 +1291,13 @@ mod tests {
use crate::ui::help::HELP_PAGE_COUNT;
let mut app = App::new(Workbook::new("T"), None);
app.mode = AppMode::Help;
app.help_page = HELP_PAGE_COUNT - 1;
app.view_state.mode = AppMode::Help;
app.view_state.help_page = HELP_PAGE_COUNT - 1;
app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.help_page,
app.view_state.help_page,
HELP_PAGE_COUNT - 1,
"page should not exceed the last page"
);
@@ -1271,12 +1308,12 @@ mod tests {
#[test]
fn help_q_returns_to_normal() {
let mut app = App::new(Workbook::new("T"), None);
app.mode = AppMode::Help;
app.view_state.mode = AppMode::Help;
app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
.unwrap();
assert!(
matches!(app.mode, AppMode::Normal),
matches!(app.view_state.mode, AppMode::Normal),
"q should return to Normal mode"
);
}
@@ -1284,12 +1321,12 @@ mod tests {
#[test]
fn help_esc_returns_to_normal() {
let mut app = App::new(Workbook::new("T"), None);
app.mode = AppMode::Help;
app.view_state.mode = AppMode::Help;
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.unwrap();
assert!(
matches!(app.mode, AppMode::Normal),
matches!(app.view_state.mode, AppMode::Normal),
"Esc should return to Normal mode"
);
}
@@ -1297,14 +1334,14 @@ mod tests {
#[test]
fn help_colon_enters_command_mode() {
let mut app = App::new(Workbook::new("T"), None);
app.mode = AppMode::Help;
app.view_state.mode = AppMode::Help;
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
.unwrap();
assert!(
matches!(app.mode, AppMode::CommandMode { .. }),
matches!(app.view_state.mode, AppMode::CommandMode { .. }),
"colon in Help mode should enter CommandMode, got {:?}",
app.mode
app.view_state.mode
);
}
@@ -1320,9 +1357,9 @@ mod tests {
};
effect.apply(&mut app);
assert!(
app.status_msg.contains("Unknown category"),
app.view_state.status_msg.contains("Unknown category"),
"should report unknown category, got: {:?}",
app.status_msg
app.view_state.status_msg
);
}
@@ -1336,9 +1373,9 @@ mod tests {
};
effect.apply(&mut app);
assert!(
app.status_msg.contains("Formula error"),
app.view_state.status_msg.contains("Formula error"),
"should report formula error, got: {:?}",
app.status_msg
app.view_state.status_msg
);
}
@@ -1347,19 +1384,19 @@ mod tests {
#[test]
fn tile_axis_change_stays_in_tile_select() {
let mut app = two_col_model();
app.mode = AppMode::TileSelect;
app.tile_cat_idx = 0;
app.view_state.mode = AppMode::TileSelect;
app.view_state.tile_cat_idx = 0;
// Press 'r' to set axis to Row — should stay in TileSelect
app.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE))
.unwrap();
assert!(
matches!(app.mode, AppMode::TileSelect),
matches!(app.view_state.mode, AppMode::TileSelect),
"should stay in TileSelect after axis change, got {:?}",
app.mode
app.view_state.mode
);
assert!(
!app.status_msg.is_empty(),
!app.view_state.status_msg.is_empty(),
"should show status feedback after axis change"
);
}
@@ -1369,42 +1406,42 @@ mod tests {
#[test]
fn category_panel_colon_enters_command_mode() {
let mut app = two_col_model();
app.mode = AppMode::CategoryPanel;
app.view_state.mode = AppMode::CategoryPanel;
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
.unwrap();
assert!(
matches!(app.mode, AppMode::CommandMode { .. }),
matches!(app.view_state.mode, AppMode::CommandMode { .. }),
"colon in CategoryPanel should enter CommandMode, got {:?}",
app.mode
app.view_state.mode
);
}
#[test]
fn view_panel_colon_enters_command_mode() {
let mut app = two_col_model();
app.mode = AppMode::ViewPanel;
app.view_state.mode = AppMode::ViewPanel;
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
.unwrap();
assert!(
matches!(app.mode, AppMode::CommandMode { .. }),
matches!(app.view_state.mode, AppMode::CommandMode { .. }),
"colon in ViewPanel should enter CommandMode, got {:?}",
app.mode
app.view_state.mode
);
}
#[test]
fn tile_select_colon_enters_command_mode() {
let mut app = two_col_model();
app.mode = AppMode::TileSelect;
app.view_state.mode = AppMode::TileSelect;
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
.unwrap();
assert!(
matches!(app.mode, AppMode::CommandMode { .. }),
matches!(app.view_state.mode, AppMode::CommandMode { .. }),
"colon in TileSelect should enter CommandMode, got {:?}",
app.mode
app.view_state.mode
);
}
}
+135 -135
View File
@@ -38,7 +38,7 @@ impl Effect for AddItem {
if let Some(cat) = app.model_state.workbook.model.category_mut(&self.category) {
cat.add_item(&self.item);
} else {
app.status_msg = format!("Unknown category '{}'", self.category);
app.view_state.status_msg = format!("Unknown category '{}'", self.category);
}
}
}
@@ -54,7 +54,7 @@ impl Effect for AddItemInGroup {
if let Some(cat) = app.model_state.workbook.model.category_mut(&self.category) {
cat.add_item_in_group(&self.item, &self.group);
} else {
app.status_msg = format!("Unknown category '{}'", self.category);
app.view_state.status_msg = format!("Unknown category '{}'", self.category);
}
}
}
@@ -103,7 +103,7 @@ impl Effect for AddFormula {
app.model_state.workbook.model.add_formula(formula);
}
Err(e) => {
app.status_msg = format!("Formula error: {e}");
app.view_state.status_msg = format!("Formula error: {e}");
}
}
}
@@ -127,7 +127,7 @@ impl Effect for RemoveFormula {
///
/// `target_mode` is supplied by the caller (keymap binding via
/// `EnterEditAtCursorCmd`, or `CommitAndAdvance` from its own `edit_mode`
/// field). The effect itself never inspects `app.mode` — the mode is decided
/// field). The effect itself never inspects `app.view_state.mode` — the mode is decided
/// statically by whoever invoked us.
#[derive(Debug)]
pub struct EnterEditAtCursor {
@@ -144,8 +144,8 @@ impl Effect for EnterEditAtCursor {
);
let value = ctx.display_value.clone();
drop(ctx);
app.buffers.insert("edit".to_string(), value);
app.mode = self.target_mode.clone();
app.view_state.buffers.insert("edit".to_string(), value);
app.view_state.mode = self.target_mode.clone();
}
}
@@ -162,8 +162,8 @@ impl Effect for TogglePruneEmpty {
pub struct ToggleCatExpand(pub String);
impl Effect for ToggleCatExpand {
fn apply(&self, app: &mut App) {
if !app.expanded_cats.remove(&self.0) {
app.expanded_cats.insert(self.0.clone());
if !app.view_state.expanded_cats.remove(&self.0) {
app.view_state.expanded_cats.insert(self.0.clone());
}
}
}
@@ -211,11 +211,11 @@ impl Effect for SwitchView {
fn apply(&self, app: &mut App) {
let current = app.model_state.workbook.active_view.clone();
if current != self.0 {
app.view_back_stack.push(ViewFrame {
app.view_state.view_back_stack.push(ViewFrame {
view_name: current,
mode: app.mode.clone(),
mode: app.view_state.mode.clone(),
});
app.view_forward_stack.clear();
app.view_state.view_forward_stack.clear();
}
let _ = app.model_state.workbook.switch_view(&self.0);
}
@@ -226,14 +226,14 @@ impl Effect for SwitchView {
pub struct ViewBack;
impl Effect for ViewBack {
fn apply(&self, app: &mut App) {
if let Some(frame) = app.view_back_stack.pop() {
if let Some(frame) = app.view_state.view_back_stack.pop() {
let current = app.model_state.workbook.active_view.clone();
app.view_forward_stack.push(ViewFrame {
app.view_state.view_forward_stack.push(ViewFrame {
view_name: current,
mode: app.mode.clone(),
mode: app.view_state.mode.clone(),
});
let _ = app.model_state.workbook.switch_view(&frame.view_name);
app.mode = frame.mode;
app.view_state.mode = frame.mode;
}
}
}
@@ -243,14 +243,14 @@ impl Effect for ViewBack {
pub struct ViewForward;
impl Effect for ViewForward {
fn apply(&self, app: &mut App) {
if let Some(frame) = app.view_forward_stack.pop() {
if let Some(frame) = app.view_state.view_forward_stack.pop() {
let current = app.model_state.workbook.active_view.clone();
app.view_back_stack.push(ViewFrame {
app.view_state.view_back_stack.push(ViewFrame {
view_name: current,
mode: app.mode.clone(),
mode: app.view_state.mode.clone(),
});
let _ = app.model_state.workbook.switch_view(&frame.view_name);
app.mode = frame.mode;
app.view_state.mode = frame.mode;
}
}
}
@@ -376,7 +376,7 @@ impl Effect for SetColOffset {
pub struct ChangeMode(pub AppMode);
impl Effect for ChangeMode {
fn apply(&self, app: &mut App) {
app.mode = self.0.clone();
app.view_state.mode = self.0.clone();
}
fn changes_mode(&self) -> bool {
true
@@ -387,7 +387,7 @@ impl Effect for ChangeMode {
pub struct SetStatus(pub String);
impl Effect for SetStatus {
fn apply(&self, app: &mut App) {
app.status_msg = self.0.clone();
app.view_state.status_msg = self.0.clone();
}
}
@@ -403,7 +403,7 @@ impl Effect for MarkDirty {
pub struct SetYanked(pub Option<CellValue>);
impl Effect for SetYanked {
fn apply(&self, app: &mut App) {
app.yanked = self.0.clone();
app.view_state.yanked = self.0.clone();
}
}
@@ -411,7 +411,7 @@ impl Effect for SetYanked {
pub struct SetSearchQuery(pub String);
impl Effect for SetSearchQuery {
fn apply(&self, app: &mut App) {
app.search_query = self.0.clone();
app.view_state.search_query = self.0.clone();
}
}
@@ -419,7 +419,7 @@ impl Effect for SetSearchQuery {
pub struct SetSearchMode(pub bool);
impl Effect for SetSearchMode {
fn apply(&self, app: &mut App) {
app.search_mode = self.0;
app.view_state.search_mode = self.0;
}
}
@@ -433,9 +433,9 @@ impl Effect for SetBuffer {
fn apply(&self, app: &mut App) {
// "search" is special — it writes to search_query for backward compat
if self.name == "search" {
app.search_query = self.value.clone();
app.view_state.search_query = self.value.clone();
} else {
app.buffers.insert(self.name.clone(), self.value.clone());
app.view_state.buffers.insert(self.name.clone(), self.value.clone());
}
}
}
@@ -444,7 +444,7 @@ impl Effect for SetBuffer {
pub struct SetTileCatIdx(pub usize);
impl Effect for SetTileCatIdx {
fn apply(&self, app: &mut App) {
app.tile_cat_idx = self.0;
app.view_state.tile_cat_idx = self.0;
}
}
@@ -454,7 +454,7 @@ impl Effect for SetTileCatIdx {
pub struct StartDrill(pub Vec<(CellKey, CellValue)>);
impl Effect for StartDrill {
fn apply(&self, app: &mut App) {
app.drill_state = Some(super::app::DrillState {
app.view_state.drill_state = Some(super::app::DrillState {
records: std::rc::Rc::new(self.0.clone()),
pending_edits: std::collections::HashMap::new(),
});
@@ -466,7 +466,7 @@ impl Effect for StartDrill {
pub struct ApplyAndClearDrill;
impl Effect for ApplyAndClearDrill {
fn apply(&self, app: &mut App) {
let Some(drill) = app.drill_state.take() else {
let Some(drill) = app.view_state.drill_state.take() else {
return;
};
if drill.pending_edits.is_empty() {
@@ -490,7 +490,7 @@ impl Effect for ApplyAndClearDrill {
app.model_state.workbook.model.set_cell(orig_key.clone(), value);
} else {
if new_value.is_empty() {
app.status_msg = RECORD_COORDS_CANNOT_BE_EMPTY.to_string();
app.view_state.status_msg = RECORD_COORDS_CANNOT_BE_EMPTY.to_string();
continue;
}
// Rename a coordinate: remove old cell, insert new with updated coord
@@ -532,7 +532,7 @@ pub struct SetDrillPendingEdit {
}
impl Effect for SetDrillPendingEdit {
fn apply(&self, app: &mut App) {
if let Some(drill) = &mut app.drill_state {
if let Some(drill) = &mut app.view_state.drill_state {
drill.pending_edits.insert(
(self.record_idx, self.col_name.clone()),
self.new_value.clone(),
@@ -551,14 +551,14 @@ impl Effect for Save {
match crate::persistence::save(&app.model_state.workbook, path) {
Ok(()) => {
app.model_state.dirty = false;
app.status_msg = format!("Saved to {}", path.display());
app.view_state.status_msg = format!("Saved to {}", path.display());
}
Err(e) => {
app.status_msg = format!("Save error: {e}");
app.view_state.status_msg = format!("Save error: {e}");
}
}
} else {
app.status_msg = "No file path — use :w <path>".to_string();
app.view_state.status_msg = "No file path — use :w <path>".to_string();
}
}
}
@@ -571,10 +571,10 @@ impl Effect for SaveAs {
Ok(()) => {
app.model_state.file_path = Some(self.0.clone());
app.model_state.dirty = false;
app.status_msg = format!("Saved to {}", self.0.display());
app.view_state.status_msg = format!("Saved to {}", self.0.display());
}
Err(e) => {
app.status_msg = format!("Save error: {e}");
app.view_state.status_msg = format!("Save error: {e}");
}
}
}
@@ -591,7 +591,7 @@ impl Effect for WizardKey {
fn apply(&self, app: &mut App) {
use crate::import::wizard::WizardStep;
let Some(wizard) = &mut app.wizard else {
let Some(wizard) = &mut app.view_state.wizard else {
return;
};
@@ -601,8 +601,8 @@ impl Effect for WizardKey {
wizard.advance()
}
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
app.view_state.mode = AppMode::Normal;
app.view_state.wizard = None;
}
_ => {}
},
@@ -615,8 +615,8 @@ impl Effect for WizardKey {
}
crossterm::event::KeyCode::Enter => wizard.confirm_path(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
app.view_state.mode = AppMode::Normal;
app.view_state.wizard = None;
}
_ => {}
},
@@ -631,8 +631,8 @@ impl Effect for WizardKey {
crossterm::event::KeyCode::Char('c') => wizard.cycle_proposal_kind(),
crossterm::event::KeyCode::Enter => wizard.advance(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
app.view_state.mode = AppMode::Normal;
app.view_state.wizard = None;
}
_ => {}
},
@@ -646,8 +646,8 @@ impl Effect for WizardKey {
crossterm::event::KeyCode::Char(' ') => wizard.toggle_date_component(),
crossterm::event::KeyCode::Enter => wizard.advance(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
app.view_state.mode = AppMode::Normal;
app.view_state.wizard = None;
}
_ => {}
},
@@ -672,8 +672,8 @@ impl Effect for WizardKey {
}
crossterm::event::KeyCode::Enter => wizard.advance(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
app.view_state.mode = AppMode::Normal;
app.view_state.wizard = None;
}
_ => {}
}
@@ -686,27 +686,27 @@ impl Effect for WizardKey {
Ok(mut workbook) => {
workbook.normalize_view_state();
app.model_state.workbook = workbook;
app.formula_cursor = 0;
app.view_state.formula_cursor = 0;
app.model_state.dirty = true;
app.status_msg = "Import successful! Press :w <path> to save.".to_string();
app.mode = AppMode::Normal;
app.wizard = None;
app.view_state.status_msg = "Import successful! Press :w <path> to save.".to_string();
app.view_state.mode = AppMode::Normal;
app.view_state.wizard = None;
}
Err(e) => {
if let Some(w) = &mut app.wizard {
if let Some(w) = &mut app.view_state.wizard {
w.message = Some(format!("Error: {e}"));
}
}
},
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
app.view_state.mode = AppMode::Normal;
app.view_state.wizard = None;
}
_ => {}
},
WizardStep::Done => {
app.mode = AppMode::Normal;
app.wizard = None;
app.view_state.mode = AppMode::Normal;
app.view_state.wizard = None;
}
}
}
@@ -720,15 +720,15 @@ impl Effect for StartImportWizard {
match std::fs::read_to_string(&self.0) {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Ok(json) => {
app.wizard = Some(crate::import::wizard::ImportWizard::new(json));
app.mode = AppMode::ImportWizard;
app.view_state.wizard = Some(crate::import::wizard::ImportWizard::new(json));
app.view_state.mode = AppMode::ImportWizard;
}
Err(e) => {
app.status_msg = format!("JSON parse error: {e}");
app.view_state.status_msg = format!("JSON parse error: {e}");
}
},
Err(e) => {
app.status_msg = format!("Cannot read file: {e}");
app.view_state.status_msg = format!("Cannot read file: {e}");
}
}
}
@@ -741,10 +741,10 @@ impl Effect for ExportCsv {
let view_name = app.model_state.workbook.active_view.clone();
match crate::persistence::export_csv(&app.model_state.workbook, &view_name, &self.0) {
Ok(()) => {
app.status_msg = format!("Exported to {}", self.0.display());
app.view_state.status_msg = format!("Exported to {}", self.0.display());
}
Err(e) => {
app.status_msg = format!("Export error: {e}");
app.view_state.status_msg = format!("Export error: {e}");
}
}
}
@@ -759,10 +759,10 @@ impl Effect for LoadModel {
Ok(mut loaded) => {
loaded.normalize_view_state();
app.model_state.workbook = loaded;
app.status_msg = format!("Loaded from {}", self.0.display());
app.view_state.status_msg = format!("Loaded from {}", self.0.display());
}
Err(e) => {
app.status_msg = format!("Load error: {e}");
app.view_state.status_msg = format!("Load error: {e}");
}
}
}
@@ -791,7 +791,7 @@ impl Effect for ImportJsonHeadless {
match crate::import::csv_parser::parse_csv(&self.path) {
Ok(recs) => recs,
Err(e) => {
app.status_msg = format!("CSV error: {e}");
app.view_state.status_msg = format!("CSV error: {e}");
return;
}
}
@@ -799,14 +799,14 @@ impl Effect for ImportJsonHeadless {
let content = match std::fs::read_to_string(&self.path) {
Ok(c) => c,
Err(e) => {
app.status_msg = format!("Cannot read '{}': {e}", self.path.display());
app.view_state.status_msg = format!("Cannot read '{}': {e}", self.path.display());
return;
}
};
let value: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
app.status_msg = format!("JSON parse error: {e}");
app.view_state.status_msg = format!("JSON parse error: {e}");
return;
}
};
@@ -815,7 +815,7 @@ impl Effect for ImportJsonHeadless {
match extract_array_at_path(&value, ap) {
Some(arr) => arr.clone(),
None => {
app.status_msg = format!("No array at path '{ap}'");
app.view_state.status_msg = format!("No array at path '{ap}'");
return;
}
}
@@ -827,12 +827,12 @@ impl Effect for ImportJsonHeadless {
match extract_array_at_path(&value, first) {
Some(arr) => arr.clone(),
None => {
app.status_msg = "Could not extract records array".to_string();
app.view_state.status_msg = "Could not extract records array".to_string();
return;
}
}
} else {
app.status_msg = "No array found in JSON".to_string();
app.view_state.status_msg = "No array found in JSON".to_string();
return;
}
}
@@ -870,10 +870,10 @@ impl Effect for ImportJsonHeadless {
match pipeline.build_model() {
Ok(new_workbook) => {
app.model_state.workbook = new_workbook;
app.status_msg = "Imported successfully".to_string();
app.view_state.status_msg = "Imported successfully".to_string();
}
Err(e) => {
app.status_msg = format!("Import error: {e}");
app.view_state.status_msg = format!("Import error: {e}");
}
}
}
@@ -905,9 +905,9 @@ impl Panel {
impl Effect for SetPanelOpen {
fn apply(&self, app: &mut App) {
match self.panel {
Panel::Formula => app.formula_panel_open = self.open,
Panel::Category => app.category_panel_open = self.open,
Panel::View => app.view_panel_open = self.open,
Panel::Formula => app.view_state.formula_panel_open = self.open,
Panel::Category => app.view_state.category_panel_open = self.open,
Panel::View => app.view_state.view_panel_open = self.open,
}
}
}
@@ -920,9 +920,9 @@ pub struct SetPanelCursor {
impl Effect for SetPanelCursor {
fn apply(&self, app: &mut App) {
match self.panel {
Panel::Formula => app.formula_cursor = self.cursor,
Panel::Category => app.cat_panel_cursor = self.cursor,
Panel::View => app.view_panel_cursor = self.cursor,
Panel::Formula => app.view_state.formula_cursor = self.cursor,
Panel::Category => app.view_state.cat_panel_cursor = self.cursor,
Panel::View => app.view_state.view_panel_cursor = self.cursor,
}
}
}
@@ -992,7 +992,7 @@ pub struct HelpPageNext;
impl Effect for HelpPageNext {
fn apply(&self, app: &mut App) {
let max = crate::ui::help::HELP_PAGE_COUNT.saturating_sub(1);
app.help_page = app.help_page.saturating_add(1).min(max);
app.view_state.help_page = app.view_state.help_page.saturating_add(1).min(max);
}
}
@@ -1000,7 +1000,7 @@ impl Effect for HelpPageNext {
pub struct HelpPagePrev;
impl Effect for HelpPagePrev {
fn apply(&self, app: &mut App) {
app.help_page = app.help_page.saturating_sub(1);
app.view_state.help_page = app.view_state.help_page.saturating_sub(1);
}
}
@@ -1008,7 +1008,7 @@ impl Effect for HelpPagePrev {
pub struct HelpPageSet(pub usize);
impl Effect for HelpPageSet {
fn apply(&self, app: &mut App) {
app.help_page = self.0;
app.view_state.help_page = self.0;
}
}
@@ -1079,7 +1079,7 @@ mod tests {
item: "X".to_string(),
}
.apply(&mut app);
assert!(app.status_msg.contains("Unknown category"));
assert!(app.view_state.status_msg.contains("Unknown category"));
}
#[test]
@@ -1184,7 +1184,7 @@ mod tests {
target_category: "Type".to_string(),
}
.apply(&mut app);
assert!(app.status_msg.contains("Formula error"));
assert!(app.view_state.status_msg.contains("Formula error"));
}
#[test]
@@ -1210,21 +1210,21 @@ mod tests {
fn switch_view_pushes_to_back_stack() {
let mut app = test_app();
app.model_state.workbook.create_view("View 2");
assert!(app.view_back_stack.is_empty());
assert!(app.view_state.view_back_stack.is_empty());
SwitchView("View 2".to_string()).apply(&mut app);
assert_eq!(app.model_state.workbook.active_view.as_str(), "View 2");
assert_eq!(app.view_back_stack.len(), 1);
assert_eq!(app.view_back_stack[0].view_name, "Default");
assert_eq!(app.view_state.view_back_stack.len(), 1);
assert_eq!(app.view_state.view_back_stack[0].view_name, "Default");
// Forward stack should be cleared
assert!(app.view_forward_stack.is_empty());
assert!(app.view_state.view_forward_stack.is_empty());
}
#[test]
fn switch_view_to_same_does_not_push_stack() {
let mut app = test_app();
SwitchView("Default".to_string()).apply(&mut app);
assert!(app.view_back_stack.is_empty());
assert!(app.view_state.view_back_stack.is_empty());
}
#[test]
@@ -1237,16 +1237,16 @@ mod tests {
// Go back
ViewBack.apply(&mut app);
assert_eq!(app.model_state.workbook.active_view.as_str(), "Default");
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());
assert_eq!(app.view_state.view_forward_stack.len(), 1);
assert_eq!(app.view_state.view_forward_stack[0].view_name, "View 2");
assert!(app.view_state.view_back_stack.is_empty());
// Go forward
ViewForward.apply(&mut app);
assert_eq!(app.model_state.workbook.active_view.as_str(), "View 2");
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());
assert_eq!(app.view_state.view_back_stack.len(), 1);
assert_eq!(app.view_state.view_back_stack[0].view_name, "Default");
assert!(app.view_state.view_forward_stack.is_empty());
}
#[test]
@@ -1347,7 +1347,7 @@ mod tests {
let mut app = test_app();
assert!(ChangeMode(AppMode::Help).changes_mode());
ChangeMode(AppMode::Help).apply(&mut app);
assert_eq!(app.mode, AppMode::Help);
assert_eq!(app.view_state.mode, AppMode::Help);
}
/// `AbortChain` must cause subsequent effects in the same
@@ -1356,7 +1356,7 @@ mod tests {
#[test]
fn abort_chain_short_circuits_apply_effects() {
let mut app = test_app();
app.status_msg = String::new();
app.view_state.status_msg = String::new();
let effects: Vec<Box<dyn Effect>> = vec![
Box::new(SetStatus("before".into())),
Box::new(AbortChain),
@@ -1364,7 +1364,7 @@ mod tests {
];
app.apply_effects(effects);
assert_eq!(
app.status_msg, "before",
app.view_state.status_msg, "before",
"effects after AbortChain must not apply"
);
assert!(
@@ -1373,7 +1373,7 @@ mod tests {
);
// A subsequent batch must not be affected by the prior abort.
app.apply_effects(vec![Box::new(SetStatus("next-batch".into()))]);
assert_eq!(app.status_msg, "next-batch");
assert_eq!(app.view_state.status_msg, "next-batch");
}
/// `CleanEmptyRecords` removes cells whose `CellKey` has no
@@ -1414,37 +1414,37 @@ mod tests {
}
/// `EnterEditAtCursor` must use its `target_mode` field, *not* whatever
/// `app.mode` happens to be when applied. Previous implementation
/// branched on `app.mode.is_records()` — the parameterized version
/// `app.view_state.mode` happens to be when applied. Previous implementation
/// branched on `app.view_state.mode.is_records()` — the parameterized version
/// trusts the caller (keymap or composing command).
#[test]
fn enter_edit_at_cursor_uses_target_mode_not_app_mode() {
let mut app = test_app();
// App starts in Normal mode — but caller has decided we want
// RecordsEditing (e.g. records-mode `o` sequence).
assert_eq!(app.mode, AppMode::Normal);
assert_eq!(app.view_state.mode, AppMode::Normal);
EnterEditAtCursor {
target_mode: AppMode::records_editing(),
}
.apply(&mut app);
assert!(
matches!(app.mode, AppMode::RecordsEditing { .. }),
matches!(app.view_state.mode, AppMode::RecordsEditing { .. }),
"Expected RecordsEditing, got {:?}",
app.mode
app.view_state.mode
);
// Same effect with editing target — should land in plain Editing
// even if app.mode was something else.
// even if app.view_state.mode was something else.
let mut app2 = test_app();
app2.mode = AppMode::RecordsNormal;
app2.view_state.mode = AppMode::RecordsNormal;
EnterEditAtCursor {
target_mode: AppMode::editing(),
}
.apply(&mut app2);
assert!(
matches!(app2.mode, AppMode::Editing { .. }),
matches!(app2.view_state.mode, AppMode::Editing { .. }),
"Expected Editing, got {:?}",
app2.mode
app2.view_state.mode
);
}
@@ -1453,21 +1453,21 @@ mod tests {
#[test]
fn set_buffer_empty_clears() {
let mut app = test_app();
app.buffers
app.view_state.buffers
.insert("formula".to_string(), "old text".to_string());
SetBuffer {
name: "formula".to_string(),
value: String::new(),
}
.apply(&mut app);
assert_eq!(app.buffers.get("formula").map(|s| s.as_str()), Some(""),);
assert_eq!(app.view_state.buffers.get("formula").map(|s| s.as_str()), Some(""),);
}
#[test]
fn set_status_effect() {
let mut app = test_app();
SetStatus("hello".to_string()).apply(&mut app);
assert_eq!(app.status_msg, "hello");
assert_eq!(app.view_state.status_msg, "hello");
}
#[test]
@@ -1482,18 +1482,18 @@ mod tests {
fn set_yanked_effect() {
let mut app = test_app();
SetYanked(Some(CellValue::Number(42.0))).apply(&mut app);
assert_eq!(app.yanked, Some(CellValue::Number(42.0)));
assert_eq!(app.view_state.yanked, Some(CellValue::Number(42.0)));
}
#[test]
fn set_search_query_and_mode() {
let mut app = test_app();
SetSearchQuery("foo".to_string()).apply(&mut app);
assert_eq!(app.search_query, "foo");
assert_eq!(app.view_state.search_query, "foo");
SetSearchMode(true).apply(&mut app);
assert!(app.search_mode);
assert!(app.view_state.search_mode);
SetSearchMode(false).apply(&mut app);
assert!(!app.search_mode);
assert!(!app.view_state.search_mode);
}
// ── SetBuffer special behavior ──────────────────────────────────────
@@ -1506,7 +1506,7 @@ mod tests {
value: "hello".to_string(),
}
.apply(&mut app);
assert_eq!(app.buffers.get("edit").unwrap(), "hello");
assert_eq!(app.view_state.buffers.get("edit").unwrap(), "hello");
}
#[test]
@@ -1517,8 +1517,8 @@ mod tests {
value: "query".to_string(),
}
.apply(&mut app);
// "search" buffer is special — writes to app.search_query
assert_eq!(app.search_query, "query");
// "search" buffer is special — writes to app.view_state.search_query
assert_eq!(app.view_state.search_query, "query");
}
// ── Panel effects ───────────────────────────────────────────────────
@@ -1531,35 +1531,35 @@ mod tests {
open: true,
}
.apply(&mut app);
assert!(app.formula_panel_open);
assert!(app.view_state.formula_panel_open);
SetPanelCursor {
panel: Panel::Formula,
cursor: 3,
}
.apply(&mut app);
assert_eq!(app.formula_cursor, 3);
assert_eq!(app.view_state.formula_cursor, 3);
SetPanelOpen {
panel: Panel::Category,
open: true,
}
.apply(&mut app);
assert!(app.category_panel_open);
assert!(app.view_state.category_panel_open);
SetPanelOpen {
panel: Panel::View,
open: true,
}
.apply(&mut app);
assert!(app.view_panel_open);
assert!(app.view_state.view_panel_open);
}
#[test]
fn set_tile_cat_idx_effect() {
let mut app = test_app();
SetTileCatIdx(2).apply(&mut app);
assert_eq!(app.tile_cat_idx, 2);
assert_eq!(app.view_state.tile_cat_idx, 2);
}
// ── Help page effects ───────────────────────────────────────────────
@@ -1567,22 +1567,22 @@ mod tests {
#[test]
fn help_page_navigation() {
let mut app = test_app();
assert_eq!(app.help_page, 0);
assert_eq!(app.view_state.help_page, 0);
HelpPageNext.apply(&mut app);
assert_eq!(app.help_page, 1);
assert_eq!(app.view_state.help_page, 1);
HelpPageNext.apply(&mut app);
assert_eq!(app.help_page, 2);
assert_eq!(app.view_state.help_page, 2);
HelpPagePrev.apply(&mut app);
assert_eq!(app.help_page, 1);
assert_eq!(app.view_state.help_page, 1);
HelpPageSet(0).apply(&mut app);
assert_eq!(app.help_page, 0);
assert_eq!(app.view_state.help_page, 0);
}
#[test]
fn help_page_prev_clamps_at_zero() {
let mut app = test_app();
HelpPagePrev.apply(&mut app);
assert_eq!(app.help_page, 0);
assert_eq!(app.view_state.help_page, 0);
}
// ── Drill effects ───────────────────────────────────────────────────
@@ -1596,11 +1596,11 @@ mod tests {
]);
let records = vec![(key, CellValue::Number(42.0))];
StartDrill(records).apply(&mut app);
assert!(app.drill_state.is_some());
assert!(app.view_state.drill_state.is_some());
// Apply with no pending edits — should just clear state
ApplyAndClearDrill.apply(&mut app);
assert!(app.drill_state.is_none());
assert!(app.view_state.drill_state.is_none());
assert!(!app.model_state.dirty); // no edits → not dirty
}
@@ -1628,7 +1628,7 @@ mod tests {
.apply(&mut app);
ApplyAndClearDrill.apply(&mut app);
assert!(app.drill_state.is_none());
assert!(app.view_state.drill_state.is_none());
assert!(app.model_state.dirty);
assert_eq!(
app.model_state.workbook.model.get_cell(&key),
@@ -1726,11 +1726,11 @@ mod tests {
#[test]
fn toggle_cat_expand_effect() {
let mut app = test_app();
assert!(!app.expanded_cats.contains("Type"));
assert!(!app.view_state.expanded_cats.contains("Type"));
ToggleCatExpand("Type".to_string()).apply(&mut app);
assert!(app.expanded_cats.contains("Type"));
assert!(app.view_state.expanded_cats.contains("Type"));
ToggleCatExpand("Type".to_string()).apply(&mut app);
assert!(!app.expanded_cats.contains("Type"));
assert!(!app.view_state.expanded_cats.contains("Type"));
}
#[test]
@@ -1846,7 +1846,7 @@ mod tests {
fn save_without_file_path_shows_status() {
let mut app = test_app();
Save.apply(&mut app);
assert!(app.status_msg.contains("No file path"));
assert!(app.view_state.status_msg.contains("No file path"));
}
// ── Panel mode helper ───────────────────────────────────────────────