From d8f7d9a501d61de5c33753675033bd5cba267808 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sat, 4 Apr 2026 09:58:31 -0700 Subject: [PATCH] feat(commands): add panel cursor and tile selection commands Add comprehensive command implementations for managing panel cursors (formula_cursor, cat_panel_cursor, view_panel_cursor), tile selection, text buffers, and search functionality. Update EnterEditMode to use SetBuffer effect before changing mode. Update EnterTileSelect to use SetTileCatIdx effect before changing mode. Add keymap bindings for all new modes with navigation (arrows/hjkl), editing actions (Enter, Backspace, Char), and mode transitions (Esc, Tab). Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M) --- src/command/cmd.rs | 561 +++++++++++++++++++++++++++++++++++++++++- src/command/keymap.rs | 192 +++++++++++++++ 2 files changed, 746 insertions(+), 7 deletions(-) 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 } }