refactor: add Effect trait and apply_effects infrastructure
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) <noreply@anthropic.com>
This commit is contained in:
@ -91,6 +91,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_effects(&mut self, effects: Vec<Box<dyn super::effect::Effect>>) {
|
||||
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()
|
||||
|
||||
398
src/ui/effect.rs
Normal file
398
src/ui/effect.rs
Normal file
@ -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<CellValue>);
|
||||
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<char>);
|
||||
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 <path>".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<dyn Effect> {
|
||||
Box::new(MarkDirty)
|
||||
}
|
||||
|
||||
pub fn set_status(msg: impl Into<String>) -> Box<dyn Effect> {
|
||||
Box::new(SetStatus(msg.into()))
|
||||
}
|
||||
|
||||
pub fn change_mode(mode: AppMode) -> Box<dyn Effect> {
|
||||
Box::new(ChangeMode(mode))
|
||||
}
|
||||
|
||||
pub fn set_selected(row: usize, col: usize) -> Box<dyn Effect> {
|
||||
Box::new(SetSelected(row, col))
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
pub mod app;
|
||||
pub mod category_panel;
|
||||
pub mod effect;
|
||||
pub mod formula_panel;
|
||||
pub mod grid;
|
||||
pub mod help;
|
||||
|
||||
Reference in New Issue
Block a user