feat: add category tree with expand/collapse in category panel

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)
This commit is contained in:
Edward Langley
2026-04-06 15:09:57 -07:00
parent 0c04d63542
commit 5fe553b57a
7 changed files with 170 additions and 81 deletions

View File

@ -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<String>,
}
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<String>,
) -> 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),
);
}
}
}