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)
This commit is contained in:
Edward Langley
2026-04-04 09:58:31 -07:00
parent ebe8df89ee
commit d8f7d9a501
2 changed files with 746 additions and 7 deletions

View File

@ -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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> = 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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
let view_names: Vec<String> = 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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
let view_names: Vec<String> = ctx.model.views.keys().cloned().collect();
if let Some(name) = view_names.get(ctx.view_panel_cursor) {
let mut effects: Vec<Box<dyn Effect>> = 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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> = 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::<f64>() {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> = 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<Box<dyn Effect>> {
let buf = ctx.buffers.get("category").cloned().unwrap_or_default();
let trimmed = buf.trim().to_string();
let mut effects: Vec<Box<dyn Effect>> = 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<Box<dyn Effect>> {
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<Box<dyn Effect>> = 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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<HashMap<String, String>> =
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}"

View File

@ -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
}
}