Add quick-add mode for categories

N (from anywhere) or n (in Category panel) opens an inline prompt
to add categories one after another without typing :add-cat each time.

- Yellow border + prompt distinguishes it from item-add (green)
- Enter / Tab adds the category and clears the buffer, staying open
- Esc returns to the category list
- Cursor automatically moves to the newly added category

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ed L
2026-03-21 23:03:45 -07:00
parent 4f322e53cd
commit c9d1313072
5 changed files with 311 additions and 19 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::CategoryAdd { .. } => "NEW CATEGORY",
AppMode::ItemAdd { .. } => "ADD ITEMS", AppMode::ItemAdd { .. } => "ADD ITEMS",
AppMode::ViewPanel => "VIEWS", AppMode::ViewPanel => "VIEWS",
AppMode::TileSelect { .. } => "TILES", AppMode::TileSelect { .. } => "TILES",

View File

@ -18,6 +18,8 @@ pub enum AppMode {
FormulaEdit { buffer: String }, FormulaEdit { buffer: String },
FormulaPanel, FormulaPanel,
CategoryPanel, CategoryPanel,
/// Quick-add a new category: Enter adds and stays open, Esc closes.
CategoryAdd { buffer: String },
/// Quick-add items to `category`: Enter adds and stays open, Esc closes. /// Quick-add items to `category`: Enter adds and stays open, Esc closes.
ItemAdd { category: String, buffer: String }, ItemAdd { category: String, buffer: String },
ViewPanel, ViewPanel,
@ -89,6 +91,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::CategoryAdd { .. } => { self.handle_category_add_key(key)?; }
AppMode::ItemAdd { .. } => { self.handle_item_add_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)?; }
@ -266,6 +269,12 @@ impl App {
} }
} }
// N = quick-add a new category (opens Category panel in add mode)
(KeyCode::Char('N'), _) => {
self.category_panel_open = true;
self.mode = AppMode::CategoryAdd { buffer: String::new() };
}
// ── Tile movement ────────────────────────────────────────────── // ── Tile movement ──────────────────────────────────────────────
// T = enter tile select mode (single key, no Ctrl needed) // T = enter tile select mode (single key, no Ctrl needed)
(KeyCode::Char('T'), _) => { (KeyCode::Char('T'), _) => {
@ -617,7 +626,11 @@ impl App {
} }
} }
} }
// a / o — open quick-add mode for the selected category // n — add a new category
KeyCode::Char('n') => {
self.mode = AppMode::CategoryAdd { buffer: String::new() };
}
// a / o — open quick-add items mode for the selected category
KeyCode::Char('a') | KeyCode::Char('o') => { KeyCode::Char('a') | KeyCode::Char('o') => {
if let Some(cat_name) = cat_names.get(self.cat_panel_cursor) { if let Some(cat_name) = cat_names.get(self.cat_panel_cursor) {
self.mode = AppMode::ItemAdd { self.mode = AppMode::ItemAdd {
@ -625,7 +638,7 @@ impl App {
buffer: String::new(), buffer: String::new(),
}; };
} else { } else {
self.status_msg = "No category selected. Add a category first with :add-cat <name>.".to_string(); self.status_msg = "No category selected. Press n to add a category first.".to_string();
} }
} }
_ => {} _ => {}
@ -633,6 +646,45 @@ impl App {
Ok(()) Ok(())
} }
fn handle_category_add_key(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
self.mode = AppMode::CategoryPanel;
self.status_msg = String::new();
}
KeyCode::Enter | KeyCode::Tab => {
let buf = if let AppMode::CategoryAdd { buffer } = &self.mode {
buffer.trim().to_string()
} else { return Ok(()); };
if !buf.is_empty() {
let result = command::dispatch(&mut self.model, &Command::AddCategory { name: buf.clone() });
if result.ok {
// Move cursor to the new category
self.cat_panel_cursor = self.model.categories.len().saturating_sub(1);
let count = self.model.categories.len();
self.status_msg = format!("Added category \"{buf}\" ({count} total). Enter to add more, Esc to finish.");
self.dirty = true;
} else {
self.status_msg = result.message.unwrap_or_default();
}
}
// Stay in CategoryAdd for the next entry
if let AppMode::CategoryAdd { ref mut buffer } = self.mode {
buffer.clear();
}
}
KeyCode::Char(c) => {
if let AppMode::CategoryAdd { ref mut buffer } = self.mode { buffer.push(c); }
}
KeyCode::Backspace => {
if let AppMode::CategoryAdd { ref mut buffer } = self.mode { buffer.pop(); }
}
_ => {}
}
Ok(())
}
fn handle_item_add_key(&mut self, key: KeyEvent) -> Result<()> { fn handle_item_add_key(&mut self, key: KeyEvent) -> Result<()> {
match key.code { match key.code {
KeyCode::Esc => { KeyCode::Esc => {
@ -1013,8 +1065,9 @@ 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 a:add-items Esc:back", AppMode::CategoryPanel => "jk:nav Space:cycle-axis n:new-cat a:add-items Esc:back",
AppMode::ItemAdd { category, .. } => "Enter:add & continue Tab:same Esc:done", AppMode::CategoryAdd { .. } => "Enter:add & continue Tab:same Esc:done — type a category name",
AppMode::ItemAdd { .. } => "Enter:add & continue Tab:same Esc:done — type an item name",
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

@ -24,12 +24,15 @@ 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_item_add = matches!(self.mode, AppMode::ItemAdd { .. }); let is_item_add = matches!(self.mode, AppMode::ItemAdd { .. });
let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add; let is_cat_add = matches!(self.mode, AppMode::CategoryAdd { .. });
let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add;
let (border_color, title) = if is_item_add { let (border_color, title) = if is_cat_add {
(Color::Yellow, " Categories — New category (Enter:add Esc:done) ")
} else if is_item_add {
(Color::Green, " Categories — Adding items (Enter:add Esc:done) ") (Color::Green, " Categories — Adding items (Enter:add Esc:done) ")
} else if is_active { } else if is_active {
(Color::Cyan, " Categories a:add-items Space:cycle-axis ") (Color::Cyan, " Categories n:new a:add-items Space:axis ")
} else { } else {
(Color::DarkGray, " Categories ") (Color::DarkGray, " Categories ")
}; };
@ -108,19 +111,26 @@ impl<'a> Widget for CategoryPanel<'a> {
} }
} }
// Inline prompt at the bottom when in ItemAdd mode // Inline prompt at the bottom for CategoryAdd or ItemAdd
if let AppMode::ItemAdd { category, buffer } = self.mode { let (prompt_color, prompt_text) = match self.mode {
let sep_y = inner.y + list_height; AppMode::CategoryAdd { buffer } => {
let prompt_y = sep_y + 1; (Color::Yellow, format!(" + category: {buffer}"))
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 { AppMode::ItemAdd { buffer, .. } => {
let prompt = format!(" + {buffer}"); (Color::Green, format!(" + item: {buffer}"))
buf.set_string(inner.x, prompt_y, &prompt,
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD));
} }
_ => return,
};
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(prompt_color));
}
if prompt_y < inner.y + inner.height {
buf.set_string(inner.x, prompt_y, &prompt_text,
Style::default().fg(prompt_color).add_modifier(Modifier::BOLD));
} }
} }
} }

View File

@ -51,7 +51,8 @@ impl Widget for HelpWidget {
("", "", norm), ("", "", norm),
("Panels", "", head), ("Panels", "", head),
(" F", "Toggle Formula panel (n:new d:del)", key), (" F", "Toggle Formula panel (n:new d:del)", key),
(" C", "Toggle Category panel (Space:cycle-axis)", key), (" C", "Toggle Category panel (n:new-cat a:add-items)", key),
(" N", "New category quick-add (from anywhere)", key),
(" V", "Toggle View panel (n:new d:del Enter:switch)", key), (" V", "Toggle View panel (n:new d:del Enter:switch)", key),
(" Tab", "Focus next open panel", key), (" Tab", "Focus next open panel", key),
("", "", norm), ("", "", norm),

227
whatever.improv Normal file
View File

@ -0,0 +1,227 @@
{
"name": "New Model",
"categories": {
"Type": {
"id": 0,
"name": "Type",
"items": {
"Food": {
"id": 0,
"name": "Food",
"group": null
},
"Clothing": {
"id": 1,
"name": "Clothing",
"group": null
},
"Gas": {
"id": 2,
"name": "Gas",
"group": null
},
"Medical": {
"id": 3,
"name": "Medical",
"group": null
}
},
"groups": [],
"next_item_id": 4
},
"Month": {
"id": 1,
"name": "Month",
"items": {
"Janury": {
"id": 0,
"name": "Janury",
"group": null
},
"February": {
"id": 1,
"name": "February",
"group": null
},
"March": {
"id": 2,
"name": "March",
"group": null
}
},
"groups": [],
"next_item_id": 3
},
"Recipient": {
"id": 2,
"name": "Recipient",
"items": {
"Bob": {
"id": 0,
"name": "Bob",
"group": null
},
"Joe": {
"id": 1,
"name": "Joe",
"group": null
},
"Mary": {
"id": 2,
"name": "Mary",
"group": null
},
"Jane": {
"id": 3,
"name": "Jane",
"group": null
},
"Will": {
"id": 4,
"name": "Will",
"group": null
}
},
"groups": [],
"next_item_id": 5
},
"Payer": {
"id": 3,
"name": "Payer",
"items": {
"Bernadette": {
"id": 0,
"name": "Bernadette",
"group": null
},
"Ed": {
"id": 1,
"name": "Ed",
"group": null
}
},
"groups": [],
"next_item_id": 2
}
},
"data": [
[
[
[
"Month",
"Janury"
],
[
"Payer",
"Bernadette"
],
[
"Recipient",
"Bob"
],
[
"Type",
"Clothing"
]
],
{
"Number": 3.0
}
],
[
[
[
"Month",
"Janury"
],
[
"Payer",
"Bernadette"
],
[
"Recipient",
"Bob"
],
[
"Type",
"Food"
]
],
{
"Number": 12.0
}
],
[
[
[
"Month",
"Janury"
],
[
"Payer",
"Bernadette"
],
[
"Recipient",
"Bob"
],
[
"Type",
"Gas"
]
],
{
"Number": 4.0
}
],
[
[
[
"Month",
"Janury"
],
[
"Payer",
"Bernadette"
],
[
"Recipient",
"Bob"
],
[
"Type",
"Medical"
]
],
{
"Number": 5.0
}
]
],
"formulas": [],
"views": {
"Default": {
"name": "Default",
"category_axes": {
"Type": "Row",
"Month": "Column",
"Recipient": "Page",
"Payer": "Page"
},
"page_selections": {
"Recipient": "Bob"
},
"hidden_items": {},
"collapsed_groups": {},
"number_format": ",.0",
"row_offset": 0,
"col_offset": 0,
"selected": [
1,
1
]
}
},
"active_view": "Default",
"next_category_id": 4
}