diff --git a/src/command/cmd.rs b/src/command/cmd.rs index 7b9d18a..5400b52 100644 --- a/src/command/cmd.rs +++ b/src/command/cmd.rs @@ -315,14 +315,479 @@ impl Cmd for EnterSearchMode { } } +// ── Panel commands ────────────────────────────────────────────────────── + +/// Toggle a panel's visibility; if it opens, focus it (enter its mode). #[derive(Debug)] -pub struct SetPendingKey(pub char); -impl Cmd for SetPendingKey { +pub struct TogglePanelAndFocus(pub Panel); +impl Cmd for TogglePanelAndFocus { fn name(&self) -> &str { - "set-pending-key" + "toggle-panel-and-focus" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let currently_open = match self.0 { + Panel::Formula => ctx.formula_panel_open, + Panel::Category => ctx.category_panel_open, + Panel::View => ctx.view_panel_open, + }; + let new_open = !currently_open; + let mut effects: Vec> = vec![Box::new(effect::SetPanelOpen { + panel: self.0, + open: new_open, + })]; + if new_open { + let mode = match self.0 { + Panel::Formula => AppMode::FormulaPanel, + Panel::Category => AppMode::CategoryPanel, + Panel::View => AppMode::ViewPanel, + }; + effects.push(effect::change_mode(mode)); + } + effects + } +} + +/// Toggle a panel's visibility without changing mode. +#[derive(Debug)] +pub struct TogglePanelVisibility(pub Panel); +impl Cmd for TogglePanelVisibility { + fn name(&self) -> &str { + "toggle-panel-visibility" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let currently_open = match self.0 { + Panel::Formula => ctx.formula_panel_open, + Panel::Category => ctx.category_panel_open, + Panel::View => ctx.view_panel_open, + }; + vec![Box::new(effect::SetPanelOpen { + panel: self.0, + open: !currently_open, + })] + } +} + +/// Tab through open panels, entering the first open panel's mode. +#[derive(Debug)] +pub struct CyclePanelFocus; +impl Cmd for CyclePanelFocus { + fn name(&self) -> &str { + "cycle-panel-focus" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + if ctx.formula_panel_open { + vec![effect::change_mode(AppMode::FormulaPanel)] + } else if ctx.category_panel_open { + vec![effect::change_mode(AppMode::CategoryPanel)] + } else if ctx.view_panel_open { + vec![effect::change_mode(AppMode::ViewPanel)] + } else { + vec![] + } + } +} + +// ── Editing entry ─────────────────────────────────────────────────────── + +/// Read the current cell value and enter Editing mode with it as the buffer. +#[derive(Debug)] +pub struct EnterEditMode; +impl Cmd for EnterEditMode { + fn name(&self) -> &str { + "enter-edit-mode" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let layout = GridLayout::new(ctx.model, ctx.model.active_view()); + let (ri, ci) = ctx.selected; + let current = layout + .cell_key(ri, ci) + .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 })] + } +} + +/// Typewriter-style advance: move down, wrap to top of next column at bottom. +#[derive(Debug)] +pub struct EnterAdvance; +impl Cmd for EnterAdvance { + fn name(&self) -> &str { + "enter-advance" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let layout = GridLayout::new(ctx.model, ctx.model.active_view()); + let row_max = layout.row_count().saturating_sub(1); + let col_max = layout.col_count().saturating_sub(1); + let (r, c) = ctx.selected; + let (nr, nc) = if r < row_max { + (r + 1, c) + } else if c < col_max { + (0, c + 1) + } else { + (r, c) // already at bottom-right; stay + }; + let mut effects: Vec> = vec![effect::set_selected(nr, nc)]; + let mut row_offset = ctx.row_offset; + let mut col_offset = ctx.col_offset; + if nr < row_offset { + row_offset = nr; + } + if nr >= row_offset + 20 { + row_offset = nr.saturating_sub(19); + } + if nc < col_offset { + col_offset = nc; + } + if nc >= col_offset + 8 { + col_offset = nc.saturating_sub(7); + } + if row_offset != ctx.row_offset { + effects.push(Box::new(effect::SetRowOffset(row_offset))); + } + if col_offset != ctx.col_offset { + effects.push(Box::new(effect::SetColOffset(col_offset))); + } + effects + } +} + +/// Enter export prompt mode. +#[derive(Debug)] +pub struct EnterExportPrompt; +impl Cmd for EnterExportPrompt { + fn name(&self) -> &str { + "enter-export-prompt" } fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![Box::new(effect::SetPendingKey(Some(self.0)))] + vec![effect::change_mode(AppMode::ExportPrompt { + buffer: String::new(), + })] + } +} + +// ── Search / navigation ───────────────────────────────────────────────── + +/// Navigate to the next or previous search match. +#[derive(Debug)] +pub struct SearchNavigate(pub bool); +impl Cmd for SearchNavigate { + fn name(&self) -> &str { + "search-navigate" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let query = ctx.search_query.to_lowercase(); + if query.is_empty() { + return vec![]; + } + + let layout = GridLayout::new(ctx.model, ctx.model.active_view()); + let (cur_row, cur_col) = ctx.selected; + let total_rows = layout.row_count().max(1); + let total_cols = layout.col_count().max(1); + let total = total_rows * total_cols; + let cur_flat = cur_row * total_cols + cur_col; + + let matches: Vec = (0..total) + .filter(|&flat| { + let ri = flat / total_cols; + let ci = flat % total_cols; + let key = match layout.cell_key(ri, ci) { + Some(k) => k, + None => return false, + }; + let s = match ctx.model.evaluate_aggregated(&key, &layout.none_cats) { + Some(CellValue::Number(n)) => format!("{n}"), + Some(CellValue::Text(t)) => t, + None => String::new(), + }; + s.to_lowercase().contains(&query) + }) + .collect(); + + if matches.is_empty() { + return vec![effect::set_status(format!( + "No matches for '{}'", + ctx.search_query + ))]; + } + + let target_flat = if self.0 { + matches + .iter() + .find(|&&f| f > cur_flat) + .or_else(|| matches.first()) + .copied() + } else { + matches + .iter() + .rev() + .find(|&&f| f < cur_flat) + .or_else(|| matches.last()) + .copied() + }; + + if let Some(flat) = target_flat { + let ri = flat / total_cols; + let ci = flat % total_cols; + let mut effects: Vec> = vec![effect::set_selected(ri, ci)]; + if ri < ctx.row_offset { + effects.push(Box::new(effect::SetRowOffset(ri))); + } + if ci < ctx.col_offset { + effects.push(Box::new(effect::SetColOffset(ci))); + } + effects.push(effect::set_status(format!( + "Match {}/{} for '{}'", + matches.iter().position(|&f| f == flat).unwrap_or(0) + 1, + matches.len(), + ctx.search_query, + ))); + effects + } else { + vec![] + } + } +} + +/// If search query is active, navigate backward; otherwise open CategoryAdd. +#[derive(Debug)] +pub struct SearchOrCategoryAdd; +impl Cmd for SearchOrCategoryAdd { + fn name(&self) -> &str { + "search-or-category-add" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + if !ctx.search_query.is_empty() { + SearchNavigate(false).execute(ctx) + } else { + vec![ + Box::new(effect::SetPanelOpen { + panel: Panel::Category, + open: true, + }), + effect::change_mode(AppMode::CategoryAdd { + buffer: String::new(), + }), + ] + } + } +} + +// ── Page navigation ───────────────────────────────────────────────────── + +/// Advance to the next page (odometer-style cycling). +#[derive(Debug)] +pub struct PageNext; +impl Cmd for PageNext { + fn name(&self) -> &str { + "page-next" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let data = page_cat_data(ctx); + if data.is_empty() { + return vec![]; + } + let mut indices: Vec = data.iter().map(|(_, _, i)| *i).collect(); + let mut carry = true; + for i in (0..data.len()).rev() { + if !carry { + break; + } + indices[i] += 1; + if indices[i] >= data[i].1.len() { + indices[i] = 0; + } else { + carry = false; + } + } + data.iter() + .enumerate() + .map(|(i, (cat, items, _))| { + Box::new(effect::SetPageSelection { + category: cat.clone(), + item: items[indices[i]].clone(), + }) as Box + }) + .collect() + } +} + +/// Go to the previous page (odometer-style cycling). +#[derive(Debug)] +pub struct PagePrev; +impl Cmd for PagePrev { + fn name(&self) -> &str { + "page-prev" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let data = page_cat_data(ctx); + if data.is_empty() { + return vec![]; + } + let mut indices: Vec = data.iter().map(|(_, _, i)| *i).collect(); + let mut borrow = true; + for i in (0..data.len()).rev() { + if !borrow { + break; + } + if indices[i] == 0 { + indices[i] = data[i].1.len().saturating_sub(1); + } else { + indices[i] -= 1; + borrow = false; + } + } + data.iter() + .enumerate() + .map(|(i, (cat, items, _))| { + Box::new(effect::SetPageSelection { + category: cat.clone(), + item: items[indices[i]].clone(), + }) as Box + }) + .collect() + } +} + +/// Gather (cat_name, items, current_idx) for page-axis categories. +fn page_cat_data(ctx: &CmdContext) -> Vec<(String, Vec, usize)> { + let view = ctx.model.active_view(); + let page_cats: Vec = view + .categories_on(Axis::Page) + .into_iter() + .map(String::from) + .collect(); + page_cats + .into_iter() + .filter_map(|cat| { + let items: Vec = ctx + .model + .category(&cat) + .map(|c| { + c.ordered_item_names() + .into_iter() + .map(String::from) + .collect() + }) + .unwrap_or_default(); + if items.is_empty() { + return None; + } + let current = view + .page_selection(&cat) + .map(String::from) + .or_else(|| items.first().cloned()) + .unwrap_or_default(); + let idx = items.iter().position(|i| *i == current).unwrap_or(0); + Some((cat, items, idx)) + }) + .collect() +} + +// ── Grid operations ───────────────────────────────────────────────────── + +/// Toggle the row group collapse under the cursor. +#[derive(Debug)] +pub struct ToggleGroupUnderCursor; +impl Cmd for ToggleGroupUnderCursor { + fn name(&self) -> &str { + "toggle-group-under-cursor" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let layout = GridLayout::new(ctx.model, ctx.model.active_view()); + let sel_row = ctx.selected.0; + let Some((cat, group)) = layout.row_group_for(sel_row) else { + return vec![]; + }; + vec![ + Box::new(effect::ToggleGroup { + category: cat, + group, + }), + effect::mark_dirty(), + ] + } +} + +/// Toggle the column group collapse under the cursor. +#[derive(Debug)] +pub struct ToggleColGroupUnderCursor; +impl Cmd for ToggleColGroupUnderCursor { + fn name(&self) -> &str { + "toggle-col-group-under-cursor" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let layout = GridLayout::new(ctx.model, ctx.model.active_view()); + let sel_col = ctx.selected.1; + let Some((cat, group)) = layout.col_group_for(sel_col) else { + return vec![]; + }; + // After toggling, col_count may shrink — clamp selection + // We return ToggleGroup + MarkDirty; selection clamping will need + // to happen in the effect or in a follow-up pass + vec![ + Box::new(effect::ToggleGroup { + category: cat, + group, + }), + effect::mark_dirty(), + ] + } +} + +/// Hide the row item at the cursor. +#[derive(Debug)] +pub struct HideSelectedRowItem; +impl Cmd for HideSelectedRowItem { + fn name(&self) -> &str { + "hide-selected-row-item" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let layout = GridLayout::new(ctx.model, ctx.model.active_view()); + let Some(cat_name) = layout.row_cats.first().cloned() else { + return vec![]; + }; + let sel_row = ctx.selected.0; + let Some(items) = layout + .row_items + .iter() + .filter_map(|e| { + if let AxisEntry::DataItem(v) = e { + Some(v) + } else { + None + } + }) + .nth(sel_row) + else { + return vec![]; + }; + let item_name = items[0].clone(); + vec![ + Box::new(effect::HideItem { + category: cat_name, + item: item_name, + }), + effect::mark_dirty(), + ] + } +} + +/// Enter tile select mode. +#[derive(Debug)] +pub struct EnterTileSelect; +impl Cmd for EnterTileSelect { + fn name(&self) -> &str { + "enter-tile-select" + } + 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 })] + } else { + vec![] + } } } @@ -342,9 +807,11 @@ mod tests { col_offset: view.col_offset, search_query: "", yanked: &None, - pending_key: None, dirty: false, file_path_set: false, + formula_panel_open: false, + category_panel_open: false, + view_panel_open: false, } } @@ -401,7 +868,10 @@ mod tests { let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 1); let dbg = format!("{:?}", effects[0]); - assert!(dbg.contains("ChangeMode"), "Expected ChangeMode, got: {dbg}"); + assert!( + dbg.contains("ChangeMode"), + "Expected ChangeMode, got: {dbg}" + ); } #[test] @@ -431,4 +901,133 @@ mod tests { let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 2); // SetYanked + SetStatus } + + #[test] + fn toggle_panel_and_focus_opens_and_enters_mode() { + let m = two_cat_model(); + let ctx = make_ctx(&m); + let cmd = TogglePanelAndFocus(effect::Panel::Formula); + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode + let dbg = format!("{:?}", effects[1]); + assert!( + dbg.contains("FormulaPanel"), + "Expected FormulaPanel mode, got: {dbg}" + ); + } + + #[test] + fn toggle_panel_and_focus_closes_when_open() { + let m = two_cat_model(); + let mut ctx = make_ctx(&m); + ctx.formula_panel_open = true; + let cmd = TogglePanelAndFocus(effect::Panel::Formula); + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); // SetPanelOpen only, no mode change + } + + #[test] + fn enter_advance_moves_down() { + let m = two_cat_model(); + let ctx = make_ctx(&m); + let cmd = EnterAdvance; + let effects = cmd.execute(&ctx); + assert!(!effects.is_empty()); + let dbg = format!("{:?}", effects[0]); + assert!( + dbg.contains("SetSelected(1, 0)"), + "Expected row 1, got: {dbg}" + ); + } + + #[test] + fn search_navigate_with_empty_query_returns_nothing() { + let m = two_cat_model(); + let ctx = make_ctx(&m); + let cmd = SearchNavigate(true); + let effects = cmd.execute(&ctx); + assert!(effects.is_empty()); + } + + #[test] + fn enter_edit_mode_produces_editing_mode() { + let m = two_cat_model(); + let ctx = make_ctx(&m); + let cmd = EnterEditMode; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = format!("{:?}", effects[0]); + assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}"); + } + + #[test] + fn enter_tile_select_with_categories() { + let m = two_cat_model(); + let ctx = make_ctx(&m); + let cmd = EnterTileSelect; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = format!("{:?}", effects[0]); + assert!( + dbg.contains("TileSelect"), + "Expected TileSelect mode, got: {dbg}" + ); + } + + #[test] + fn enter_tile_select_no_categories() { + let m = Model::new("Empty"); + let ctx = make_ctx(&m); + let cmd = EnterTileSelect; + let effects = cmd.execute(&ctx); + assert!(effects.is_empty()); + } + + #[test] + fn toggle_group_under_cursor_returns_empty_without_groups() { + let m = two_cat_model(); + let ctx = make_ctx(&m); + let cmd = ToggleGroupUnderCursor; + let effects = cmd.execute(&ctx); + // No groups defined, so nothing to toggle + assert!(effects.is_empty()); + } + + #[test] + fn search_or_category_add_without_query_opens_category_add() { + let m = two_cat_model(); + let ctx = make_ctx(&m); + let cmd = SearchOrCategoryAdd; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode + let dbg = format!("{:?}", effects[1]); + assert!( + dbg.contains("CategoryAdd"), + "Expected CategoryAdd, got: {dbg}" + ); + } + + #[test] + fn cycle_panel_focus_with_no_panels_open() { + let m = two_cat_model(); + let ctx = make_ctx(&m); + let cmd = CyclePanelFocus; + let effects = cmd.execute(&ctx); + assert!(effects.is_empty()); + } + + #[test] + fn cycle_panel_focus_with_formula_panel_open() { + let m = two_cat_model(); + let mut ctx = make_ctx(&m); + ctx.formula_panel_open = true; + let cmd = CyclePanelFocus; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = format!("{:?}", effects[0]); + assert!( + dbg.contains("FormulaPanel"), + "Expected FormulaPanel, got: {dbg}" + ); + } }