From 4f322e53cd9b9382e326301042fde3a63e5931c4 Mon Sep 17 00:00:00 2001 From: Ed L Date: Sat, 21 Mar 2026 22:51:36 -0700 Subject: [PATCH] Add quick multi-item entry to categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new ways to add multiple items without repeating yourself: 1. :add-items 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 --- src/main.rs | 1 + src/ui/app.rs | 89 +++++++++++++++++++++++++++++++++++++++- src/ui/category_panel.rs | 70 +++++++++++++++++++++---------- src/ui/help.rs | 3 +- 4 files changed, 140 insertions(+), 23 deletions(-) diff --git a/src/main.rs b/src/main.rs index c2de6d6..45c4aaa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -286,6 +286,7 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) { AppMode::FormulaEdit { .. } => "FORMULA", AppMode::FormulaPanel => "FORMULAS", AppMode::CategoryPanel => "CATEGORIES", + AppMode::ItemAdd { .. } => "ADD ITEMS", AppMode::ViewPanel => "VIEWS", AppMode::TileSelect { .. } => "TILES", AppMode::ImportWizard => "IMPORT", diff --git a/src/ui/app.rs b/src/ui/app.rs index 9db1277..f0d72c5 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -18,6 +18,8 @@ pub enum AppMode { FormulaEdit { buffer: String }, FormulaPanel, CategoryPanel, + /// Quick-add items to `category`: Enter adds and stays open, Esc closes. + ItemAdd { category: String, buffer: String }, ViewPanel, TileSelect { cat_idx: usize }, ImportWizard, @@ -87,6 +89,7 @@ impl App { AppMode::FormulaEdit { .. } => { self.handle_formula_edit_key(key)?; } AppMode::FormulaPanel => { self.handle_formula_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::TileSelect { .. } => { self.handle_tile_select_key(key)?; } AppMode::ExportPrompt { .. } => { self.handle_export_key(key)?; } @@ -446,6 +449,26 @@ impl App { self.dirty = true; } } + "add-items" | "items" => { + // :add-items 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 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" => { if rest.is_empty() { 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 .".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(()) @@ -927,7 +1013,8 @@ impl App { AppMode::Editing { .. } => "Enter:commit Esc:cancel", AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back", 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::TileSelect { .. } => "hl:select Enter:cycle r/c/p:set-axis Esc:back", AppMode::CommandMode { .. } => ":q quit :w save :import :add-cat :formula :help", diff --git a/src/ui/category_panel.rs b/src/ui/category_panel.rs index b7f9c99..8b58b03 100644 --- a/src/ui/category_panel.rs +++ b/src/ui/category_panel.rs @@ -23,17 +23,21 @@ impl<'a> CategoryPanel<'a> { impl<'a> Widget for CategoryPanel<'a> { fn render(self, area: Rect, buf: &mut Buffer) { - let is_active = matches!(self.mode, AppMode::CategoryPanel); - let border_style = if is_active { - Style::default().fg(Color::Cyan) + let is_item_add = matches!(self.mode, AppMode::ItemAdd { .. }); + let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add; + + 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 { - Style::default().fg(Color::DarkGray) + (Color::DarkGray, " Categories ") }; let block = Block::default() .borders(Borders::ALL) - .border_style(border_style) - .title(" Categories [Enter] cycle axis "); + .border_style(Style::default().fg(border_color)) + .title(title); let inner = block.inner(area); block.render(area, buf); @@ -45,13 +49,18 @@ impl<'a> Widget for CategoryPanel<'a> { let cat_names: Vec<&str> = self.model.category_names(); if cat_names.is_empty() { buf.set_string(inner.x, inner.y, - "(no categories)", + "(no categories — use :add-cat )", Style::default().fg(Color::DarkGray)); 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() { - 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_str = match axis { @@ -67,31 +76,50 @@ impl<'a> Widget for CategoryPanel<'a> { Axis::Unassigned => Color::DarkGray, }; - let cat = self.model.category(cat_name); - let item_count = cat.map(|c| c.items.len()).unwrap_or(0); + let item_count = self.model.category(cat_name).map(|c| c.items.len()).unwrap_or(0); - let is_selected = i == self.cursor && is_active; - let base_style = if is_selected { + // Highlight the selected category both in CategoryPanel and ItemAdd modes + 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) } else { Style::default() }; - // Background fill for selected row - if is_selected { + if is_selected_cat { 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 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); - if name_part.len() + axis_part.len() < available { - let axis_x = inner.x + name_part.len() as u16; - buf.set_string(axis_x, inner.y + i as u16, &axis_part, - if is_selected { base_style } else { Style::default().fg(axis_color) }); + buf.set_string(inner.x, y, &name_part, base_style); + if name_part.len() + axis_part.len() < inner.width as usize { + buf.set_string(inner.x + name_part.len() as u16, y, &axis_part, + if is_selected_cat { 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)); } } } diff --git a/src/ui/help.rs b/src/ui/help.rs index 7e5fe10..82d94fb 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -67,7 +67,8 @@ impl Widget for HelpWidget { (" :import ", "Open JSON import wizard", key), (" :export [path.csv]", "Export active view to CSV", key), (" :add-cat ", "Add a category", key), - (" :add-item ", "Add an item to a category", key), + (" :add-item ", "Add one item to a category", key), + (" :add-items a b c…", "Add multiple items at once", key), (" :formula ", "Add a formula", key), (" :add-view [name]", "Create a new view", key), ("", "", norm),