From 9421d01da5ffb12f04bd3644a8fe7967149f8fca Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Fri, 3 Apr 2026 22:36:44 -0700 Subject: [PATCH] refactor: add Effect trait and apply_effects infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define Effect trait in ui/effect.rs with concrete effect structs for all model mutations, view changes, navigation, and app state updates. Each effect implements apply(&self, &mut App). Add App::apply_effects to apply a sequence of effects. No behavior change yet — existing key handlers still work as before. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/app.rs | 6 + src/ui/effect.rs | 398 +++++++++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 1 + 3 files changed, 405 insertions(+) create mode 100644 src/ui/effect.rs diff --git a/src/ui/app.rs b/src/ui/app.rs index e1d1300..2e0673c 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -91,6 +91,12 @@ impl App { } } + pub fn apply_effects(&mut self, effects: Vec>) { + for effect in effects { + effect.apply(self); + } + } + /// True when the model has no categories yet (show welcome screen) pub fn is_empty_model(&self) -> bool { self.model.categories.is_empty() diff --git a/src/ui/effect.rs b/src/ui/effect.rs new file mode 100644 index 0000000..9de1396 --- /dev/null +++ b/src/ui/effect.rs @@ -0,0 +1,398 @@ +use std::fmt::Debug; +use std::path::PathBuf; + +use crate::model::cell::{CellKey, CellValue}; +use crate::view::Axis; + +use super::app::{App, AppMode}; + +/// A discrete state change produced by a command. +/// Effects know how to apply themselves to the App. +pub trait Effect: Debug { + fn apply(&self, app: &mut App); +} + +// ── Model mutations ────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct AddCategory(pub String); +impl Effect for AddCategory { + fn apply(&self, app: &mut App) { + let _ = app.model.add_category(&self.0); + } +} + +#[derive(Debug)] +pub struct AddItem { + pub category: String, + pub item: String, +} +impl Effect for AddItem { + fn apply(&self, app: &mut App) { + if let Some(cat) = app.model.category_mut(&self.category) { + cat.add_item(&self.item); + } + } +} + +#[derive(Debug)] +pub struct AddItemInGroup { + pub category: String, + pub item: String, + pub group: String, +} +impl Effect for AddItemInGroup { + fn apply(&self, app: &mut App) { + if let Some(cat) = app.model.category_mut(&self.category) { + cat.add_item_in_group(&self.item, &self.group); + } + } +} + +#[derive(Debug)] +pub struct SetCell(pub CellKey, pub CellValue); +impl Effect for SetCell { + fn apply(&self, app: &mut App) { + app.model.set_cell(self.0.clone(), self.1.clone()); + } +} + +#[derive(Debug)] +pub struct ClearCell(pub CellKey); +impl Effect for ClearCell { + fn apply(&self, app: &mut App) { + app.model.clear_cell(&self.0); + } +} + +#[derive(Debug)] +pub struct AddFormula { + pub raw: String, + pub target_category: String, +} +impl Effect for AddFormula { + fn apply(&self, app: &mut App) { + if let Ok(formula) = crate::formula::parse_formula(&self.raw, &self.target_category) { + app.model.add_formula(formula); + } + } +} + +#[derive(Debug)] +pub struct RemoveFormula { + pub target: String, + pub target_category: String, +} +impl Effect for RemoveFormula { + fn apply(&self, app: &mut App) { + app.model.remove_formula(&self.target, &self.target_category); + } +} + +// ── View mutations ─────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct CreateView(pub String); +impl Effect for CreateView { + fn apply(&self, app: &mut App) { + app.model.create_view(&self.0); + } +} + +#[derive(Debug)] +pub struct DeleteView(pub String); +impl Effect for DeleteView { + fn apply(&self, app: &mut App) { + let _ = app.model.delete_view(&self.0); + } +} + +#[derive(Debug)] +pub struct SwitchView(pub String); +impl Effect for SwitchView { + fn apply(&self, app: &mut App) { + let _ = app.model.switch_view(&self.0); + } +} + +#[derive(Debug)] +pub struct SetAxis { + pub category: String, + pub axis: Axis, +} +impl Effect for SetAxis { + fn apply(&self, app: &mut App) { + app.model.active_view_mut().set_axis(&self.category, self.axis); + } +} + +#[derive(Debug)] +pub struct SetPageSelection { + pub category: String, + pub item: String, +} +impl Effect for SetPageSelection { + fn apply(&self, app: &mut App) { + app.model + .active_view_mut() + .set_page_selection(&self.category, &self.item); + } +} + +#[derive(Debug)] +pub struct ToggleGroup { + pub category: String, + pub group: String, +} +impl Effect for ToggleGroup { + fn apply(&self, app: &mut App) { + app.model + .active_view_mut() + .toggle_group_collapse(&self.category, &self.group); + } +} + +#[derive(Debug)] +pub struct HideItem { + pub category: String, + pub item: String, +} +impl Effect for HideItem { + fn apply(&self, app: &mut App) { + app.model + .active_view_mut() + .hide_item(&self.category, &self.item); + } +} + +#[derive(Debug)] +pub struct ShowItem { + pub category: String, + pub item: String, +} +impl Effect for ShowItem { + fn apply(&self, app: &mut App) { + app.model + .active_view_mut() + .show_item(&self.category, &self.item); + } +} + +#[derive(Debug)] +pub struct TransposeAxes; +impl Effect for TransposeAxes { + fn apply(&self, app: &mut App) { + app.model.active_view_mut().transpose_axes(); + } +} + +#[derive(Debug)] +pub struct CycleAxis(pub String); +impl Effect for CycleAxis { + fn apply(&self, app: &mut App) { + app.model.active_view_mut().cycle_axis(&self.0); + } +} + +#[derive(Debug)] +pub struct SetNumberFormat(pub String); +impl Effect for SetNumberFormat { + fn apply(&self, app: &mut App) { + app.model.active_view_mut().number_format = self.0.clone(); + } +} + +// ── Navigation ─────────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct SetSelected(pub usize, pub usize); +impl Effect for SetSelected { + fn apply(&self, app: &mut App) { + app.model.active_view_mut().selected = (self.0, self.1); + } +} + +#[derive(Debug)] +pub struct SetRowOffset(pub usize); +impl Effect for SetRowOffset { + fn apply(&self, app: &mut App) { + app.model.active_view_mut().row_offset = self.0; + } +} + +#[derive(Debug)] +pub struct SetColOffset(pub usize); +impl Effect for SetColOffset { + fn apply(&self, app: &mut App) { + app.model.active_view_mut().col_offset = self.0; + } +} + +// ── App state ──────────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct ChangeMode(pub AppMode); +impl Effect for ChangeMode { + fn apply(&self, app: &mut App) { + app.mode = self.0.clone(); + } +} + +#[derive(Debug)] +pub struct SetStatus(pub String); +impl Effect for SetStatus { + fn apply(&self, app: &mut App) { + app.status_msg = self.0.clone(); + } +} + +#[derive(Debug)] +pub struct MarkDirty; +impl Effect for MarkDirty { + fn apply(&self, app: &mut App) { + app.dirty = true; + } +} + +#[derive(Debug)] +pub struct SetYanked(pub Option); +impl Effect for SetYanked { + fn apply(&self, app: &mut App) { + app.yanked = self.0.clone(); + } +} + +#[derive(Debug)] +pub struct SetSearchQuery(pub String); +impl Effect for SetSearchQuery { + fn apply(&self, app: &mut App) { + app.search_query = self.0.clone(); + } +} + +#[derive(Debug)] +pub struct SetSearchMode(pub bool); +impl Effect for SetSearchMode { + fn apply(&self, app: &mut App) { + app.search_mode = self.0; + } +} + +#[derive(Debug)] +pub struct SetPendingKey(pub Option); +impl Effect for SetPendingKey { + fn apply(&self, app: &mut App) { + app.pending_key = self.0; + } +} + +// ── Side effects ───────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct Save; +impl Effect for Save { + fn apply(&self, app: &mut App) { + if let Some(ref path) = app.file_path { + match crate::persistence::save(&app.model, path) { + Ok(()) => { + app.dirty = false; + app.status_msg = format!("Saved to {}", path.display()); + } + Err(e) => { + app.status_msg = format!("Save error: {e}"); + } + } + } else { + app.status_msg = "No file path — use :w ".to_string(); + } + } +} + +#[derive(Debug)] +pub struct SaveAs(pub PathBuf); +impl Effect for SaveAs { + fn apply(&self, app: &mut App) { + match crate::persistence::save(&app.model, &self.0) { + Ok(()) => { + app.file_path = Some(self.0.clone()); + app.dirty = false; + app.status_msg = format!("Saved to {}", self.0.display()); + } + Err(e) => { + app.status_msg = format!("Save error: {e}"); + } + } + } +} + +#[derive(Debug)] +pub struct ExportCsv(pub PathBuf); +impl Effect for ExportCsv { + fn apply(&self, app: &mut App) { + let view_name = app.model.active_view.clone(); + match crate::persistence::export_csv(&app.model, &view_name, &self.0) { + Ok(()) => { + app.status_msg = format!("Exported to {}", self.0.display()); + } + Err(e) => { + app.status_msg = format!("Export error: {e}"); + } + } + } +} + +#[derive(Debug)] +pub struct SetPanelOpen { + pub panel: Panel, + pub open: bool, +} + +#[derive(Debug, Clone, Copy)] +pub enum Panel { + Formula, + Category, + View, +} + +impl Effect for SetPanelOpen { + fn apply(&self, app: &mut App) { + match self.panel { + Panel::Formula => app.formula_panel_open = self.open, + Panel::Category => app.category_panel_open = self.open, + Panel::View => app.view_panel_open = self.open, + } + } +} + +#[derive(Debug)] +pub struct SetPanelCursor { + pub panel: Panel, + pub cursor: usize, +} +impl Effect for SetPanelCursor { + fn apply(&self, app: &mut App) { + match self.panel { + Panel::Formula => app.formula_cursor = self.cursor, + Panel::Category => app.cat_panel_cursor = self.cursor, + Panel::View => app.view_panel_cursor = self.cursor, + } + } +} + +// ── Convenience constructors ───────────────────────────────────────────────── + +pub fn mark_dirty() -> Box { + Box::new(MarkDirty) +} + +pub fn set_status(msg: impl Into) -> Box { + Box::new(SetStatus(msg.into())) +} + +pub fn change_mode(mode: AppMode) -> Box { + Box::new(ChangeMode(mode)) +} + +pub fn set_selected(row: usize, col: usize) -> Box { + Box::new(SetSelected(row, col)) +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7438a47..e1c466f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,6 @@ pub mod app; pub mod category_panel; +pub mod effect; pub mod formula_panel; pub mod grid; pub mod help;