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
+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
);
}
}