Add quick multi-item entry to categories

Two new ways to add multiple items without repeating yourself:

1. :add-items <category> item1 item2 item3 ...
   Adds all space-separated items in one command.

2. Category panel quick-add mode (press 'a' or 'o' on a category):
   - Opens an inline prompt at the bottom of the panel
   - Enter adds the item and clears the buffer — stays open for next entry
   - Tab does the same as Enter
   - Esc closes and returns to the category list
   - The panel border turns green and the title updates to signal add mode
   - Item count in the category list updates live as items are added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ed L
2026-03-21 22:51:36 -07:00
parent 66dfdf705f
commit 4f322e53cd
4 changed files with 140 additions and 23 deletions

View File

@ -286,6 +286,7 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
AppMode::FormulaEdit { .. } => "FORMULA", AppMode::FormulaEdit { .. } => "FORMULA",
AppMode::FormulaPanel => "FORMULAS", AppMode::FormulaPanel => "FORMULAS",
AppMode::CategoryPanel => "CATEGORIES", AppMode::CategoryPanel => "CATEGORIES",
AppMode::ItemAdd { .. } => "ADD ITEMS",
AppMode::ViewPanel => "VIEWS", AppMode::ViewPanel => "VIEWS",
AppMode::TileSelect { .. } => "TILES", AppMode::TileSelect { .. } => "TILES",
AppMode::ImportWizard => "IMPORT", AppMode::ImportWizard => "IMPORT",

View File

@ -18,6 +18,8 @@ pub enum AppMode {
FormulaEdit { buffer: String }, FormulaEdit { buffer: String },
FormulaPanel, FormulaPanel,
CategoryPanel, CategoryPanel,
/// Quick-add items to `category`: Enter adds and stays open, Esc closes.
ItemAdd { category: String, buffer: String },
ViewPanel, ViewPanel,
TileSelect { cat_idx: usize }, TileSelect { cat_idx: usize },
ImportWizard, ImportWizard,
@ -87,6 +89,7 @@ impl App {
AppMode::FormulaEdit { .. } => { self.handle_formula_edit_key(key)?; } AppMode::FormulaEdit { .. } => { self.handle_formula_edit_key(key)?; }
AppMode::FormulaPanel => { self.handle_formula_panel_key(key)?; } AppMode::FormulaPanel => { self.handle_formula_panel_key(key)?; }
AppMode::CategoryPanel => { self.handle_category_panel_key(key)?; } AppMode::CategoryPanel => { self.handle_category_panel_key(key)?; }
AppMode::ItemAdd { .. } => { self.handle_item_add_key(key)?; }
AppMode::ViewPanel => { self.handle_view_panel_key(key)?; } AppMode::ViewPanel => { self.handle_view_panel_key(key)?; }
AppMode::TileSelect { .. } => { self.handle_tile_select_key(key)?; } AppMode::TileSelect { .. } => { self.handle_tile_select_key(key)?; }
AppMode::ExportPrompt { .. } => { self.handle_export_key(key)?; } AppMode::ExportPrompt { .. } => { self.handle_export_key(key)?; }
@ -446,6 +449,26 @@ impl App {
self.dirty = true; self.dirty = true;
} }
} }
"add-items" | "items" => {
// :add-items <category> item1 item2 item3 ...
let mut parts = rest.splitn(2, char::is_whitespace);
let cat = parts.next().unwrap_or("").trim().to_string();
let items_str = parts.next().unwrap_or("").trim().to_string();
if cat.is_empty() || items_str.is_empty() {
self.status_msg = "Usage: :add-items <category> item1 item2 ...".to_string();
} else {
let items: Vec<&str> = items_str.split_whitespace().collect();
let count = items.len();
for item in &items {
command::dispatch(&mut self.model, &Command::AddItem {
category: cat.clone(),
item: item.to_string(),
});
}
self.status_msg = format!("Added {count} items to \"{cat}\".");
self.dirty = true;
}
}
"formula" | "add-formula" => { "formula" | "add-formula" => {
if rest.is_empty() { if rest.is_empty() {
self.formula_panel_open = true; self.formula_panel_open = true;
@ -594,6 +617,69 @@ impl App {
} }
} }
} }
// a / o — open quick-add mode for the selected category
KeyCode::Char('a') | KeyCode::Char('o') => {
if let Some(cat_name) = cat_names.get(self.cat_panel_cursor) {
self.mode = AppMode::ItemAdd {
category: cat_name.clone(),
buffer: String::new(),
};
} else {
self.status_msg = "No category selected. Add a category first with :add-cat <name>.".to_string();
}
}
_ => {}
}
Ok(())
}
fn handle_item_add_key(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
// Return to category panel
self.mode = AppMode::CategoryPanel;
self.status_msg = String::new();
}
KeyCode::Enter => {
let (cat, buf) = if let AppMode::ItemAdd { category, buffer } = &self.mode {
(category.clone(), buffer.trim().to_string())
} else { return Ok(()); };
if !buf.is_empty() {
let result = command::dispatch(&mut self.model, &Command::AddItem {
category: cat.clone(),
item: buf.clone(),
});
if result.ok {
let count = self.model.category(&cat).map(|c| c.items.len()).unwrap_or(0);
self.status_msg = format!("Added \"{buf}\"{count} items. Enter to add more, Esc to finish.");
self.dirty = true;
} else {
self.status_msg = result.message.unwrap_or_default();
}
}
// Clear buffer but stay in ItemAdd for next entry
if let AppMode::ItemAdd { ref mut buffer, .. } = self.mode {
buffer.clear();
}
}
KeyCode::Tab => {
// Tab completes the current item and moves to next, same as Enter
return self.handle_item_add_key(crossterm::event::KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
}
KeyCode::Char(c) => {
if let AppMode::ItemAdd { ref mut buffer, .. } = self.mode {
buffer.push(c);
}
}
KeyCode::Backspace => {
if let AppMode::ItemAdd { ref mut buffer, .. } = self.mode {
buffer.pop();
}
}
_ => {} _ => {}
} }
Ok(()) Ok(())
@ -927,7 +1013,8 @@ impl App {
AppMode::Editing { .. } => "Enter:commit Esc:cancel", AppMode::Editing { .. } => "Enter:commit Esc:cancel",
AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back", AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back",
AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression", AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
AppMode::CategoryPanel => "jk:nav Space:cycle-axis Esc:back", AppMode::CategoryPanel => "jk:nav Space:cycle-axis a:add-items Esc:back",
AppMode::ItemAdd { category, .. } => "Enter:add & continue Tab:same Esc:done",
AppMode::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back", AppMode::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back",
AppMode::TileSelect { .. } => "hl:select Enter:cycle r/c/p:set-axis Esc:back", AppMode::TileSelect { .. } => "hl:select Enter:cycle r/c/p:set-axis Esc:back",
AppMode::CommandMode { .. } => ":q quit :w save :import :add-cat :formula :help", AppMode::CommandMode { .. } => ":q quit :w save :import :add-cat :formula :help",

View File

@ -23,17 +23,21 @@ impl<'a> CategoryPanel<'a> {
impl<'a> Widget for CategoryPanel<'a> { impl<'a> Widget for CategoryPanel<'a> {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let is_active = matches!(self.mode, AppMode::CategoryPanel); let is_item_add = matches!(self.mode, AppMode::ItemAdd { .. });
let border_style = if is_active { let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add;
Style::default().fg(Color::Cyan)
let (border_color, title) = if is_item_add {
(Color::Green, " Categories — Adding items (Enter:add Esc:done) ")
} else if is_active {
(Color::Cyan, " Categories a:add-items Space:cycle-axis ")
} else { } else {
Style::default().fg(Color::DarkGray) (Color::DarkGray, " Categories ")
}; };
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(border_style) .border_style(Style::default().fg(border_color))
.title(" Categories [Enter] cycle axis "); .title(title);
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);
@ -45,13 +49,18 @@ impl<'a> Widget for CategoryPanel<'a> {
let cat_names: Vec<&str> = self.model.category_names(); let cat_names: Vec<&str> = self.model.category_names();
if cat_names.is_empty() { if cat_names.is_empty() {
buf.set_string(inner.x, inner.y, buf.set_string(inner.x, inner.y,
"(no categories)", "(no categories — use :add-cat <name>)",
Style::default().fg(Color::DarkGray)); Style::default().fg(Color::DarkGray));
return; return;
} }
// How many rows for the list vs the prompt at bottom
let prompt_rows = if is_item_add { 2u16 } else { 0 };
let list_height = inner.height.saturating_sub(prompt_rows);
for (i, cat_name) in cat_names.iter().enumerate() { for (i, cat_name) in cat_names.iter().enumerate() {
if inner.y + i as u16 >= inner.y + inner.height { break; } if i as u16 >= list_height { break; }
let y = inner.y + i as u16;
let axis = view.axis_of(cat_name); let axis = view.axis_of(cat_name);
let axis_str = match axis { let axis_str = match axis {
@ -67,31 +76,50 @@ impl<'a> Widget for CategoryPanel<'a> {
Axis::Unassigned => Color::DarkGray, Axis::Unassigned => Color::DarkGray,
}; };
let cat = self.model.category(cat_name); let item_count = self.model.category(cat_name).map(|c| c.items.len()).unwrap_or(0);
let item_count = cat.map(|c| c.items.len()).unwrap_or(0);
let is_selected = i == self.cursor && is_active; // Highlight the selected category both in CategoryPanel and ItemAdd modes
let base_style = if is_selected { let is_selected_cat = if is_item_add {
if let AppMode::ItemAdd { category, .. } = self.mode {
*cat_name == category.as_str()
} else { false }
} else {
i == self.cursor && is_active
};
let base_style = if is_selected_cat {
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD) Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
} else { } else {
Style::default() Style::default()
}; };
// Background fill for selected row if is_selected_cat {
if is_selected {
let fill = " ".repeat(inner.width as usize); let fill = " ".repeat(inner.width as usize);
buf.set_string(inner.x, inner.y + i as u16, &fill, base_style); buf.set_string(inner.x, y, &fill, base_style);
} }
let name_part = format!(" {cat_name} ({item_count})"); let name_part = format!(" {cat_name} ({item_count})");
let axis_part = format!(" [{axis_str}]"); let axis_part = format!(" [{axis_str}]");
let available = inner.width as usize;
buf.set_string(inner.x, inner.y + i as u16, &name_part, base_style); buf.set_string(inner.x, y, &name_part, base_style);
if name_part.len() + axis_part.len() < available { if name_part.len() + axis_part.len() < inner.width as usize {
let axis_x = inner.x + name_part.len() as u16; buf.set_string(inner.x + name_part.len() as u16, y, &axis_part,
buf.set_string(axis_x, inner.y + i as u16, &axis_part, if is_selected_cat { base_style } else { Style::default().fg(axis_color) });
if is_selected { base_style } else { Style::default().fg(axis_color) }); }
}
// Inline prompt at the bottom when in ItemAdd mode
if let AppMode::ItemAdd { category, buffer } = self.mode {
let sep_y = inner.y + list_height;
let prompt_y = sep_y + 1;
if sep_y < inner.y + inner.height {
let sep = "".repeat(inner.width as usize);
buf.set_string(inner.x, sep_y, &sep, Style::default().fg(Color::Green));
}
if prompt_y < inner.y + inner.height {
let prompt = format!(" + {buffer}");
buf.set_string(inner.x, prompt_y, &prompt,
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD));
} }
} }
} }

View File

@ -67,7 +67,8 @@ impl Widget for HelpWidget {
(" :import <path.json>", "Open JSON import wizard", key), (" :import <path.json>", "Open JSON import wizard", key),
(" :export [path.csv]", "Export active view to CSV", key), (" :export [path.csv]", "Export active view to CSV", key),
(" :add-cat <name>", "Add a category", key), (" :add-cat <name>", "Add a category", key),
(" :add-item <cat> <item>", "Add an item to a category", key), (" :add-item <cat> <item>", "Add one item to a category", key),
(" :add-items <cat> a b c…", "Add multiple items at once", key),
(" :formula <cat> <Name=expr>", "Add a formula", key), (" :formula <cat> <Name=expr>", "Add a formula", key),
(" :add-view [name]", "Create a new view", key), (" :add-view [name]", "Create a new view", key),
("", "", norm), ("", "", norm),