From 5fe553b57a3dc8578ef2862dc3a3754f18ed8a39 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Mon, 6 Apr 2026 15:09:57 -0700 Subject: [PATCH] feat: add category tree with expand/collapse in category panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a tree-based category panel that supports expand/collapse of categories. Introduces CatTreeEntry and build_cat_tree to render categories as a collapsible tree. The category panel now displays categories with expand indicators (▶/▼) and shows items under expanded categories. CmdContext gains cat_tree_entry(), cat_at_cursor(), and cat_tree_len() methods to work with the tree. App tracks expanded_cats in a HashSet. Keymap updates: Enter in category panel now triggers filter-to-item. Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M) --- src/command/cmd.rs | 29 +++++++++ src/command/keymap.rs | 21 +++++++ src/draw.rs | 7 ++- src/ui/app.rs | 16 +++++ src/ui/cat_tree.rs | 51 ++++++++++++++++ src/ui/category_panel.rs | 126 ++++++++++++++------------------------- src/ui/mod.rs | 1 + 7 files changed, 170 insertions(+), 81 deletions(-) create mode 100644 src/ui/cat_tree.rs diff --git a/src/command/cmd.rs b/src/command/cmd.rs index ff82d89..49986e0 100644 --- a/src/command/cmd.rs +++ b/src/command/cmd.rs @@ -47,10 +47,35 @@ pub struct CmdContext<'a> { /// The display value at the cursor in records mode (including any /// pending edit override). None for normal pivot views. pub records_value: Option, + /// How many data rows/cols fit on screen (for viewport scrolling). + /// Defaults to generous fallbacks when unknown. + pub visible_rows: usize, + pub visible_cols: usize, + /// Expanded categories in the tree panel + pub expanded_cats: &'a std::collections::HashSet, /// The key that triggered this command pub key_code: KeyCode, } +impl<'a> CmdContext<'a> { + /// Resolve the category panel tree entry at the current cursor. + pub fn cat_tree_entry(&self) -> Option { + let tree = crate::ui::cat_tree::build_cat_tree(self.model, self.expanded_cats); + tree.into_iter().nth(self.cat_panel_cursor) + } + + /// The category name at the current tree cursor (whether on a + /// category header or an item). + pub fn cat_at_cursor(&self) -> Option { + self.cat_tree_entry().map(|e| e.cat_name().to_string()) + } + + /// Total number of entries in the category tree. + pub fn cat_tree_len(&self) -> usize { + crate::ui::cat_tree::build_cat_tree(self.model, self.expanded_cats).len() + } +} + /// A command that reads state and produces effects. pub trait Cmd: Debug + Send + Sync { fn execute(&self, ctx: &CmdContext) -> Vec>; @@ -217,6 +242,8 @@ pub struct CursorState { pub col_count: usize, pub row_offset: usize, pub col_offset: usize, + pub visible_rows: usize, + pub visible_cols: usize, } impl CursorState { @@ -228,6 +255,8 @@ impl CursorState { col_count: ctx.col_count, row_offset: ctx.row_offset, col_offset: ctx.col_offset, + visible_rows: ctx.visible_rows, + visible_cols: ctx.visible_cols, } } } diff --git a/src/command/keymap.rs b/src/command/keymap.rs index 62c3cff..ec2e7e7 100644 --- a/src/command/keymap.rs +++ b/src/command/keymap.rs @@ -449,6 +449,27 @@ impl KeymapSet { ); cp.bind(KeyCode::Char('a'), none, "open-item-add-at-cursor"); cp.bind(KeyCode::Char('o'), none, "open-item-add-at-cursor"); + cp.bind(KeyCode::Char('d'), none, "delete-category-at-cursor"); + cp.bind(KeyCode::Delete, none, "delete-category-at-cursor"); + // C/F/V in panel modes: close panel (toggle-panel-and-focus sees focused=true) + cp.bind_args( + KeyCode::Char('C'), + none, + "toggle-panel-and-focus", + vec!["category".into()], + ); + cp.bind_args( + KeyCode::Char('F'), + none, + "toggle-panel-and-focus", + vec!["formula".into()], + ); + cp.bind_args( + KeyCode::Char('V'), + none, + "toggle-panel-and-focus", + vec!["view".into()], + ); set.insert(ModeKey::CategoryPanel, Arc::new(cp)); // ── View panel ─────────────────────────────────────────────────── diff --git a/src/draw.rs b/src/draw.rs index a54d91d..63a71ad 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -228,7 +228,12 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) { if app.category_panel_open { let a = Rect::new(side.x, y, side.width, ph); f.render_widget( - CategoryPanel::new(&app.model, &app.mode, app.cat_panel_cursor), + CategoryPanel::new( + &app.model, + &app.mode, + app.cat_panel_cursor, + &app.expanded_cats, + ), a, ); y += ph; diff --git a/src/ui/app.rs b/src/ui/app.rs index 689418b..2b8e45e 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -91,6 +91,11 @@ pub struct App { /// when filters would change. Pending edits are stored alongside and /// applied to the model on commit/navigate-away. pub drill_state: Option, + /// Terminal dimensions (updated on resize and at startup). + pub term_width: u16, + pub term_height: u16, + /// Categories expanded in the category panel tree view. + pub expanded_cats: std::collections::HashSet, /// Named text buffers for text-entry modes pub buffers: HashMap, /// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.) @@ -121,6 +126,9 @@ impl App { view_back_stack: Vec::new(), view_forward_stack: Vec::new(), drill_state: None, + term_width: crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80), + term_height: crossterm::terminal::size().map(|(_, h)| h).unwrap_or(24), + expanded_cats: std::collections::HashSet::new(), buffers: HashMap::new(), transient_keymap: None, keymap_set: KeymapSet::default_keymaps(), @@ -171,6 +179,14 @@ impl App { } else { None }, + // Approximate visible rows/cols from terminal size. + // Chrome: title(1) + border(2) + col_headers(n_col_levels) + separator(1) + // + tile_bar(1) + status_bar(1) = ~8 rows of chrome. + visible_rows: (self.term_height as usize).saturating_sub(8), + // Visible cols depends on column widths — use a rough estimate. + // The grid renderer does the precise calculation. + visible_cols: ((self.term_width as usize).saturating_sub(30) / 12).max(1), + expanded_cats: &self.expanded_cats, key_code: key, } } diff --git a/src/ui/cat_tree.rs b/src/ui/cat_tree.rs new file mode 100644 index 0000000..6d589e9 --- /dev/null +++ b/src/ui/cat_tree.rs @@ -0,0 +1,51 @@ +use crate::model::Model; +use std::collections::HashSet; + +/// A flattened entry in the category panel tree. +#[derive(Debug, Clone)] +pub enum CatTreeEntry { + /// Category header row: name, item count, expanded? + Category { + name: String, + item_count: usize, + expanded: bool, + }, + /// Item row under a category + Item { cat_name: String, item_name: String }, +} + +impl CatTreeEntry { + /// The category this entry belongs to. + pub fn cat_name(&self) -> &str { + match self { + CatTreeEntry::Category { name, .. } => name, + CatTreeEntry::Item { cat_name, .. } => cat_name, + } + } +} + +/// Build the flattened tree of categories and their items. +pub fn build_cat_tree(model: &Model, expanded: &HashSet) -> Vec { + let mut entries = Vec::new(); + for cat_name in model.category_names() { + let cat = model.category(cat_name); + let item_count = cat.map(|c| c.items.len()).unwrap_or(0); + let is_expanded = expanded.contains(cat_name); + entries.push(CatTreeEntry::Category { + name: cat_name.to_string(), + item_count, + expanded: is_expanded, + }); + if is_expanded { + if let Some(cat) = cat { + for item_name in cat.ordered_item_names() { + entries.push(CatTreeEntry::Item { + cat_name: cat_name.to_string(), + item_name: item_name.to_string(), + }); + } + } + } + } + entries +} diff --git a/src/ui/category_panel.rs b/src/ui/category_panel.rs index d60d60f..687e67d 100644 --- a/src/ui/category_panel.rs +++ b/src/ui/category_panel.rs @@ -7,6 +7,7 @@ use ratatui::{ use crate::model::Model; use crate::ui::app::AppMode; +use crate::ui::cat_tree::{build_cat_tree, CatTreeEntry}; use crate::view::Axis; fn axis_display(axis: Axis) -> (&'static str, Color) { @@ -22,14 +23,21 @@ pub struct CategoryPanel<'a> { pub model: &'a Model, pub mode: &'a AppMode, pub cursor: usize, + pub expanded: &'a std::collections::HashSet, } impl<'a> CategoryPanel<'a> { - pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self { + pub fn new( + model: &'a Model, + mode: &'a AppMode, + cursor: usize, + expanded: &'a std::collections::HashSet, + ) -> Self { Self { model, mode, cursor, + expanded, } } } @@ -40,18 +48,8 @@ impl<'a> Widget for CategoryPanel<'a> { 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_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) ", - ) - } else if is_active { - (Color::Cyan, " Categories n:new a:add-items Space:axis ") + let (border_color, title) = if is_active { + (Color::Cyan, " Categories n:new d:del Space:axis ") } else { (Color::DarkGray, " Categories ") }; @@ -64,9 +62,9 @@ impl<'a> Widget for CategoryPanel<'a> { block.render(area, buf); let view = self.model.active_view(); + let tree = build_cat_tree(self.model, self.expanded); - let cat_names: Vec<&str> = self.model.category_names(); - if cat_names.is_empty() { + if tree.is_empty() { buf.set_string( inner.x, inner.y, @@ -76,36 +74,14 @@ impl<'a> Widget for CategoryPanel<'a> { 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 i as u16 >= list_height { + for (i, entry) in tree.iter().enumerate() { + if i as u16 >= inner.height { break; } let y = inner.y + i as u16; + let is_selected = i == self.cursor && is_active; - let (axis_str, axis_color) = axis_display(view.axis_of(cat_name)); - - let item_count = self - .model - .category(cat_name) - .map(|c| c.items.len()) - .unwrap_or(0); - - // 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 { + let base_style = if is_selected { Style::default() .fg(Color::Black) .bg(Color::Cyan) @@ -114,51 +90,41 @@ impl<'a> Widget for CategoryPanel<'a> { Style::default() }; - if is_selected_cat { + if is_selected { let fill = " ".repeat(inner.width as usize); buf.set_string(inner.x, y, &fill, base_style); } - let name_part = format!(" {cat_name} ({item_count})"); - let axis_part = format!(" [{axis_str}]"); + match entry { + CatTreeEntry::Category { + name, + item_count, + expanded, + } => { + let indicator = if *expanded { "▼" } else { "▶" }; + let (axis_str, axis_color) = axis_display(view.axis_of(name)); + let name_part = format!("{indicator} {name} ({item_count})"); + let axis_part = format!(" [{axis_str}]"); - 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) - }, - ); + 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 { + base_style + } else { + Style::default().fg(axis_color) + }, + ); + } + } + CatTreeEntry::Item { item_name, .. } => { + let label = format!(" · {item_name}"); + buf.set_string(inner.x, y, &label, base_style); + } } } - - // Inline prompt at the bottom for CategoryAdd or ItemAdd - let (prompt_color, prompt_text) = match self.mode { - AppMode::CategoryAdd { buffer } => (Color::Yellow, format!(" + category: {buffer}▌")), - AppMode::ItemAdd { buffer, .. } => (Color::Green, format!(" + item: {buffer}▌")), - _ => 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), - ); - } } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e1c466f..4bc76ec 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,5 @@ pub mod app; +pub mod cat_tree; pub mod category_panel; pub mod effect; pub mod formula_panel;