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
+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) {