add panel toggling and new command implementations

- Implemented a suite of new commands for panel visibility, editing, export
  prompts, search navigation, page cycling, and grid operations.
- Updated tests to cover new command behavior.
- Adjusted command context usage accordingly.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gpt-oss:20b)
This commit is contained in:
Edward Langley
2026-04-04 09:31:48 -07:00
parent f2bb8ec2a7
commit c188ce3f9d

View File

@ -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<Box<dyn Effect>> {
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<Box<dyn Effect>> = 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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> = 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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<usize> = (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<Box<dyn Effect>> = 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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
let data = page_cat_data(ctx);
if data.is_empty() {
return vec![];
}
let mut indices: Vec<usize> = 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<dyn Effect>
})
.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<Box<dyn Effect>> {
let data = page_cat_data(ctx);
if data.is_empty() {
return vec![];
}
let mut indices: Vec<usize> = 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<dyn Effect>
})
.collect()
}
}
/// Gather (cat_name, items, current_idx) for page-axis categories.
fn page_cat_data(ctx: &CmdContext) -> Vec<(String, Vec<String>, usize)> {
let view = ctx.model.active_view();
let page_cats: Vec<String> = view
.categories_on(Axis::Page)
.into_iter()
.map(String::from)
.collect();
page_cats
.into_iter()
.filter_map(|cat| {
let items: Vec<String> = 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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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}"
);
}
}