Files
improvise/src/ui/effect.rs
Edward Langley fbd672d5ed refactor!: unify records and pivot mode cell handling
Refactor records mode to use synthetic CellKeys (_Index, _Dim) for all columns,
allowing uniform handling of display values and edits across both pivot and
records modes.

- Introduce `synthetic_record_info` to extract metadata from synthetic keys.
- Update `GridLayout::cell_key` to return synthetic keys in records mode.
- Add `GridLayout::resolve_display` to handle value resolution for synthetic
  keys.
- Replace `records_col` and `records_value` in `CmdContext` with a unified
  `display_value`.
- Update `EditOrDrill` and `AddRecordRow` to use synthetic key detection.
- Refactor `CommitCellEdit` to use a shared `commit_cell_value` helper.

BREAKING CHANGE: CmdContext fields `records_col` and `records_value` are replaced by
`display_value` .
Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
2026-04-11 00:06:48 -07:00

875 lines
27 KiB
Rust

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);
}
}
/// Re-enter edit mode by reading the cell value at the current cursor.
/// Used after commit+advance to continue data entry.
#[derive(Debug)]
pub struct EnterEditAtCursor;
impl Effect for EnterEditAtCursor {
fn apply(&self, app: &mut App) {
let ctx = app.cmd_context(crossterm::event::KeyCode::Null, crossterm::event::KeyModifiers::NONE);
let value = ctx.display_value.clone();
drop(ctx);
app.buffers.insert("edit".to_string(), value);
app.mode = AppMode::Editing {
buffer: String::new(),
};
}
}
#[derive(Debug)]
pub struct TogglePruneEmpty;
impl Effect for TogglePruneEmpty {
fn apply(&self, app: &mut App) {
let v = app.model.active_view_mut();
v.prune_empty = !v.prune_empty;
}
}
#[derive(Debug)]
pub struct ToggleCatExpand(pub String);
impl Effect for ToggleCatExpand {
fn apply(&self, app: &mut App) {
if !app.expanded_cats.remove(&self.0) {
app.expanded_cats.insert(self.0.clone());
}
}
}
#[derive(Debug)]
pub struct RemoveItem {
pub category: String,
pub item: String,
}
impl Effect for RemoveItem {
fn apply(&self, app: &mut App) {
app.model.remove_item(&self.category, &self.item);
}
}
#[derive(Debug)]
pub struct RemoveCategory(pub String);
impl Effect for RemoveCategory {
fn apply(&self, app: &mut App) {
app.model.remove_category(&self.0);
}
}
// ── 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 current = app.model.active_view.clone();
if current != self.0 {
app.view_back_stack.push(current);
app.view_forward_stack.clear();
}
let _ = app.model.switch_view(&self.0);
}
}
/// Go back in view history (pop back stack, push current to forward stack).
#[derive(Debug)]
pub struct ViewBack;
impl Effect for ViewBack {
fn apply(&self, app: &mut App) {
if let Some(prev) = app.view_back_stack.pop() {
let current = app.model.active_view.clone();
app.view_forward_stack.push(current);
let _ = app.model.switch_view(&prev);
}
}
}
/// Go forward in view history (pop forward stack, push current to back stack).
#[derive(Debug)]
pub struct ViewForward;
impl Effect for ViewForward {
fn apply(&self, app: &mut App) {
if let Some(next) = app.view_forward_stack.pop() {
let current = app.model.active_view.clone();
app.view_back_stack.push(current);
let _ = app.model.switch_view(&next);
}
}
}
#[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;
}
}
/// Set a named buffer's contents.
#[derive(Debug)]
pub struct SetBuffer {
pub name: String,
pub value: String,
}
impl Effect for SetBuffer {
fn apply(&self, app: &mut App) {
// "search" is special — it writes to search_query for backward compat
if self.name == "search" {
app.search_query = self.value.clone();
} else {
app.buffers.insert(self.name.clone(), self.value.clone());
}
}
}
#[derive(Debug)]
pub struct SetTileCatIdx(pub usize);
impl Effect for SetTileCatIdx {
fn apply(&self, app: &mut App) {
app.tile_cat_idx = self.0;
}
}
/// Populate the drill state with a frozen snapshot of records.
/// Clears any previous drill state.
#[derive(Debug)]
pub struct StartDrill(pub Vec<(CellKey, CellValue)>);
impl Effect for StartDrill {
fn apply(&self, app: &mut App) {
app.drill_state = Some(super::app::DrillState {
records: self.0.clone(),
pending_edits: std::collections::HashMap::new(),
});
}
}
/// Apply any pending edits to the model and clear the drill state.
#[derive(Debug)]
pub struct ApplyAndClearDrill;
impl Effect for ApplyAndClearDrill {
fn apply(&self, app: &mut App) {
let Some(drill) = app.drill_state.take() else {
return;
};
if drill.pending_edits.is_empty() {
return;
}
// For each pending edit, update the cell
for ((record_idx, col_name), new_value) in &drill.pending_edits {
let Some((orig_key, _)) = drill.records.get(*record_idx) else {
continue;
};
if col_name == "Value" {
// Update the cell's value
let value = if new_value.is_empty() {
app.model.clear_cell(orig_key);
continue;
} else if let Ok(n) = new_value.parse::<f64>() {
CellValue::Number(n)
} else {
CellValue::Text(new_value.clone())
};
app.model.set_cell(orig_key.clone(), value);
} else {
// Rename a coordinate: remove old cell, insert new with updated coord
let value = match app.model.get_cell(orig_key) {
Some(v) => v.clone(),
None => continue,
};
app.model.clear_cell(orig_key);
// Build new key by replacing the coord
let new_coords: Vec<(String, String)> = orig_key
.0
.iter()
.map(|(c, i)| {
if c == col_name {
(c.clone(), new_value.clone())
} else {
(c.clone(), i.clone())
}
})
.collect();
let new_key = CellKey::new(new_coords);
// Ensure the new item exists in that category
if let Some(cat) = app.model.category_mut(col_name) {
cat.add_item(new_value.clone());
}
app.model.set_cell(new_key, value);
}
}
app.dirty = true;
}
}
/// Stage a pending edit in the drill state.
#[derive(Debug)]
pub struct SetDrillPendingEdit {
pub record_idx: usize,
pub col_name: String,
pub new_value: String,
}
impl Effect for SetDrillPendingEdit {
fn apply(&self, app: &mut App) {
if let Some(drill) = &mut app.drill_state {
drill
.pending_edits
.insert((self.record_idx, self.col_name.clone()), self.new_value.clone());
}
}
}
// ── 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}");
}
}
}
}
/// Dispatch a key event to the import wizard.
/// The wizard has its own internal state machine; this effect handles
/// all wizard key interactions and App-level side effects.
#[derive(Debug)]
pub struct WizardKey {
pub key_code: crossterm::event::KeyCode,
}
impl Effect for WizardKey {
fn apply(&self, app: &mut App) {
use crate::import::wizard::WizardStep;
let Some(wizard) = &mut app.wizard else {
return;
};
match &wizard.step.clone() {
WizardStep::Preview => match self.key_code {
crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Char(' ') => {
wizard.advance()
}
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::SelectArrayPath => match self.key_code {
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
wizard.move_cursor(-1)
}
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
wizard.move_cursor(1)
}
crossterm::event::KeyCode::Enter => wizard.confirm_path(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::ReviewProposals => match self.key_code {
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
wizard.move_cursor(-1)
}
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
wizard.move_cursor(1)
}
crossterm::event::KeyCode::Char(' ') => wizard.toggle_proposal(),
crossterm::event::KeyCode::Char('c') => wizard.cycle_proposal_kind(),
crossterm::event::KeyCode::Enter => wizard.advance(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::ConfigureDates => match self.key_code {
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
wizard.move_cursor(-1)
}
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
wizard.move_cursor(1)
}
crossterm::event::KeyCode::Char(' ') => wizard.toggle_date_component(),
crossterm::event::KeyCode::Enter => wizard.advance(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::DefineFormulas => {
if wizard.formula_editing {
match self.key_code {
crossterm::event::KeyCode::Enter => wizard.confirm_formula(),
crossterm::event::KeyCode::Esc => wizard.cancel_formula_edit(),
crossterm::event::KeyCode::Backspace => wizard.pop_formula_char(),
crossterm::event::KeyCode::Char(c) => wizard.push_formula_char(c),
_ => {}
}
} else {
match self.key_code {
crossterm::event::KeyCode::Char('n') => wizard.start_formula_edit(),
crossterm::event::KeyCode::Char('d') => wizard.delete_formula(),
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
wizard.move_cursor(-1)
}
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
wizard.move_cursor(1)
}
crossterm::event::KeyCode::Enter => wizard.advance(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
}
}
}
WizardStep::NameModel => match self.key_code {
crossterm::event::KeyCode::Char(c) => wizard.push_name_char(c),
crossterm::event::KeyCode::Backspace => wizard.pop_name_char(),
crossterm::event::KeyCode::Enter => match wizard.build_model() {
Ok(mut model) => {
model.normalize_view_state();
app.model = model;
app.formula_cursor = 0;
app.dirty = true;
app.status_msg = "Import successful! Press :w <path> to save.".to_string();
app.mode = AppMode::Normal;
app.wizard = None;
}
Err(e) => {
if let Some(w) = &mut app.wizard {
w.message = Some(format!("Error: {e}"));
}
}
},
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::Done => {
app.mode = AppMode::Normal;
app.wizard = None;
}
}
}
}
/// Start the import wizard from a JSON file path.
#[derive(Debug)]
pub struct StartImportWizard(pub String);
impl Effect for StartImportWizard {
fn apply(&self, app: &mut App) {
match std::fs::read_to_string(&self.0) {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Ok(json) => {
app.wizard = Some(crate::import::wizard::ImportWizard::new(json));
app.mode = AppMode::ImportWizard;
}
Err(e) => {
app.status_msg = format!("JSON parse error: {e}");
}
},
Err(e) => {
app.status_msg = format!("Cannot read file: {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}");
}
}
}
}
/// Load a model from a file, replacing the current one.
#[derive(Debug)]
pub struct LoadModel(pub PathBuf);
impl Effect for LoadModel {
fn apply(&self, app: &mut App) {
match crate::persistence::load(&self.0) {
Ok(mut loaded) => {
loaded.normalize_view_state();
app.model = loaded;
app.status_msg = format!("Loaded from {}", self.0.display());
}
Err(e) => {
app.status_msg = format!("Load error: {e}");
}
}
}
}
/// Headless JSON/CSV import: read file, analyze, build model, replace current.
#[derive(Debug)]
pub struct ImportJsonHeadless {
pub path: PathBuf,
pub model_name: Option<String>,
pub array_path: Option<String>,
}
impl Effect for ImportJsonHeadless {
fn apply(&self, app: &mut App) {
use crate::import::analyzer::{
analyze_records, extract_array_at_path, find_array_paths, FieldKind,
};
use crate::import::wizard::ImportPipeline;
let is_csv = self
.path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("csv"));
let records = if is_csv {
match crate::import::csv_parser::parse_csv(&self.path) {
Ok(recs) => recs,
Err(e) => {
app.status_msg = format!("CSV error: {e}");
return;
}
}
} else {
let content = match std::fs::read_to_string(&self.path) {
Ok(c) => c,
Err(e) => {
app.status_msg = format!("Cannot read '{}': {e}", self.path.display());
return;
}
};
let value: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
app.status_msg = format!("JSON parse error: {e}");
return;
}
};
if let Some(ap) = self.array_path.as_deref().filter(|s| !s.is_empty()) {
match extract_array_at_path(&value, ap) {
Some(arr) => arr.clone(),
None => {
app.status_msg = format!("No array at path '{ap}'");
return;
}
}
} else if let Some(arr) = value.as_array() {
arr.clone()
} else {
let paths = find_array_paths(&value);
if let Some(first) = paths.first() {
match extract_array_at_path(&value, first) {
Some(arr) => arr.clone(),
None => {
app.status_msg = "Could not extract records array".to_string();
return;
}
}
} else {
app.status_msg = "No array found in JSON".to_string();
return;
}
}
};
let proposals = analyze_records(&records);
let raw = if is_csv {
serde_json::Value::Array(records.clone())
} else {
serde_json::from_str(&std::fs::read_to_string(&self.path).unwrap_or_default())
.unwrap_or(serde_json::Value::Array(records.clone()))
};
let pipeline = ImportPipeline {
raw,
array_paths: vec![],
selected_path: self.array_path.as_deref().unwrap_or("").to_string(),
records,
proposals: proposals
.into_iter()
.map(|mut p| {
p.accepted = p.kind != FieldKind::Label;
p
})
.collect(),
model_name: self
.model_name
.as_deref()
.unwrap_or("Imported Model")
.to_string(),
formulas: vec![],
};
match pipeline.build_model() {
Ok(new_model) => {
app.model = new_model;
app.status_msg = "Imported successfully".to_string();
}
Err(e) => {
app.status_msg = format!("Import 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))
}