refactor(ui): move workbook/file_path/dirty into ModelState (improvise-x2c)

Step 2 of vb4. Populates ModelState with the document slice and routes
every read/write site (effects, draw, main, app methods, tests) through
app.model_state.X. App no longer owns workbook, file_path, or dirty
directly. Effect::apply signatures still take &mut App; narrowing happens
in step 5 (improvise-drg).

A structural test (app_model_state_owns_workbook_file_path_and_dirty)
locks in the field layout. ModelState now has a manual Default impl
that creates an "Untitled" Workbook so the existing constructibility
test keeps working.

623 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 22:06:33 -07:00
parent f11d79f700
commit 917b928759
4 changed files with 228 additions and 186 deletions
+13 -12
View File
@@ -188,8 +188,9 @@ fn draw(f: &mut Frame, app: &App) {
}
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
let dirty = if app.dirty { " [+]" } else { "" };
let dirty = if app.model_state.dirty { " [+]" } else { "" };
let file = app
.model_state
.file_path
.as_ref()
.and_then(|p| p.file_name())
@@ -198,7 +199,7 @@ fn draw_title(f: &mut Frame, area: Rect, app: &App) {
.unwrap_or_default();
let title = format!(
" improvise · {}{}{} ",
app.workbook.model.name, file, dirty
app.model_state.workbook.model.name, file, dirty
);
let right = " ?:help :q quit ";
let line = fill_line(title, right, area.width);
@@ -240,15 +241,15 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
if app.formula_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
let content = FormulaContent::new(&app.workbook.model, &app.mode);
let content = FormulaContent::new(&app.model_state.workbook.model, &app.mode);
f.render_widget(Panel::new(content, &app.mode, app.formula_cursor), a);
y += ph;
}
if app.category_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
let content = CategoryContent::new(
&app.workbook.model,
app.workbook.active_view(),
&app.model_state.workbook.model,
app.model_state.workbook.active_view(),
&app.expanded_cats,
);
f.render_widget(Panel::new(content, &app.mode, app.cat_panel_cursor), a);
@@ -256,7 +257,7 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
}
if app.view_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
let content = ViewContent::new(&app.workbook);
let content = ViewContent::new(&app.model_state.workbook);
f.render_widget(Panel::new(content, &app.mode, app.view_panel_cursor), a);
}
} else {
@@ -265,9 +266,9 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(
GridWidget::new(
&app.workbook.model,
app.workbook.active_view(),
&app.workbook.active_view,
&app.model_state.workbook.model,
app.model_state.workbook.active_view(),
&app.model_state.workbook.active_view,
&app.layout,
&app.mode,
&app.search_query,
@@ -281,8 +282,8 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(
TileBar::new(
&app.workbook.model,
app.workbook.active_view(),
&app.model_state.workbook.model,
app.model_state.workbook.active_view(),
&app.mode,
app.tile_cat_idx,
),
@@ -326,7 +327,7 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
};
let yank_indicator = if app.yanked.is_some() { " [yank]" } else { "" };
let view_badge = format!(" {}{} ", app.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 line = fill_line(left, &view_badge, area.width);