diff --git a/src/command/cmd.rs b/src/command/cmd.rs index 3527931..fd41698 100644 --- a/src/command/cmd.rs +++ b/src/command/cmd.rs @@ -419,7 +419,13 @@ impl Cmd for EnterEditMode { .and_then(|k| ctx.model.get_cell(&k).cloned()) .map(|v| v.to_string()) .unwrap_or_default(); - vec![effect::change_mode(AppMode::Editing { buffer: current })] + vec![ + Box::new(effect::SetBuffer { + name: "edit".to_string(), + value: current, + }), + effect::change_mode(AppMode::Editing { buffer: String::new() }), + ] } } @@ -799,20 +805,553 @@ impl Cmd for EnterTileSelect { fn execute(&self, ctx: &CmdContext) -> Vec> { let count = ctx.model.category_names().len(); if count > 0 { - vec![effect::change_mode(AppMode::TileSelect { cat_idx: 0 })] + vec![ + Box::new(effect::SetTileCatIdx(0)), + effect::change_mode(AppMode::TileSelect), + ] } else { vec![] } } } +// ── Panel cursor commands ──────────────────────────────────────────────────── + +/// Move a panel cursor by delta, clamping to bounds. +#[derive(Debug)] +pub struct MovePanelCursor { + pub panel: Panel, + pub delta: i32, +} +impl Cmd for MovePanelCursor { + fn name(&self) -> &str { + "move-panel-cursor" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let (cursor, max) = match self.panel { + Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()), + Panel::Category => (ctx.cat_panel_cursor, ctx.model.category_names().len()), + Panel::View => (ctx.view_panel_cursor, ctx.model.views.len()), + }; + if max == 0 { + return vec![]; + } + let clamped_cursor = cursor.min(max - 1); + let new = (clamped_cursor as i32 + self.delta).clamp(0, (max - 1) as i32) as usize; + if new != cursor { + vec![Box::new(effect::SetPanelCursor { + panel: self.panel, + cursor: new, + })] + } else { + vec![] + } + } +} + +// ── Formula panel commands ────────────────────────────────────────────────── + +/// Enter formula edit mode with an empty buffer. +#[derive(Debug)] +pub struct EnterFormulaEdit; +impl Cmd for EnterFormulaEdit { + fn name(&self) -> &str { + "enter-formula-edit" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![effect::change_mode(AppMode::FormulaEdit { + buffer: String::new(), + })] + } +} + +/// Delete the formula at the current cursor position. +#[derive(Debug)] +pub struct DeleteFormulaAtCursor; +impl Cmd for DeleteFormulaAtCursor { + fn name(&self) -> &str { + "delete-formula-at-cursor" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let formulas = ctx.model.formulas(); + let cursor = ctx.formula_cursor.min(formulas.len().saturating_sub(1)); + if cursor < formulas.len() { + let f = &formulas[cursor]; + let mut effects: Vec> = vec![ + Box::new(effect::RemoveFormula { + target: f.target.clone(), + target_category: f.target_category.clone(), + }), + effect::mark_dirty(), + ]; + if cursor > 0 { + effects.push(Box::new(effect::SetPanelCursor { + panel: Panel::Formula, + cursor: cursor - 1, + })); + } + effects + } else { + vec![] + } + } +} + +// ── Category panel commands ───────────────────────────────────────────────── + +/// Cycle the axis assignment of the category at the cursor. +#[derive(Debug)] +pub struct CycleAxisAtCursor; +impl Cmd for CycleAxisAtCursor { + fn name(&self) -> &str { + "cycle-axis-at-cursor" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let cat_names = ctx.model.category_names(); + if let Some(cat_name) = cat_names.get(ctx.cat_panel_cursor) { + vec![Box::new(effect::CycleAxis(cat_name.to_string()))] + } else { + vec![] + } + } +} + +/// Enter ItemAdd mode for the category at the panel cursor. +#[derive(Debug)] +pub struct OpenItemAddAtCursor; +impl Cmd for OpenItemAddAtCursor { + fn name(&self) -> &str { + "open-item-add-at-cursor" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let cat_names = ctx.model.category_names(); + if let Some(cat_name) = cat_names.get(ctx.cat_panel_cursor) { + vec![effect::change_mode(AppMode::ItemAdd { + category: cat_name.to_string(), + buffer: String::new(), + })] + } else { + vec![effect::set_status( + "No category selected. Press n to add a category first.", + )] + } + } +} + +// ── View panel commands ───────────────────────────────────────────────────── + +/// Switch to the view at the panel cursor and return to Normal mode. +#[derive(Debug)] +pub struct SwitchViewAtCursor; +impl Cmd for SwitchViewAtCursor { + fn name(&self) -> &str { + "switch-view-at-cursor" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let view_names: Vec = ctx.model.views.keys().cloned().collect(); + if let Some(name) = view_names.get(ctx.view_panel_cursor) { + vec![ + Box::new(effect::SwitchView(name.clone())), + effect::change_mode(AppMode::Normal), + ] + } else { + vec![] + } + } +} + +/// Create a new view, switch to it, and return to Normal mode. +#[derive(Debug)] +pub struct CreateAndSwitchView; +impl Cmd for CreateAndSwitchView { + fn name(&self) -> &str { + "create-and-switch-view" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let name = format!("View {}", ctx.model.views.len() + 1); + vec![ + Box::new(effect::CreateView(name.clone())), + Box::new(effect::SwitchView(name)), + effect::mark_dirty(), + effect::change_mode(AppMode::Normal), + ] + } +} + +/// Delete the view at the panel cursor. +#[derive(Debug)] +pub struct DeleteViewAtCursor; +impl Cmd for DeleteViewAtCursor { + fn name(&self) -> &str { + "delete-view-at-cursor" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let view_names: Vec = ctx.model.views.keys().cloned().collect(); + if let Some(name) = view_names.get(ctx.view_panel_cursor) { + let mut effects: Vec> = vec![ + Box::new(effect::DeleteView(name.clone())), + effect::mark_dirty(), + ]; + if ctx.view_panel_cursor > 0 { + effects.push(Box::new(effect::SetPanelCursor { + panel: Panel::View, + cursor: ctx.view_panel_cursor - 1, + })); + } + effects + } else { + vec![] + } + } +} + +// ── Tile select commands ──────────────────────────────────────────────────── + +/// Move the tile select cursor left or right. +#[derive(Debug)] +pub struct MoveTileCursor(pub i32); +impl Cmd for MoveTileCursor { + fn name(&self) -> &str { + "move-tile-cursor" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let count = ctx.model.category_names().len(); + if count == 0 { + return vec![]; + } + let new = (ctx.tile_cat_idx as i32 + self.0).clamp(0, (count - 1) as i32) as usize; + vec![Box::new(effect::SetTileCatIdx(new))] + } +} + +/// Cycle the axis for the category at the tile cursor, then return to Normal. +#[derive(Debug)] +pub struct CycleAxisForTile; +impl Cmd for CycleAxisForTile { + fn name(&self) -> &str { + "cycle-axis-for-tile" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let cat_names = ctx.model.category_names(); + if let Some(name) = cat_names.get(ctx.tile_cat_idx) { + vec![ + Box::new(effect::CycleAxis(name.to_string())), + effect::mark_dirty(), + effect::change_mode(AppMode::Normal), + ] + } else { + vec![effect::change_mode(AppMode::Normal)] + } + } +} + +/// Set a specific axis for the category at the tile cursor, then return to Normal. +#[derive(Debug)] +pub struct SetAxisForTile(pub Axis); +impl Cmd for SetAxisForTile { + fn name(&self) -> &str { + "set-axis-for-tile" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let cat_names = ctx.model.category_names(); + if let Some(name) = cat_names.get(ctx.tile_cat_idx) { + vec![ + Box::new(effect::SetAxis { + category: name.to_string(), + axis: self.0, + }), + effect::mark_dirty(), + effect::change_mode(AppMode::Normal), + ] + } else { + vec![effect::change_mode(AppMode::Normal)] + } + } +} + +// ── Text buffer commands ───────────────────────────────────────────────────── + +/// Append the pressed character to a named buffer. +#[derive(Debug)] +pub struct AppendChar { + pub buffer: String, +} +impl Cmd for AppendChar { + fn name(&self) -> &str { + "append-char" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + if let KeyCode::Char(c) = ctx.key_code { + let mut val = ctx.buffers.get(&self.buffer).cloned().unwrap_or_default(); + val.push(c); + vec![Box::new(effect::SetBuffer { + name: self.buffer.clone(), + value: val, + })] + } else { + vec![] + } + } +} + +/// Pop the last character from a named buffer. +#[derive(Debug)] +pub struct PopChar { + pub buffer: String, +} +impl Cmd for PopChar { + fn name(&self) -> &str { + "pop-char" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let mut val = ctx.buffers.get(&self.buffer).cloned().unwrap_or_default(); + val.pop(); + vec![Box::new(effect::SetBuffer { + name: self.buffer.clone(), + value: val, + })] + } +} + +/// Initialize a named buffer (set it to a value) and change mode. +#[derive(Debug)] +pub struct InitBuffer { + pub buffer: String, + pub value: String, + pub mode: AppMode, +} +impl Cmd for InitBuffer { + fn name(&self) -> &str { + "init-buffer" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![ + Box::new(effect::SetBuffer { + name: self.buffer.clone(), + value: self.value.clone(), + }), + effect::change_mode(self.mode.clone()), + ] + } +} + +// ── Commit commands (mode-specific buffer consumers) ──────────────────────── + +/// Commit a cell edit: parse buffer, set cell, advance cursor, return to Normal. +#[derive(Debug)] +pub struct CommitCellEdit; +impl Cmd for CommitCellEdit { + fn name(&self) -> &str { + "commit-cell-edit" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let buf = ctx.buffers.get("edit").cloned().unwrap_or_default(); + let layout = GridLayout::new(ctx.model, ctx.model.active_view()); + let (ri, ci) = ctx.selected; + + let mut effects: Vec> = Vec::new(); + + if let Some(key) = layout.cell_key(ri, ci) { + if buf.is_empty() { + effects.push(Box::new(effect::ClearCell(key))); + } else if let Ok(n) = buf.parse::() { + effects.push(Box::new(effect::SetCell( + key, + CellValue::Number(n), + ))); + } else { + effects.push(Box::new(effect::SetCell( + key, + CellValue::Text(buf), + ))); + } + effects.push(effect::mark_dirty()); + } + effects.push(effect::change_mode(AppMode::Normal)); + // Advance cursor down (typewriter-style) + let adv = EnterAdvance; + effects.extend(adv.execute(ctx)); + effects + } +} + +/// Commit a formula from the formula edit buffer. +#[derive(Debug)] +pub struct CommitFormula; +impl Cmd for CommitFormula { + fn name(&self) -> &str { + "commit-formula" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let buf = ctx.buffers.get("formula").cloned().unwrap_or_default(); + let first_cat = ctx.model.category_names().into_iter().next().map(String::from); + let mut effects: Vec> = Vec::new(); + if let Some(cat) = first_cat { + effects.push(Box::new(effect::AddFormula { + raw: buf, + target_category: cat, + })); + effects.push(effect::mark_dirty()); + effects.push(effect::set_status("Formula added")); + } else { + effects.push(effect::set_status("Add at least one category first.")); + } + effects.push(effect::change_mode(AppMode::FormulaPanel)); + effects + } +} + +/// Commit adding a category, staying in CategoryAdd mode for the next entry. +#[derive(Debug)] +pub struct CommitCategoryAdd; +impl Cmd for CommitCategoryAdd { + fn name(&self) -> &str { + "commit-category-add" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let buf = ctx.buffers.get("category").cloned().unwrap_or_default(); + let trimmed = buf.trim().to_string(); + let mut effects: Vec> = Vec::new(); + if !trimmed.is_empty() { + effects.push(Box::new(effect::AddCategory(trimmed.clone()))); + effects.push(effect::mark_dirty()); + effects.push(effect::set_status(format!("Added category \"{trimmed}\""))); + } + // Clear buffer for next entry + effects.push(Box::new(effect::SetBuffer { + name: "category".to_string(), + value: String::new(), + })); + effects + } +} + +/// Commit adding an item, staying in ItemAdd mode for the next entry. +#[derive(Debug)] +pub struct CommitItemAdd; +impl Cmd for CommitItemAdd { + fn name(&self) -> &str { + "commit-item-add" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let buf = ctx.buffers.get("item").cloned().unwrap_or_default(); + let trimmed = buf.trim().to_string(); + // Get the category from the mode + let category = if let AppMode::ItemAdd { category, .. } = ctx.mode { + category.clone() + } else { + return vec![]; + }; + let mut effects: Vec> = Vec::new(); + if !trimmed.is_empty() { + effects.push(Box::new(effect::AddItem { + category, + item: trimmed.clone(), + })); + effects.push(effect::mark_dirty()); + effects.push(effect::set_status(format!("Added \"{trimmed}\""))); + } + // Clear buffer for next entry + effects.push(Box::new(effect::SetBuffer { + name: "item".to_string(), + value: String::new(), + })); + effects + } +} + +/// Commit an export from the export buffer. +#[derive(Debug)] +pub struct CommitExport; +impl Cmd for CommitExport { + fn name(&self) -> &str { + "commit-export" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let buf = ctx.buffers.get("export").cloned().unwrap_or_default(); + vec![ + Box::new(effect::ExportCsv(std::path::PathBuf::from(buf))), + effect::change_mode(AppMode::Normal), + ] + } +} + +/// Exit search mode (clears search_mode flag). +#[derive(Debug)] +pub struct ExitSearchMode; +impl Cmd for ExitSearchMode { + fn name(&self) -> &str { + "exit-search-mode" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![Box::new(effect::SetSearchMode(false))] + } +} + +/// Append a character to the search query. +#[derive(Debug)] +pub struct SearchAppendChar; +impl Cmd for SearchAppendChar { + fn name(&self) -> &str { + "search-append-char" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + if let KeyCode::Char(c) = ctx.key_code { + let mut q = ctx.search_query.to_string(); + q.push(c); + vec![Box::new(effect::SetSearchQuery(q))] + } else { + vec![] + } + } +} + +/// Pop the last character from the search query. +#[derive(Debug)] +pub struct SearchPopChar; +impl Cmd for SearchPopChar { + fn name(&self) -> &str { + "search-pop-char" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let mut q = ctx.search_query.to_string(); + q.pop(); + vec![Box::new(effect::SetSearchQuery(q))] + } +} + +/// Handle backspace in command mode — pop char or return to Normal if empty. +#[derive(Debug)] +pub struct CommandModeBackspace; +impl Cmd for CommandModeBackspace { + fn name(&self) -> &str { + "command-mode-backspace" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let val = ctx.buffers.get("command").cloned().unwrap_or_default(); + if val.is_empty() { + vec![effect::change_mode(AppMode::Normal)] + } else { + let mut val = val; + val.pop(); + vec![Box::new(effect::SetBuffer { + name: "command".to_string(), + value: val, + })] + } + } +} + #[cfg(test)] mod tests { use super::*; use crate::model::cell::{CellKey, CellValue}; use crate::model::Model; - fn make_ctx(model: &Model) -> CmdContext { + static EMPTY_BUFFERS: std::sync::LazyLock> = + std::sync::LazyLock::new(HashMap::new); + + fn make_ctx(model: &Model) -> CmdContext<'_> { let view = model.active_view(); CmdContext { model, @@ -824,9 +1363,17 @@ mod tests { yanked: &None, dirty: false, file_path_set: false, + search_mode: false, formula_panel_open: false, category_panel_open: false, view_panel_open: false, + formula_cursor: 0, + cat_panel_cursor: 0, + view_panel_cursor: 0, + tile_cat_idx: 0, + buffers: &EMPTY_BUFFERS, + key_code: KeyCode::Null, + key_modifiers: KeyModifiers::NONE, } } @@ -970,8 +1517,8 @@ mod tests { let ctx = make_ctx(&m); let cmd = EnterEditMode; let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = format!("{:?}", effects[0]); + assert_eq!(effects.len(), 2); // SetBuffer + ChangeMode + let dbg = format!("{:?}", effects[1]); assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}"); } @@ -981,8 +1528,8 @@ mod tests { let ctx = make_ctx(&m); let cmd = EnterTileSelect; let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = format!("{:?}", effects[0]); + assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode + let dbg = format!("{:?}", effects[1]); assert!( dbg.contains("TileSelect"), "Expected TileSelect mode, got: {dbg}" diff --git a/src/command/keymap.rs b/src/command/keymap.rs index a1aab5b..6022c8c 100644 --- a/src/command/keymap.rs +++ b/src/command/keymap.rs @@ -341,6 +341,198 @@ impl KeymapSet { help.bind_cmd(KeyCode::Char('q'), none, cmd::EnterMode(AppMode::Normal)); set.insert(ModeKey::Help, Arc::new(help)); + // ── Formula panel mode ─────────────────────────────────────────── + let mut fp = Keymap::new(); + fp.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::Normal)); + fp.bind_cmd(KeyCode::Tab, none, cmd::EnterMode(AppMode::Normal)); + fp.bind_cmd( + KeyCode::Up, + none, + cmd::MovePanelCursor { panel: crate::ui::effect::Panel::Formula, delta: -1 }, + ); + fp.bind_cmd( + KeyCode::Char('k'), + none, + cmd::MovePanelCursor { panel: crate::ui::effect::Panel::Formula, delta: -1 }, + ); + fp.bind_cmd( + KeyCode::Down, + none, + cmd::MovePanelCursor { panel: crate::ui::effect::Panel::Formula, delta: 1 }, + ); + fp.bind_cmd( + KeyCode::Char('j'), + none, + cmd::MovePanelCursor { panel: crate::ui::effect::Panel::Formula, delta: 1 }, + ); + fp.bind_cmd(KeyCode::Char('a'), none, cmd::EnterFormulaEdit); + fp.bind_cmd(KeyCode::Char('n'), none, cmd::EnterFormulaEdit); + fp.bind_cmd(KeyCode::Char('o'), none, cmd::EnterFormulaEdit); + fp.bind_cmd(KeyCode::Char('d'), none, cmd::DeleteFormulaAtCursor); + fp.bind_cmd(KeyCode::Delete, none, cmd::DeleteFormulaAtCursor); + set.insert(ModeKey::FormulaPanel, Arc::new(fp)); + + // ── Category panel mode ────────────────────────────────────────── + let mut cp = Keymap::new(); + cp.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::Normal)); + cp.bind_cmd(KeyCode::Tab, none, cmd::EnterMode(AppMode::Normal)); + cp.bind_cmd( + KeyCode::Up, + none, + cmd::MovePanelCursor { panel: crate::ui::effect::Panel::Category, delta: -1 }, + ); + cp.bind_cmd( + KeyCode::Char('k'), + none, + cmd::MovePanelCursor { panel: crate::ui::effect::Panel::Category, delta: -1 }, + ); + cp.bind_cmd( + KeyCode::Down, + none, + cmd::MovePanelCursor { panel: crate::ui::effect::Panel::Category, delta: 1 }, + ); + cp.bind_cmd( + KeyCode::Char('j'), + none, + cmd::MovePanelCursor { panel: crate::ui::effect::Panel::Category, delta: 1 }, + ); + cp.bind_cmd(KeyCode::Enter, none, cmd::CycleAxisAtCursor); + cp.bind_cmd(KeyCode::Char(' '), none, cmd::CycleAxisAtCursor); + cp.bind_cmd( + KeyCode::Char('n'), + none, + cmd::EnterMode(AppMode::CategoryAdd { buffer: String::new() }), + ); + cp.bind_cmd(KeyCode::Char('a'), none, cmd::OpenItemAddAtCursor); + cp.bind_cmd(KeyCode::Char('o'), none, cmd::OpenItemAddAtCursor); + set.insert(ModeKey::CategoryPanel, Arc::new(cp)); + + // ── View panel mode ────────────────────────────────────────────── + let mut vp = Keymap::new(); + vp.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::Normal)); + vp.bind_cmd(KeyCode::Tab, none, cmd::EnterMode(AppMode::Normal)); + vp.bind_cmd( + KeyCode::Up, + none, + cmd::MovePanelCursor { panel: crate::ui::effect::Panel::View, delta: -1 }, + ); + vp.bind_cmd( + KeyCode::Char('k'), + none, + cmd::MovePanelCursor { panel: crate::ui::effect::Panel::View, delta: -1 }, + ); + vp.bind_cmd( + KeyCode::Down, + none, + cmd::MovePanelCursor { panel: crate::ui::effect::Panel::View, delta: 1 }, + ); + vp.bind_cmd( + KeyCode::Char('j'), + none, + cmd::MovePanelCursor { panel: crate::ui::effect::Panel::View, delta: 1 }, + ); + vp.bind_cmd(KeyCode::Enter, none, cmd::SwitchViewAtCursor); + vp.bind_cmd(KeyCode::Char('n'), none, cmd::CreateAndSwitchView); + vp.bind_cmd(KeyCode::Char('o'), none, cmd::CreateAndSwitchView); + vp.bind_cmd(KeyCode::Char('d'), none, cmd::DeleteViewAtCursor); + vp.bind_cmd(KeyCode::Delete, none, cmd::DeleteViewAtCursor); + set.insert(ModeKey::ViewPanel, Arc::new(vp)); + + // ── Tile select mode ───────────────────────────────────────────── + let mut ts = Keymap::new(); + ts.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::Normal)); + ts.bind_cmd(KeyCode::Tab, none, cmd::EnterMode(AppMode::Normal)); + ts.bind_cmd(KeyCode::Left, none, cmd::MoveTileCursor(-1)); + ts.bind_cmd(KeyCode::Char('h'), none, cmd::MoveTileCursor(-1)); + ts.bind_cmd(KeyCode::Right, none, cmd::MoveTileCursor(1)); + ts.bind_cmd(KeyCode::Char('l'), none, cmd::MoveTileCursor(1)); + ts.bind_cmd(KeyCode::Enter, none, cmd::CycleAxisForTile); + ts.bind_cmd(KeyCode::Char(' '), none, cmd::CycleAxisForTile); + ts.bind_cmd(KeyCode::Char('r'), none, cmd::SetAxisForTile(Axis::Row)); + ts.bind_cmd(KeyCode::Char('c'), none, cmd::SetAxisForTile(Axis::Column)); + ts.bind_cmd(KeyCode::Char('p'), none, cmd::SetAxisForTile(Axis::Page)); + ts.bind_cmd(KeyCode::Char('n'), none, cmd::SetAxisForTile(Axis::None)); + set.insert(ModeKey::TileSelect, Arc::new(ts)); + + // ── Editing mode ───────────────────────────────────────────────── + let mut ed = Keymap::new(); + ed.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::Normal)); + ed.bind_cmd(KeyCode::Enter, none, cmd::CommitCellEdit); + ed.bind_cmd( + KeyCode::Backspace, + none, + cmd::PopChar { buffer: "edit".to_string() }, + ); + ed.bind_any_char(cmd::AppendChar { buffer: "edit".to_string() }); + set.insert(ModeKey::Editing, Arc::new(ed)); + + // ── Formula edit mode ──────────────────────────────────────────── + let mut fe = Keymap::new(); + fe.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::FormulaPanel)); + fe.bind_cmd(KeyCode::Enter, none, cmd::CommitFormula); + fe.bind_cmd( + KeyCode::Backspace, + none, + cmd::PopChar { buffer: "formula".to_string() }, + ); + fe.bind_any_char(cmd::AppendChar { buffer: "formula".to_string() }); + set.insert(ModeKey::FormulaEdit, Arc::new(fe)); + + // ── Category add mode ──────────────────────────────────────────── + let mut ca = Keymap::new(); + ca.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::CategoryPanel)); + ca.bind_cmd(KeyCode::Enter, none, cmd::CommitCategoryAdd); + ca.bind_cmd(KeyCode::Tab, none, cmd::CommitCategoryAdd); + ca.bind_cmd( + KeyCode::Backspace, + none, + cmd::PopChar { buffer: "category".to_string() }, + ); + ca.bind_any_char(cmd::AppendChar { buffer: "category".to_string() }); + set.insert(ModeKey::CategoryAdd, Arc::new(ca)); + + // ── Item add mode ──────────────────────────────────────────────── + let mut ia = Keymap::new(); + ia.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::CategoryPanel)); + ia.bind_cmd(KeyCode::Enter, none, cmd::CommitItemAdd); + ia.bind_cmd(KeyCode::Tab, none, cmd::CommitItemAdd); + ia.bind_cmd( + KeyCode::Backspace, + none, + cmd::PopChar { buffer: "item".to_string() }, + ); + ia.bind_any_char(cmd::AppendChar { buffer: "item".to_string() }); + set.insert(ModeKey::ItemAdd, Arc::new(ia)); + + // ── Export prompt mode ─────────────────────────────────────────── + let mut ep = Keymap::new(); + ep.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::Normal)); + ep.bind_cmd(KeyCode::Enter, none, cmd::CommitExport); + ep.bind_cmd( + KeyCode::Backspace, + none, + cmd::PopChar { buffer: "export".to_string() }, + ); + ep.bind_any_char(cmd::AppendChar { buffer: "export".to_string() }); + set.insert(ModeKey::ExportPrompt, Arc::new(ep)); + + // ── Command mode ───────────────────────────────────────────────── + let mut cm = Keymap::new(); + cm.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::Normal)); + // Enter → execute_command (still handled by old handler for now — + // the complex execute_command logic isn't easily a single Cmd) + cm.bind_cmd(KeyCode::Backspace, none, cmd::CommandModeBackspace); + cm.bind_any_char(cmd::AppendChar { buffer: "command".to_string() }); + set.insert(ModeKey::CommandMode, Arc::new(cm)); + + // ── Search mode ────────────────────────────────────────────────── + let mut sm = Keymap::new(); + sm.bind_cmd(KeyCode::Esc, none, cmd::ExitSearchMode); + sm.bind_cmd(KeyCode::Enter, none, cmd::ExitSearchMode); + sm.bind_cmd(KeyCode::Backspace, none, cmd::SearchPopChar); + sm.bind_any_char(cmd::SearchAppendChar); + set.insert(ModeKey::SearchMode, Arc::new(sm)); + set } }