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:
@ -47,10 +47,35 @@ pub struct CmdContext<'a> {
|
|||||||
/// The display value at the cursor in records mode (including any
|
/// The display value at the cursor in records mode (including any
|
||||||
/// pending edit override). None for normal pivot views.
|
/// pending edit override). None for normal pivot views.
|
||||||
pub records_value: Option<String>,
|
pub records_value: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
/// The key that triggered this command
|
/// The key that triggered this command
|
||||||
pub key_code: KeyCode,
|
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<crate::ui::cat_tree::CatTreeEntry> {
|
||||||
|
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<String> {
|
||||||
|
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.
|
/// A command that reads state and produces effects.
|
||||||
pub trait Cmd: Debug + Send + Sync {
|
pub trait Cmd: Debug + Send + Sync {
|
||||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>>;
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>>;
|
||||||
@ -217,6 +242,8 @@ pub struct CursorState {
|
|||||||
pub col_count: usize,
|
pub col_count: usize,
|
||||||
pub row_offset: usize,
|
pub row_offset: usize,
|
||||||
pub col_offset: usize,
|
pub col_offset: usize,
|
||||||
|
pub visible_rows: usize,
|
||||||
|
pub visible_cols: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CursorState {
|
impl CursorState {
|
||||||
@ -228,6 +255,8 @@ impl CursorState {
|
|||||||
col_count: ctx.col_count,
|
col_count: ctx.col_count,
|
||||||
row_offset: ctx.row_offset,
|
row_offset: ctx.row_offset,
|
||||||
col_offset: ctx.col_offset,
|
col_offset: ctx.col_offset,
|
||||||
|
visible_rows: ctx.visible_rows,
|
||||||
|
visible_cols: ctx.visible_cols,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -449,6 +449,27 @@ impl KeymapSet {
|
|||||||
);
|
);
|
||||||
cp.bind(KeyCode::Char('a'), none, "open-item-add-at-cursor");
|
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('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));
|
set.insert(ModeKey::CategoryPanel, Arc::new(cp));
|
||||||
|
|
||||||
// ── View panel ───────────────────────────────────────────────────
|
// ── View panel ───────────────────────────────────────────────────
|
||||||
|
|||||||
@ -228,7 +228,12 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
if app.category_panel_open {
|
if app.category_panel_open {
|
||||||
let a = Rect::new(side.x, y, side.width, ph);
|
let a = Rect::new(side.x, y, side.width, ph);
|
||||||
f.render_widget(
|
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,
|
a,
|
||||||
);
|
);
|
||||||
y += ph;
|
y += ph;
|
||||||
|
|||||||
@ -91,6 +91,11 @@ pub struct App {
|
|||||||
/// when filters would change. Pending edits are stored alongside and
|
/// when filters would change. Pending edits are stored alongside and
|
||||||
/// applied to the model on commit/navigate-away.
|
/// applied to the model on commit/navigate-away.
|
||||||
pub drill_state: Option<DrillState>,
|
pub drill_state: Option<DrillState>,
|
||||||
|
/// 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<String>,
|
||||||
/// Named text buffers for text-entry modes
|
/// Named text buffers for text-entry modes
|
||||||
pub buffers: HashMap<String, String>,
|
pub buffers: HashMap<String, String>,
|
||||||
/// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.)
|
/// 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_back_stack: Vec::new(),
|
||||||
view_forward_stack: Vec::new(),
|
view_forward_stack: Vec::new(),
|
||||||
drill_state: None,
|
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(),
|
buffers: HashMap::new(),
|
||||||
transient_keymap: None,
|
transient_keymap: None,
|
||||||
keymap_set: KeymapSet::default_keymaps(),
|
keymap_set: KeymapSet::default_keymaps(),
|
||||||
@ -171,6 +179,14 @@ impl App {
|
|||||||
} else {
|
} else {
|
||||||
None
|
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,
|
key_code: key,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/ui/cat_tree.rs
Normal file
51
src/ui/cat_tree.rs
Normal file
@ -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<String>) -> Vec<CatTreeEntry> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ use ratatui::{
|
|||||||
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::cat_tree::{build_cat_tree, CatTreeEntry};
|
||||||
use crate::view::Axis;
|
use crate::view::Axis;
|
||||||
|
|
||||||
fn axis_display(axis: Axis) -> (&'static str, Color) {
|
fn axis_display(axis: Axis) -> (&'static str, Color) {
|
||||||
@ -22,14 +23,21 @@ pub struct CategoryPanel<'a> {
|
|||||||
pub model: &'a Model,
|
pub model: &'a Model,
|
||||||
pub mode: &'a AppMode,
|
pub mode: &'a AppMode,
|
||||||
pub cursor: usize,
|
pub cursor: usize,
|
||||||
|
pub expanded: &'a std::collections::HashSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> CategoryPanel<'a> {
|
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 {
|
Self {
|
||||||
model,
|
model,
|
||||||
mode,
|
mode,
|
||||||
cursor,
|
cursor,
|
||||||
|
expanded,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -40,18 +48,8 @@ impl<'a> Widget for CategoryPanel<'a> {
|
|||||||
let is_cat_add = matches!(self.mode, AppMode::CategoryAdd { .. });
|
let is_cat_add = matches!(self.mode, AppMode::CategoryAdd { .. });
|
||||||
let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add;
|
let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add;
|
||||||
|
|
||||||
let (border_color, title) = if is_cat_add {
|
let (border_color, title) = if is_active {
|
||||||
(
|
(Color::Cyan, " Categories n:new d:del Space:axis ")
|
||||||
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 ")
|
|
||||||
} else {
|
} else {
|
||||||
(Color::DarkGray, " Categories ")
|
(Color::DarkGray, " Categories ")
|
||||||
};
|
};
|
||||||
@ -64,9 +62,9 @@ impl<'a> Widget for CategoryPanel<'a> {
|
|||||||
block.render(area, buf);
|
block.render(area, buf);
|
||||||
|
|
||||||
let view = self.model.active_view();
|
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 tree.is_empty() {
|
||||||
if cat_names.is_empty() {
|
|
||||||
buf.set_string(
|
buf.set_string(
|
||||||
inner.x,
|
inner.x,
|
||||||
inner.y,
|
inner.y,
|
||||||
@ -76,36 +74,14 @@ impl<'a> Widget for CategoryPanel<'a> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// How many rows for the list vs the prompt at bottom
|
for (i, entry) in tree.iter().enumerate() {
|
||||||
let prompt_rows = if is_item_add { 2u16 } else { 0 };
|
if i as u16 >= inner.height {
|
||||||
let list_height = inner.height.saturating_sub(prompt_rows);
|
|
||||||
|
|
||||||
for (i, cat_name) in cat_names.iter().enumerate() {
|
|
||||||
if i as u16 >= list_height {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let y = inner.y + i as u16;
|
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 base_style = if is_selected {
|
||||||
|
|
||||||
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 {
|
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Black)
|
.fg(Color::Black)
|
||||||
.bg(Color::Cyan)
|
.bg(Color::Cyan)
|
||||||
@ -114,51 +90,41 @@ impl<'a> Widget for CategoryPanel<'a> {
|
|||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
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, y, &fill, base_style);
|
buf.set_string(inner.x, y, &fill, base_style);
|
||||||
}
|
}
|
||||||
|
|
||||||
let name_part = format!(" {cat_name} ({item_count})");
|
match entry {
|
||||||
let axis_part = format!(" [{axis_str}]");
|
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);
|
buf.set_string(inner.x, y, &name_part, base_style);
|
||||||
if name_part.len() + axis_part.len() < inner.width as usize {
|
if name_part.len() + axis_part.len() < inner.width as usize {
|
||||||
buf.set_string(
|
buf.set_string(
|
||||||
inner.x + name_part.len() as u16,
|
inner.x + name_part.len() as u16,
|
||||||
y,
|
y,
|
||||||
&axis_part,
|
&axis_part,
|
||||||
if is_selected_cat {
|
if is_selected {
|
||||||
base_style
|
base_style
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(axis_color)
|
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod cat_tree;
|
||||||
pub mod category_panel;
|
pub mod category_panel;
|
||||||
pub mod effect;
|
pub mod effect;
|
||||||
pub mod formula_panel;
|
pub mod formula_panel;
|
||||||
|
|||||||
Reference in New Issue
Block a user