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