Compare commits
15 Commits
v0.1.0-rc1
...
334597d825
| Author | SHA1 | Date | |
|---|---|---|---|
| 334597d825 | |||
| 9329f04082 | |||
| 631067b011 | |||
| bd5dcfe1f7 | |||
| 0249afe33d | |||
| 132f017c79 | |||
| f1a777670f | |||
| 92f351bce3 | |||
| 6f4bc5e798 | |||
| 8e0c06d888 | |||
| 32677141de | |||
| ecc2987963 | |||
| 6d5138d904 | |||
| def3902eb9 | |||
| 9d88ad3205 |
+352
-53
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,21 +267,25 @@ fn viewport_effects(
|
|||||||
nc: usize,
|
nc: usize,
|
||||||
old_row_offset: usize,
|
old_row_offset: usize,
|
||||||
old_col_offset: usize,
|
old_col_offset: usize,
|
||||||
|
visible_rows: usize,
|
||||||
|
visible_cols: usize,
|
||||||
) -> Vec<Box<dyn Effect>> {
|
) -> Vec<Box<dyn Effect>> {
|
||||||
let mut effects: Vec<Box<dyn Effect>> = vec![effect::set_selected(nr, nc)];
|
let mut effects: Vec<Box<dyn Effect>> = vec![effect::set_selected(nr, nc)];
|
||||||
let mut row_offset = old_row_offset;
|
let mut row_offset = old_row_offset;
|
||||||
let mut col_offset = old_col_offset;
|
let mut col_offset = old_col_offset;
|
||||||
|
let vr = visible_rows.max(1);
|
||||||
|
let vc = visible_cols.max(1);
|
||||||
if nr < row_offset {
|
if nr < row_offset {
|
||||||
row_offset = nr;
|
row_offset = nr;
|
||||||
}
|
}
|
||||||
if nr >= row_offset + 20 {
|
if nr >= row_offset + vr {
|
||||||
row_offset = nr.saturating_sub(19);
|
row_offset = nr.saturating_sub(vr - 1);
|
||||||
}
|
}
|
||||||
if nc < col_offset {
|
if nc < col_offset {
|
||||||
col_offset = nc;
|
col_offset = nc;
|
||||||
}
|
}
|
||||||
if nc >= col_offset + 8 {
|
if nc >= col_offset + vc {
|
||||||
col_offset = nc.saturating_sub(7);
|
col_offset = nc.saturating_sub(vc - 1);
|
||||||
}
|
}
|
||||||
if row_offset != old_row_offset {
|
if row_offset != old_row_offset {
|
||||||
effects.push(Box::new(effect::SetRowOffset(row_offset)));
|
effects.push(Box::new(effect::SetRowOffset(row_offset)));
|
||||||
@@ -280,7 +313,7 @@ impl Cmd for MoveSelection {
|
|||||||
let col_max = self.cursor.col_count.saturating_sub(1);
|
let col_max = self.cursor.col_count.saturating_sub(1);
|
||||||
let nr = (self.cursor.row as i32 + self.dr).clamp(0, row_max as i32) as usize;
|
let nr = (self.cursor.row as i32 + self.dr).clamp(0, row_max as i32) as usize;
|
||||||
let nc = (self.cursor.col as i32 + self.dc).clamp(0, col_max as i32) as usize;
|
let nc = (self.cursor.col as i32 + self.dc).clamp(0, col_max as i32) as usize;
|
||||||
viewport_effects(nr, nc, self.cursor.row_offset, self.cursor.col_offset)
|
viewport_effects(nr, nc, self.cursor.row_offset, self.cursor.col_offset, self.cursor.visible_rows, self.cursor.visible_cols)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,25 +576,28 @@ impl Cmd for EnterSearchMode {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct TogglePanelAndFocus {
|
pub struct TogglePanelAndFocus {
|
||||||
pub panel: Panel,
|
pub panel: Panel,
|
||||||
pub currently_open: bool,
|
pub open: bool,
|
||||||
|
pub focused: bool,
|
||||||
}
|
}
|
||||||
impl Cmd for TogglePanelAndFocus {
|
impl Cmd for TogglePanelAndFocus {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"toggle-panel-and-focus"
|
"toggle-panel-and-focus"
|
||||||
}
|
}
|
||||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
let new_open = !self.currently_open;
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||||
let mut effects: Vec<Box<dyn Effect>> = vec![Box::new(effect::SetPanelOpen {
|
effects.push(Box::new(effect::SetPanelOpen {
|
||||||
panel: self.panel,
|
panel: self.panel,
|
||||||
open: new_open,
|
open: self.open,
|
||||||
})];
|
}));
|
||||||
if new_open {
|
if self.focused {
|
||||||
let mode = match self.panel {
|
let mode = match self.panel {
|
||||||
Panel::Formula => AppMode::FormulaPanel,
|
Panel::Formula => AppMode::FormulaPanel,
|
||||||
Panel::Category => AppMode::CategoryPanel,
|
Panel::Category => AppMode::CategoryPanel,
|
||||||
Panel::View => AppMode::ViewPanel,
|
Panel::View => AppMode::ViewPanel,
|
||||||
};
|
};
|
||||||
effects.push(effect::change_mode(mode));
|
effects.push(effect::change_mode(mode));
|
||||||
|
} else {
|
||||||
|
effects.push(effect::change_mode(AppMode::Normal));
|
||||||
}
|
}
|
||||||
effects
|
effects
|
||||||
}
|
}
|
||||||
@@ -643,7 +679,16 @@ impl Cmd for EditOrDrill {
|
|||||||
"edit-or-drill"
|
"edit-or-drill"
|
||||||
}
|
}
|
||||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
let is_aggregated = ctx.records_col.is_none() && !ctx.none_cats.is_empty();
|
// Only consider regular (non-virtual, non-label) categories on None
|
||||||
|
// as true aggregation. Virtuals like _Index/_Dim are always None in
|
||||||
|
// pivot mode and don't imply aggregation.
|
||||||
|
let regular_none = ctx.none_cats.iter().any(|c| {
|
||||||
|
ctx.model
|
||||||
|
.category(c)
|
||||||
|
.map(|cat| cat.kind.is_regular())
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
let is_aggregated = ctx.records_col.is_none() && regular_none;
|
||||||
if is_aggregated {
|
if is_aggregated {
|
||||||
let Some(key) = ctx.cell_key.clone() else {
|
let Some(key) = ctx.cell_key.clone() else {
|
||||||
return vec![effect::set_status(
|
return vec![effect::set_status(
|
||||||
@@ -667,6 +712,45 @@ impl Cmd for EditOrDrill {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// In records mode, add a new row with an empty value. The new cell gets
|
||||||
|
/// coords from the current page filters. In pivot mode, this is a no-op.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AddRecordRow;
|
||||||
|
impl Cmd for AddRecordRow {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"add-record-row"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
if ctx.records_col.is_none() {
|
||||||
|
return vec![effect::set_status("add-record-row only works in records mode")];
|
||||||
|
}
|
||||||
|
// Build a CellKey from the current page filters
|
||||||
|
let view = ctx.model.active_view();
|
||||||
|
let page_cats: Vec<String> = view
|
||||||
|
.categories_on(crate::view::Axis::Page)
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
let coords: Vec<(String, String)> = page_cats
|
||||||
|
.iter()
|
||||||
|
.map(|cat| {
|
||||||
|
let sel = view
|
||||||
|
.page_selection(cat)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
(cat.clone(), sel)
|
||||||
|
})
|
||||||
|
.filter(|(_, v)| !v.is_empty())
|
||||||
|
.collect();
|
||||||
|
let key = crate::model::cell::CellKey::new(coords);
|
||||||
|
vec![
|
||||||
|
Box::new(effect::SetCell(key, CellValue::Number(0.0))),
|
||||||
|
effect::mark_dirty(),
|
||||||
|
effect::set_status("Added new record row"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Typewriter-style advance: move down, wrap to top of next column at bottom.
|
/// Typewriter-style advance: move down, wrap to top of next column at bottom.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct EnterAdvance {
|
pub struct EnterAdvance {
|
||||||
@@ -687,7 +771,7 @@ impl Cmd for EnterAdvance {
|
|||||||
} else {
|
} else {
|
||||||
(r, c) // already at bottom-right; stay
|
(r, c) // already at bottom-right; stay
|
||||||
};
|
};
|
||||||
viewport_effects(nr, nc, self.cursor.row_offset, self.cursor.col_offset)
|
viewport_effects(nr, nc, self.cursor.row_offset, self.cursor.col_offset, self.cursor.visible_rows, self.cursor.visible_cols)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1241,9 +1325,8 @@ impl Cmd for CycleAxisAtCursor {
|
|||||||
"cycle-axis-at-cursor"
|
"cycle-axis-at-cursor"
|
||||||
}
|
}
|
||||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
let cat_names = ctx.model.category_names();
|
if let Some(cat_name) = ctx.cat_at_cursor() {
|
||||||
if let Some(cat_name) = cat_names.get(ctx.cat_panel_cursor) {
|
vec![Box::new(effect::CycleAxis(cat_name))]
|
||||||
vec![Box::new(effect::CycleAxis(cat_name.to_string()))]
|
|
||||||
} else {
|
} else {
|
||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
@@ -1258,10 +1341,9 @@ impl Cmd for OpenItemAddAtCursor {
|
|||||||
"open-item-add-at-cursor"
|
"open-item-add-at-cursor"
|
||||||
}
|
}
|
||||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
let cat_names = ctx.model.category_names();
|
if let Some(cat_name) = ctx.cat_at_cursor() {
|
||||||
if let Some(cat_name) = cat_names.get(ctx.cat_panel_cursor) {
|
|
||||||
vec![effect::change_mode(AppMode::ItemAdd {
|
vec![effect::change_mode(AppMode::ItemAdd {
|
||||||
category: cat_name.to_string(),
|
category: cat_name,
|
||||||
buffer: String::new(),
|
buffer: String::new(),
|
||||||
})]
|
})]
|
||||||
} else {
|
} else {
|
||||||
@@ -1272,6 +1354,187 @@ impl Cmd for OpenItemAddAtCursor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Toggle expand/collapse of the category at the tree cursor.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ToggleCatExpand;
|
||||||
|
impl Cmd for ToggleCatExpand {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"toggle-cat-expand"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
if let Some(cat_name) = ctx.cat_at_cursor() {
|
||||||
|
vec![Box::new(effect::ToggleCatExpand(cat_name))]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter to item: when on an item row, set the category to Page with the
|
||||||
|
/// item as the filter value.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct FilterToItem;
|
||||||
|
impl Cmd for FilterToItem {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"filter-to-item"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
use crate::ui::cat_tree::CatTreeEntry;
|
||||||
|
match ctx.cat_tree_entry() {
|
||||||
|
Some(CatTreeEntry::Item {
|
||||||
|
cat_name,
|
||||||
|
item_name,
|
||||||
|
}) => {
|
||||||
|
vec![
|
||||||
|
Box::new(effect::SetAxis {
|
||||||
|
category: cat_name.clone(),
|
||||||
|
axis: crate::view::Axis::Page,
|
||||||
|
}),
|
||||||
|
Box::new(effect::SetPageSelection {
|
||||||
|
category: cat_name.clone(),
|
||||||
|
item: item_name.clone(),
|
||||||
|
}),
|
||||||
|
effect::set_status(format!("Filter: {cat_name} = {item_name}")),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Some(CatTreeEntry::Category { .. }) => {
|
||||||
|
// On a category header — toggle expand instead
|
||||||
|
ToggleCatExpand.execute(ctx)
|
||||||
|
}
|
||||||
|
None => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle pruning of empty rows/columns in the current view.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TogglePruneEmpty;
|
||||||
|
impl Cmd for TogglePruneEmpty {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"toggle-prune-empty"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let currently_on = ctx.model.active_view().prune_empty;
|
||||||
|
vec![
|
||||||
|
Box::new(effect::TogglePruneEmpty),
|
||||||
|
effect::set_status(if currently_on {
|
||||||
|
"Showing all rows/columns"
|
||||||
|
} else {
|
||||||
|
"Hiding empty rows/columns"
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle between records mode (_Index on Row, _Dim on Column) and
|
||||||
|
/// pivot mode (auto-assigned axes). In records mode every cell is shown
|
||||||
|
/// as a flat row; in pivot mode the view is a cross-tab.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ToggleRecordsMode;
|
||||||
|
impl Cmd for ToggleRecordsMode {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"toggle-records-mode"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
use crate::view::Axis;
|
||||||
|
let view = ctx.model.active_view();
|
||||||
|
|
||||||
|
// Detect current state
|
||||||
|
let is_records = view
|
||||||
|
.category_axes
|
||||||
|
.get("_Index")
|
||||||
|
.copied()
|
||||||
|
== Some(Axis::Row)
|
||||||
|
&& view.category_axes.get("_Dim").copied() == Some(Axis::Column);
|
||||||
|
|
||||||
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||||
|
|
||||||
|
if is_records {
|
||||||
|
// Switch back to pivot: auto-assign axes
|
||||||
|
// First regular category → Row, second → Column, rest → Page,
|
||||||
|
// virtuals/labels → None.
|
||||||
|
let mut row_done = false;
|
||||||
|
let mut col_done = false;
|
||||||
|
for (name, cat) in &ctx.model.categories {
|
||||||
|
let axis = if !cat.kind.is_regular() {
|
||||||
|
Axis::None
|
||||||
|
} else if !row_done {
|
||||||
|
row_done = true;
|
||||||
|
Axis::Row
|
||||||
|
} else if !col_done {
|
||||||
|
col_done = true;
|
||||||
|
Axis::Column
|
||||||
|
} else {
|
||||||
|
Axis::Page
|
||||||
|
};
|
||||||
|
effects.push(Box::new(effect::SetAxis {
|
||||||
|
category: name.clone(),
|
||||||
|
axis,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
effects.push(effect::set_status("Pivot mode"));
|
||||||
|
} else {
|
||||||
|
// Switch to records mode
|
||||||
|
effects.push(Box::new(effect::SetAxis {
|
||||||
|
category: "_Index".to_string(),
|
||||||
|
axis: Axis::Row,
|
||||||
|
}));
|
||||||
|
effects.push(Box::new(effect::SetAxis {
|
||||||
|
category: "_Dim".to_string(),
|
||||||
|
axis: Axis::Column,
|
||||||
|
}));
|
||||||
|
// Everything else → None
|
||||||
|
for name in ctx.model.categories.keys() {
|
||||||
|
if name != "_Index" && name != "_Dim" {
|
||||||
|
effects.push(Box::new(effect::SetAxis {
|
||||||
|
category: name.clone(),
|
||||||
|
axis: Axis::None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
effects.push(effect::set_status("Records mode"));
|
||||||
|
}
|
||||||
|
effects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the category or item at the panel cursor.
|
||||||
|
/// On a category header → delete the whole category.
|
||||||
|
/// On an item row → delete just that item.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DeleteCategoryAtCursor;
|
||||||
|
impl Cmd for DeleteCategoryAtCursor {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"delete-category-at-cursor"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
use crate::ui::cat_tree::CatTreeEntry;
|
||||||
|
match ctx.cat_tree_entry() {
|
||||||
|
Some(CatTreeEntry::Category { name, .. }) => {
|
||||||
|
vec![
|
||||||
|
Box::new(effect::RemoveCategory(name.clone())),
|
||||||
|
effect::mark_dirty(),
|
||||||
|
effect::set_status(format!("Deleted category '{name}'")),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Some(CatTreeEntry::Item {
|
||||||
|
cat_name,
|
||||||
|
item_name,
|
||||||
|
}) => {
|
||||||
|
vec![
|
||||||
|
Box::new(effect::RemoveItem {
|
||||||
|
category: cat_name.clone(),
|
||||||
|
item: item_name.clone(),
|
||||||
|
}),
|
||||||
|
effect::mark_dirty(),
|
||||||
|
effect::set_status(format!("Deleted item '{item_name}' from '{cat_name}'")),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
None => vec![effect::set_status("No category to delete")],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── View panel commands ─────────────────────────────────────────────────────
|
// ── View panel commands ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Switch to the view at the panel cursor and return to Normal mode.
|
/// Switch to the view at the panel cursor and return to Normal mode.
|
||||||
@@ -1704,12 +1967,13 @@ impl Cmd for CommitCellEdit {
|
|||||||
)));
|
)));
|
||||||
effects.push(effect::mark_dirty());
|
effects.push(effect::mark_dirty());
|
||||||
}
|
}
|
||||||
effects.push(effect::change_mode(AppMode::Normal));
|
// Advance cursor down (typewriter-style) and re-enter edit mode
|
||||||
// Advance cursor down (typewriter-style)
|
// at the new cell so the user can continue data entry.
|
||||||
let adv = EnterAdvance {
|
let adv = EnterAdvance {
|
||||||
cursor: CursorState::from_ctx(ctx),
|
cursor: CursorState::from_ctx(ctx),
|
||||||
};
|
};
|
||||||
effects.extend(adv.execute(ctx));
|
effects.extend(adv.execute(ctx));
|
||||||
|
effects.push(Box::new(effect::EnterEditAtCursor));
|
||||||
effects
|
effects
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1755,18 +2019,19 @@ impl Cmd for CommitCategoryAdd {
|
|||||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
let buf = ctx.buffers.get("category").cloned().unwrap_or_default();
|
let buf = ctx.buffers.get("category").cloned().unwrap_or_default();
|
||||||
let trimmed = buf.trim().to_string();
|
let trimmed = buf.trim().to_string();
|
||||||
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
if trimmed.is_empty() {
|
||||||
if !trimmed.is_empty() {
|
// Empty → exit category-add mode
|
||||||
effects.push(Box::new(effect::AddCategory(trimmed.clone())));
|
return vec![effect::change_mode(AppMode::CategoryPanel)];
|
||||||
effects.push(effect::mark_dirty());
|
|
||||||
effects.push(effect::set_status(format!("Added category \"{trimmed}\"")));
|
|
||||||
}
|
}
|
||||||
// Clear buffer for next entry
|
vec![
|
||||||
effects.push(Box::new(effect::SetBuffer {
|
Box::new(effect::AddCategory(trimmed.clone())),
|
||||||
|
effect::mark_dirty(),
|
||||||
|
effect::set_status(format!("Added category \"{trimmed}\"")),
|
||||||
|
Box::new(effect::SetBuffer {
|
||||||
name: "category".to_string(),
|
name: "category".to_string(),
|
||||||
value: String::new(),
|
value: String::new(),
|
||||||
}));
|
}),
|
||||||
effects
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1780,27 +2045,27 @@ impl Cmd for CommitItemAdd {
|
|||||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
let buf = ctx.buffers.get("item").cloned().unwrap_or_default();
|
let buf = ctx.buffers.get("item").cloned().unwrap_or_default();
|
||||||
let trimmed = buf.trim().to_string();
|
let trimmed = buf.trim().to_string();
|
||||||
// Get the category from the mode
|
if trimmed.is_empty() {
|
||||||
|
// Empty → exit item-add mode
|
||||||
|
return vec![effect::change_mode(AppMode::CategoryPanel)];
|
||||||
|
}
|
||||||
let category = if let AppMode::ItemAdd { category, .. } = ctx.mode {
|
let category = if let AppMode::ItemAdd { category, .. } = ctx.mode {
|
||||||
category.clone()
|
category.clone()
|
||||||
} else {
|
} else {
|
||||||
return vec![];
|
return vec![];
|
||||||
};
|
};
|
||||||
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
vec![
|
||||||
if !trimmed.is_empty() {
|
Box::new(effect::AddItem {
|
||||||
effects.push(Box::new(effect::AddItem {
|
|
||||||
category,
|
category,
|
||||||
item: trimmed.clone(),
|
item: trimmed.clone(),
|
||||||
}));
|
}),
|
||||||
effects.push(effect::mark_dirty());
|
effect::mark_dirty(),
|
||||||
effects.push(effect::set_status(format!("Added \"{trimmed}\"")));
|
effect::set_status(format!("Added \"{trimmed}\"")),
|
||||||
}
|
Box::new(effect::SetBuffer {
|
||||||
// Clear buffer for next entry
|
|
||||||
effects.push(Box::new(effect::SetBuffer {
|
|
||||||
name: "item".to_string(),
|
name: "item".to_string(),
|
||||||
value: String::new(),
|
value: String::new(),
|
||||||
}));
|
}),
|
||||||
effects
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2201,6 +2466,8 @@ pub fn default_registry() -> CmdRegistry {
|
|||||||
col_count: 0,
|
col_count: 0,
|
||||||
row_offset: 0,
|
row_offset: 0,
|
||||||
col_offset: 0,
|
col_offset: 0,
|
||||||
|
visible_rows: 20,
|
||||||
|
visible_cols: 8,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
@@ -2281,6 +2548,8 @@ pub fn default_registry() -> CmdRegistry {
|
|||||||
col_count: 0,
|
col_count: 0,
|
||||||
row_offset: 0,
|
row_offset: 0,
|
||||||
col_offset: 0,
|
col_offset: 0,
|
||||||
|
visible_rows: 20,
|
||||||
|
visible_cols: 8,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
@@ -2304,6 +2573,8 @@ pub fn default_registry() -> CmdRegistry {
|
|||||||
col_count: 0,
|
col_count: 0,
|
||||||
row_offset: 0,
|
row_offset: 0,
|
||||||
col_offset: 0,
|
col_offset: 0,
|
||||||
|
visible_rows: 20,
|
||||||
|
visible_cols: 8,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
@@ -2439,26 +2710,42 @@ pub fn default_registry() -> CmdRegistry {
|
|||||||
|
|
||||||
// ── Panel operations ─────────────────────────────────────────────────
|
// ── Panel operations ─────────────────────────────────────────────────
|
||||||
r.register(
|
r.register(
|
||||||
&TogglePanelAndFocus { panel: Panel::Formula, currently_open: false },
|
&TogglePanelAndFocus { panel: Panel::Formula, open: true, focused: true },
|
||||||
|args| {
|
|args| {
|
||||||
|
// Parse: toggle-panel-and-focus <panel> [open] [focused]
|
||||||
require_args("toggle-panel-and-focus", args, 1)?;
|
require_args("toggle-panel-and-focus", args, 1)?;
|
||||||
let panel = parse_panel(&args[0])?;
|
let panel = parse_panel(&args[0])?;
|
||||||
|
let open = args.get(1).map(|s| s == "true").unwrap_or(true);
|
||||||
|
let focused = args.get(2).map(|s| s == "true").unwrap_or(open);
|
||||||
Ok(Box::new(TogglePanelAndFocus {
|
Ok(Box::new(TogglePanelAndFocus {
|
||||||
panel,
|
panel,
|
||||||
currently_open: false,
|
open,
|
||||||
|
focused,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|args, ctx| {
|
|args, ctx| {
|
||||||
require_args("toggle-panel-and-focus", args, 1)?;
|
require_args("toggle-panel-and-focus", args, 1)?;
|
||||||
let panel = parse_panel(&args[0])?;
|
let panel = parse_panel(&args[0])?;
|
||||||
|
// Default interactive: if already open+focused → close, else open+focus
|
||||||
let currently_open = match panel {
|
let currently_open = match panel {
|
||||||
Panel::Formula => ctx.formula_panel_open,
|
Panel::Formula => ctx.formula_panel_open,
|
||||||
Panel::Category => ctx.category_panel_open,
|
Panel::Category => ctx.category_panel_open,
|
||||||
Panel::View => ctx.view_panel_open,
|
Panel::View => ctx.view_panel_open,
|
||||||
};
|
};
|
||||||
|
let currently_focused = match panel {
|
||||||
|
Panel::Formula => matches!(ctx.mode, AppMode::FormulaPanel | AppMode::FormulaEdit { .. }),
|
||||||
|
Panel::Category => matches!(ctx.mode, AppMode::CategoryPanel | AppMode::CategoryAdd { .. } | AppMode::ItemAdd { .. }),
|
||||||
|
Panel::View => matches!(ctx.mode, AppMode::ViewPanel),
|
||||||
|
};
|
||||||
|
let (open, focused) = if currently_open && currently_focused {
|
||||||
|
(false, false) // close
|
||||||
|
} else {
|
||||||
|
(true, true) // open + focus
|
||||||
|
};
|
||||||
Ok(Box::new(TogglePanelAndFocus {
|
Ok(Box::new(TogglePanelAndFocus {
|
||||||
panel,
|
panel,
|
||||||
currently_open,
|
open,
|
||||||
|
focused,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -2522,7 +2809,7 @@ pub fn default_registry() -> CmdRegistry {
|
|||||||
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
|
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
|
||||||
let (current, max) = match panel {
|
let (current, max) = match panel {
|
||||||
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
|
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
|
||||||
Panel::Category => (ctx.cat_panel_cursor, ctx.model.category_names().len()),
|
Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()),
|
||||||
Panel::View => (ctx.view_panel_cursor, ctx.model.views.len()),
|
Panel::View => (ctx.view_panel_cursor, ctx.model.views.len()),
|
||||||
};
|
};
|
||||||
Ok(Box::new(MovePanelCursor {
|
Ok(Box::new(MovePanelCursor {
|
||||||
@@ -2536,8 +2823,14 @@ pub fn default_registry() -> CmdRegistry {
|
|||||||
r.register_nullary(|| {
|
r.register_nullary(|| {
|
||||||
Box::new(DeleteFormulaAtCursor)
|
Box::new(DeleteFormulaAtCursor)
|
||||||
});
|
});
|
||||||
|
r.register_nullary(|| Box::new(AddRecordRow));
|
||||||
|
r.register_nullary(|| Box::new(TogglePruneEmpty));
|
||||||
|
r.register_nullary(|| Box::new(ToggleRecordsMode));
|
||||||
r.register_nullary(|| Box::new(CycleAxisAtCursor));
|
r.register_nullary(|| Box::new(CycleAxisAtCursor));
|
||||||
r.register_nullary(|| Box::new(OpenItemAddAtCursor));
|
r.register_nullary(|| Box::new(OpenItemAddAtCursor));
|
||||||
|
r.register_nullary(|| Box::new(DeleteCategoryAtCursor));
|
||||||
|
r.register_nullary(|| Box::new(ToggleCatExpand));
|
||||||
|
r.register_nullary(|| Box::new(FilterToItem));
|
||||||
r.register_nullary(|| Box::new(SwitchViewAtCursor));
|
r.register_nullary(|| Box::new(SwitchViewAtCursor));
|
||||||
r.register_nullary(|| Box::new(CreateAndSwitchView));
|
r.register_nullary(|| Box::new(CreateAndSwitchView));
|
||||||
r.register_nullary(|| Box::new(DeleteViewAtCursor));
|
r.register_nullary(|| Box::new(DeleteViewAtCursor));
|
||||||
@@ -2655,6 +2948,8 @@ mod tests {
|
|||||||
|
|
||||||
static EMPTY_BUFFERS: std::sync::LazyLock<HashMap<String, String>> =
|
static EMPTY_BUFFERS: std::sync::LazyLock<HashMap<String, String>> =
|
||||||
std::sync::LazyLock::new(HashMap::new);
|
std::sync::LazyLock::new(HashMap::new);
|
||||||
|
static EMPTY_EXPANDED: std::sync::LazyLock<std::collections::HashSet<String>> =
|
||||||
|
std::sync::LazyLock::new(std::collections::HashSet::new);
|
||||||
|
|
||||||
fn make_ctx(model: &Model) -> CmdContext<'_> {
|
fn make_ctx(model: &Model) -> CmdContext<'_> {
|
||||||
let view = model.active_view();
|
let view = model.active_view();
|
||||||
@@ -2686,6 +2981,9 @@ mod tests {
|
|||||||
cell_key: layout.cell_key(sr, sc),
|
cell_key: layout.cell_key(sr, sc),
|
||||||
row_count: layout.row_count(),
|
row_count: layout.row_count(),
|
||||||
col_count: layout.col_count(),
|
col_count: layout.col_count(),
|
||||||
|
visible_rows: 20,
|
||||||
|
visible_cols: 8,
|
||||||
|
expanded_cats: &EMPTY_EXPANDED,
|
||||||
key_code: KeyCode::Null,
|
key_code: KeyCode::Null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2792,12 +3090,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toggle_panel_and_focus_opens_and_enters_mode() {
|
fn toggle_panel_open_and_focus() {
|
||||||
let m = two_cat_model();
|
let m = two_cat_model();
|
||||||
let ctx = make_ctx(&m);
|
let ctx = make_ctx(&m);
|
||||||
let cmd = TogglePanelAndFocus {
|
let cmd = TogglePanelAndFocus {
|
||||||
panel: effect::Panel::Formula,
|
panel: effect::Panel::Formula,
|
||||||
currently_open: false,
|
open: true,
|
||||||
|
focused: true,
|
||||||
};
|
};
|
||||||
let effects = cmd.execute(&ctx);
|
let effects = cmd.execute(&ctx);
|
||||||
assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode
|
assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode
|
||||||
@@ -2809,16 +3108,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toggle_panel_and_focus_closes_when_open() {
|
fn toggle_panel_close_and_unfocus() {
|
||||||
let m = two_cat_model();
|
let m = two_cat_model();
|
||||||
let mut ctx = make_ctx(&m);
|
let ctx = make_ctx(&m);
|
||||||
ctx.formula_panel_open = true;
|
|
||||||
let cmd = TogglePanelAndFocus {
|
let cmd = TogglePanelAndFocus {
|
||||||
panel: effect::Panel::Formula,
|
panel: effect::Panel::Formula,
|
||||||
currently_open: true,
|
open: false,
|
||||||
|
focused: false,
|
||||||
};
|
};
|
||||||
let effects = cmd.execute(&ctx);
|
let effects = cmd.execute(&ctx);
|
||||||
assert_eq!(effects.len(), 1); // SetPanelOpen only, no mode change
|
assert_eq!(effects.len(), 2); // SetPanelOpen(false) + ChangeMode(Normal)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+34
-2
@@ -354,9 +354,14 @@ impl KeymapSet {
|
|||||||
normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor");
|
normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor");
|
||||||
normal.bind(KeyCode::Char('H'), none, "hide-selected-row-item");
|
normal.bind(KeyCode::Char('H'), none, "hide-selected-row-item");
|
||||||
|
|
||||||
// Drill into aggregated cell / view history
|
// Drill into aggregated cell / view history / add row
|
||||||
normal.bind(KeyCode::Char('>'), none, "drill-into-cell");
|
normal.bind(KeyCode::Char('>'), none, "drill-into-cell");
|
||||||
normal.bind(KeyCode::Char('<'), none, "view-back");
|
normal.bind(KeyCode::Char('<'), none, "view-back");
|
||||||
|
normal.bind(KeyCode::Char('o'), none, "add-record-row");
|
||||||
|
|
||||||
|
// Records mode toggle and prune toggle
|
||||||
|
normal.bind(KeyCode::Char('R'), none, "toggle-records-mode");
|
||||||
|
normal.bind(KeyCode::Char('P'), none, "toggle-prune-empty");
|
||||||
|
|
||||||
// Tile select
|
// Tile select
|
||||||
normal.bind(KeyCode::Char('T'), none, "enter-tile-select");
|
normal.bind(KeyCode::Char('T'), none, "enter-tile-select");
|
||||||
@@ -417,6 +422,9 @@ impl KeymapSet {
|
|||||||
fp.bind(KeyCode::Char('o'), none, "enter-formula-edit");
|
fp.bind(KeyCode::Char('o'), none, "enter-formula-edit");
|
||||||
fp.bind(KeyCode::Char('d'), none, "delete-formula-at-cursor");
|
fp.bind(KeyCode::Char('d'), none, "delete-formula-at-cursor");
|
||||||
fp.bind(KeyCode::Delete, none, "delete-formula-at-cursor");
|
fp.bind(KeyCode::Delete, none, "delete-formula-at-cursor");
|
||||||
|
fp.bind_args(KeyCode::Char('F'), none, "toggle-panel-and-focus", vec!["formula".into()]);
|
||||||
|
fp.bind_args(KeyCode::Char('C'), none, "toggle-panel-and-focus", vec!["category".into()]);
|
||||||
|
fp.bind_args(KeyCode::Char('V'), none, "toggle-panel-and-focus", vec!["view".into()]);
|
||||||
set.insert(ModeKey::FormulaPanel, Arc::new(fp));
|
set.insert(ModeKey::FormulaPanel, Arc::new(fp));
|
||||||
|
|
||||||
// ── Category panel ───────────────────────────────────────────────
|
// ── Category panel ───────────────────────────────────────────────
|
||||||
@@ -439,7 +447,7 @@ impl KeymapSet {
|
|||||||
vec!["category".into(), "1".into()],
|
vec!["category".into(), "1".into()],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
cp.bind(KeyCode::Enter, none, "cycle-axis-at-cursor");
|
cp.bind(KeyCode::Enter, none, "filter-to-item");
|
||||||
cp.bind(KeyCode::Char(' '), none, "cycle-axis-at-cursor");
|
cp.bind(KeyCode::Char(' '), none, "cycle-axis-at-cursor");
|
||||||
cp.bind_args(
|
cp.bind_args(
|
||||||
KeyCode::Char('n'),
|
KeyCode::Char('n'),
|
||||||
@@ -449,6 +457,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 ───────────────────────────────────────────────────
|
||||||
@@ -476,6 +505,9 @@ impl KeymapSet {
|
|||||||
vp.bind(KeyCode::Char('o'), none, "create-and-switch-view");
|
vp.bind(KeyCode::Char('o'), none, "create-and-switch-view");
|
||||||
vp.bind(KeyCode::Char('d'), none, "delete-view-at-cursor");
|
vp.bind(KeyCode::Char('d'), none, "delete-view-at-cursor");
|
||||||
vp.bind(KeyCode::Delete, none, "delete-view-at-cursor");
|
vp.bind(KeyCode::Delete, none, "delete-view-at-cursor");
|
||||||
|
vp.bind_args(KeyCode::Char('V'), none, "toggle-panel-and-focus", vec!["view".into()]);
|
||||||
|
vp.bind_args(KeyCode::Char('C'), none, "toggle-panel-and-focus", vec!["category".into()]);
|
||||||
|
vp.bind_args(KeyCode::Char('F'), none, "toggle-panel-and-focus", vec!["formula".into()]);
|
||||||
set.insert(ModeKey::ViewPanel, Arc::new(vp));
|
set.insert(ModeKey::ViewPanel, Arc::new(vp));
|
||||||
|
|
||||||
// ── Tile select ──────────────────────────────────────────────────
|
// ── Tile select ──────────────────────────────────────────────────
|
||||||
|
|||||||
+65
-29
@@ -65,9 +65,16 @@ pub fn run_tui(
|
|||||||
tui_context.terminal.draw(|f| draw(f, &app))?;
|
tui_context.terminal.draw(|f| draw(f, &app))?;
|
||||||
|
|
||||||
if event::poll(Duration::from_millis(100))? {
|
if event::poll(Duration::from_millis(100))? {
|
||||||
if let Event::Key(key) = event::read()? {
|
match event::read()? {
|
||||||
|
Event::Key(key) => {
|
||||||
app.handle_key(key)?;
|
app.handle_key(key)?;
|
||||||
}
|
}
|
||||||
|
Event::Resize(w, h) => {
|
||||||
|
app.term_width = w;
|
||||||
|
app.term_height = h;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.autosave_if_needed();
|
app.autosave_if_needed();
|
||||||
@@ -161,9 +168,7 @@ fn draw(f: &mut Frame, app: &App) {
|
|||||||
f.render_widget(ImportWizardWidget::new(wizard), size);
|
f.render_widget(ImportWizardWidget::new(wizard), size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if matches!(app.mode, AppMode::ExportPrompt { .. }) {
|
// ExportPrompt now uses the minibuffer at the bottom bar.
|
||||||
draw_export_prompt(f, size, app);
|
|
||||||
}
|
|
||||||
if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
|
if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
|
||||||
draw_welcome(f, main_chunks[1]);
|
draw_welcome(f, main_chunks[1]);
|
||||||
}
|
}
|
||||||
@@ -228,7 +233,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;
|
||||||
@@ -261,12 +271,59 @@ fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||||
match app.mode {
|
// All text-entry modes use the bottom bar as a minibuffer.
|
||||||
|
let minibuf = match &app.mode {
|
||||||
AppMode::CommandMode { .. } => {
|
AppMode::CommandMode { .. } => {
|
||||||
let buf = app.buffers.get("command").map(|s| s.as_str()).unwrap_or("");
|
let buf = app.buffers.get("command").map(|s| s.as_str()).unwrap_or("");
|
||||||
draw_command_bar(f, area, buf);
|
Some((format!(":{buf}▌"), Color::Yellow))
|
||||||
}
|
}
|
||||||
_ => draw_status(f, area, app),
|
AppMode::Editing { .. } => {
|
||||||
|
let buf = app.buffers.get("edit").map(|s| s.as_str()).unwrap_or("");
|
||||||
|
Some((format!("edit: {buf}▌"), Color::Green))
|
||||||
|
}
|
||||||
|
AppMode::FormulaEdit { .. } => {
|
||||||
|
let buf = app
|
||||||
|
.buffers
|
||||||
|
.get("formula")
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
Some((format!("formula: {buf}▌"), Color::Cyan))
|
||||||
|
}
|
||||||
|
AppMode::CategoryAdd { .. } => {
|
||||||
|
let buf = app
|
||||||
|
.buffers
|
||||||
|
.get("category")
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
Some((format!("new category: {buf}▌"), Color::Yellow))
|
||||||
|
}
|
||||||
|
AppMode::ItemAdd { category, .. } => {
|
||||||
|
let buf = app.buffers.get("item").map(|s| s.as_str()).unwrap_or("");
|
||||||
|
Some((format!("add item to {category}: {buf}▌"), Color::Green))
|
||||||
|
}
|
||||||
|
AppMode::ExportPrompt { .. } => {
|
||||||
|
let buf = app
|
||||||
|
.buffers
|
||||||
|
.get("export")
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
Some((format!("export path: {buf}▌"), Color::Yellow))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((text, color)) = minibuf {
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(text).style(
|
||||||
|
Style::default()
|
||||||
|
.fg(color)
|
||||||
|
.bg(Color::Indexed(235))
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
draw_status(f, area, app);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,27 +349,6 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
f.render_widget(Paragraph::new(line).style(mode_style(&app.mode)), area);
|
f.render_widget(Paragraph::new(line).style(mode_style(&app.mode)), area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_command_bar(f: &mut Frame, area: Rect, buffer: &str) {
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(format!(":{buffer}▌"))
|
|
||||||
.style(Style::default().fg(Color::White).bg(Color::Black)),
|
|
||||||
area,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_export_prompt(f: &mut Frame, area: Rect, app: &App) {
|
|
||||||
let buf = if let AppMode::ExportPrompt { buffer } = &app.mode {
|
|
||||||
buffer.as_str()
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
let popup = centered_popup(area, 64, 3);
|
|
||||||
let inner = draw_popup_frame(f, popup, " Export CSV — path (Esc cancel) ", Color::Yellow);
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(format!("{buf}▌")).style(Style::default().fg(Color::Green)),
|
|
||||||
inner,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_welcome(f: &mut Frame, area: Rect) {
|
fn draw_welcome(f: &mut Frame, area: Rect) {
|
||||||
let popup = centered_popup(area, 58, 20);
|
let popup = centered_popup(area, 58, 20);
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ impl Category {
|
|||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn remove_item(&mut self, name: &str) {
|
||||||
|
self.items.shift_remove(name);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn add_item_in_group(
|
pub fn add_item_in_group(
|
||||||
&mut self,
|
&mut self,
|
||||||
name: impl Into<String>,
|
name: impl Into<String>,
|
||||||
|
|||||||
+71
-1
@@ -53,10 +53,14 @@ impl Model {
|
|||||||
next_category_id: 2,
|
next_category_id: 2,
|
||||||
measure_agg: HashMap::new(),
|
measure_agg: HashMap::new(),
|
||||||
};
|
};
|
||||||
// Add virtuals to existing views (default view)
|
// Add virtuals to existing views (default view).
|
||||||
|
// Start in records mode; on_category_added will reclaim Row/Column
|
||||||
|
// for the first two regular categories.
|
||||||
for view in m.views.values_mut() {
|
for view in m.views.values_mut() {
|
||||||
view.on_category_added("_Index");
|
view.on_category_added("_Index");
|
||||||
view.on_category_added("_Dim");
|
view.on_category_added("_Dim");
|
||||||
|
view.set_axis("_Index", crate::view::Axis::Row);
|
||||||
|
view.set_axis("_Dim", crate::view::Axis::Column);
|
||||||
}
|
}
|
||||||
m
|
m
|
||||||
}
|
}
|
||||||
@@ -107,6 +111,47 @@ impl Model {
|
|||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove a category and all cells that reference it.
|
||||||
|
pub fn remove_category(&mut self, name: &str) {
|
||||||
|
if !self.categories.contains_key(name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.categories.shift_remove(name);
|
||||||
|
// Remove from all views
|
||||||
|
for view in self.views.values_mut() {
|
||||||
|
view.on_category_removed(name);
|
||||||
|
}
|
||||||
|
// Remove cells that have a coord in this category
|
||||||
|
let to_remove: Vec<CellKey> = self
|
||||||
|
.data
|
||||||
|
.iter_cells()
|
||||||
|
.filter(|(k, _)| k.get(name).is_some())
|
||||||
|
.map(|(k, _)| k)
|
||||||
|
.collect();
|
||||||
|
for k in to_remove {
|
||||||
|
self.data.remove(&k);
|
||||||
|
}
|
||||||
|
// Remove formulas targeting this category
|
||||||
|
self.formulas
|
||||||
|
.retain(|f| f.target_category != name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an item from a category and all cells that reference it.
|
||||||
|
pub fn remove_item(&mut self, cat_name: &str, item_name: &str) {
|
||||||
|
if let Some(cat) = self.categories.get_mut(cat_name) {
|
||||||
|
cat.remove_item(item_name);
|
||||||
|
}
|
||||||
|
let to_remove: Vec<CellKey> = self
|
||||||
|
.data
|
||||||
|
.iter_cells()
|
||||||
|
.filter(|(k, _)| k.get(cat_name) == Some(item_name))
|
||||||
|
.map(|(k, _)| k)
|
||||||
|
.collect();
|
||||||
|
for k in to_remove {
|
||||||
|
self.data.remove(&k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn category_mut(&mut self, name: &str) -> Option<&mut Category> {
|
pub fn category_mut(&mut self, name: &str) -> Option<&mut Category> {
|
||||||
self.categories.get_mut(name)
|
self.categories.get_mut(name)
|
||||||
}
|
}
|
||||||
@@ -527,6 +572,31 @@ mod model_tests {
|
|||||||
assert_eq!(m.get_cell(&k4), Some(&CellValue::Number(40.0)));
|
assert_eq!(m.get_cell(&k4), Some(&CellValue::Number(40.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_category_deletes_category_and_cells() {
|
||||||
|
let mut m = Model::new("Test");
|
||||||
|
m.add_category("Region").unwrap();
|
||||||
|
m.add_category("Product").unwrap();
|
||||||
|
m.category_mut("Region").unwrap().add_item("East");
|
||||||
|
m.category_mut("Product").unwrap().add_item("Shirts");
|
||||||
|
m.set_cell(
|
||||||
|
coord(&[("Region", "East"), ("Product", "Shirts")]),
|
||||||
|
CellValue::Number(42.0),
|
||||||
|
);
|
||||||
|
m.remove_category("Region");
|
||||||
|
assert!(m.category("Region").is_none());
|
||||||
|
// Cells referencing Region should be gone
|
||||||
|
assert_eq!(
|
||||||
|
m.data.iter_cells().count(),
|
||||||
|
0,
|
||||||
|
"all cells with Region coord should be removed"
|
||||||
|
);
|
||||||
|
// Views should no longer know about Region
|
||||||
|
// (axis_of would panic for unknown category, so check categories_on)
|
||||||
|
let v = m.active_view();
|
||||||
|
assert!(v.categories_on(crate::view::Axis::Row).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_view_copies_category_structure() {
|
fn create_view_copies_category_structure() {
|
||||||
let mut m = Model::new("Test");
|
let mut m = Model::new("Test");
|
||||||
|
|||||||
+20
-2
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,11 +246,11 @@ impl App {
|
|||||||
/// Hint text for the status bar (context-sensitive)
|
/// Hint text for the status bar (context-sensitive)
|
||||||
pub fn hint_text(&self) -> &'static str {
|
pub fn hint_text(&self) -> &'static str {
|
||||||
match &self.mode {
|
match &self.mode {
|
||||||
AppMode::Normal => "hjkl:nav Enter:advance i:edit x:clear t:transpose /:search F/C/V:panels T:tiles [:]:page >:drill ::cmd",
|
AppMode::Normal => "hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd",
|
||||||
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 n:new-cat a:add-items Esc:back",
|
AppMode::CategoryPanel => "jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back",
|
||||||
AppMode::CategoryAdd { .. } => "Enter:add & continue Tab:same Esc:done — type a category name",
|
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::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",
|
||||||
@@ -280,6 +296,8 @@ mod tests {
|
|||||||
col_count: 2,
|
col_count: 2,
|
||||||
row_offset: 0,
|
row_offset: 0,
|
||||||
col_offset: 0,
|
col_offset: 0,
|
||||||
|
visible_rows: 20,
|
||||||
|
visible_cols: 8,
|
||||||
};
|
};
|
||||||
crate::command::cmd::EnterAdvance { cursor }
|
crate::command::cmd::EnterAdvance { cursor }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+33
-67
@@ -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,12 +90,20 @@ 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 {
|
||||||
|
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}]");
|
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);
|
||||||
@@ -128,7 +112,7 @@ impl<'a> Widget for CategoryPanel<'a> {
|
|||||||
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)
|
||||||
@@ -136,29 +120,11 @@ impl<'a> Widget for CategoryPanel<'a> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
CatTreeEntry::Item { item_name, .. } => {
|
||||||
// Inline prompt at the bottom for CategoryAdd or ItemAdd
|
let label = format!(" · {item_name}");
|
||||||
let (prompt_color, prompt_text) = match self.mode {
|
buf.set_string(inner.x, y, &label, base_style);
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,68 @@ impl Effect for RemoveFormula {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-enter edit mode by reading the cell value at the current cursor.
|
||||||
|
/// Used after commit+advance to continue data entry.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EnterEditAtCursor;
|
||||||
|
impl Effect for EnterEditAtCursor {
|
||||||
|
fn apply(&self, app: &mut App) {
|
||||||
|
let ctx = app.cmd_context(crossterm::event::KeyCode::Null, crossterm::event::KeyModifiers::NONE);
|
||||||
|
let value = if let Some(v) = &ctx.records_value {
|
||||||
|
v.clone()
|
||||||
|
} else {
|
||||||
|
ctx.cell_key
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|k| ctx.model.get_cell(k).cloned())
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
drop(ctx);
|
||||||
|
app.buffers.insert("edit".to_string(), value);
|
||||||
|
app.mode = AppMode::Editing {
|
||||||
|
buffer: String::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TogglePruneEmpty;
|
||||||
|
impl Effect for TogglePruneEmpty {
|
||||||
|
fn apply(&self, app: &mut App) {
|
||||||
|
let v = app.model.active_view_mut();
|
||||||
|
v.prune_empty = !v.prune_empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ToggleCatExpand(pub String);
|
||||||
|
impl Effect for ToggleCatExpand {
|
||||||
|
fn apply(&self, app: &mut App) {
|
||||||
|
if !app.expanded_cats.remove(&self.0) {
|
||||||
|
app.expanded_cats.insert(self.0.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RemoveItem {
|
||||||
|
pub category: String,
|
||||||
|
pub item: String,
|
||||||
|
}
|
||||||
|
impl Effect for RemoveItem {
|
||||||
|
fn apply(&self, app: &mut App) {
|
||||||
|
app.model.remove_item(&self.category, &self.item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RemoveCategory(pub String);
|
||||||
|
impl Effect for RemoveCategory {
|
||||||
|
fn apply(&self, app: &mut App) {
|
||||||
|
app.model.remove_category(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── View mutations ───────────────────────────────────────────────────────────
|
// ── View mutations ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -358,6 +420,9 @@ impl Effect for ApplyAndClearDrill {
|
|||||||
let Some(drill) = app.drill_state.take() else {
|
let Some(drill) = app.drill_state.take() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if drill.pending_edits.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// For each pending edit, update the cell
|
// For each pending edit, update the cell
|
||||||
for ((record_idx, col_name), new_value) in &drill.pending_edits {
|
for ((record_idx, col_name), new_value) in &drill.pending_edits {
|
||||||
let Some((orig_key, _)) = drill.records.get(*record_idx) else {
|
let Some((orig_key, _)) = drill.records.get(*record_idx) else {
|
||||||
|
|||||||
+118
-46
@@ -11,10 +11,11 @@ use crate::model::Model;
|
|||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
use crate::view::{AxisEntry, GridLayout};
|
use crate::view::{AxisEntry, GridLayout};
|
||||||
|
|
||||||
const ROW_HEADER_WIDTH: u16 = 16;
|
/// Minimum column width — enough for short numbers/labels + 1 char gap.
|
||||||
const COL_WIDTH: u16 = 10;
|
const MIN_COL_WIDTH: u16 = 5;
|
||||||
const MIN_COL_WIDTH: u16 = 6;
|
|
||||||
const MAX_COL_WIDTH: u16 = 32;
|
const MAX_COL_WIDTH: u16 = 32;
|
||||||
|
const MIN_ROW_HEADER_W: u16 = 4;
|
||||||
|
const MAX_ROW_HEADER_W: u16 = 24;
|
||||||
/// Subtle dark-gray background used to highlight the row containing the cursor.
|
/// Subtle dark-gray background used to highlight the row containing the cursor.
|
||||||
const ROW_HIGHLIGHT_BG: Color = Color::Indexed(237);
|
const ROW_HIGHLIGHT_BG: Color = Color::Indexed(237);
|
||||||
const GROUP_EXPANDED: &str = "▼";
|
const GROUP_EXPANDED: &str = "▼";
|
||||||
@@ -70,12 +71,13 @@ impl<'a> GridWidget<'a> {
|
|||||||
let n_col_levels = layout.col_cats.len().max(1);
|
let n_col_levels = layout.col_cats.len().max(1);
|
||||||
let n_row_levels = layout.row_cats.len().max(1);
|
let n_row_levels = layout.row_cats.len().max(1);
|
||||||
|
|
||||||
// Per-column widths. In records mode, size each column to its widest
|
// ── Adaptive column widths ────────────────────────────────────
|
||||||
// content (pending edit → record value → header label). Otherwise use
|
// Size each column to fit its widest content (header + cell values)
|
||||||
// the fixed COL_WIDTH. Always at least MIN_COL_WIDTH, capped at MAX.
|
// plus 1 char gap. Minimum MIN_COL_WIDTH, capped at MAX_COL_WIDTH.
|
||||||
let col_widths: Vec<u16> = if layout.is_records_mode() {
|
let col_widths: Vec<u16> = {
|
||||||
let n = layout.col_count();
|
let n = layout.col_count();
|
||||||
let mut widths = vec![MIN_COL_WIDTH; n];
|
let mut widths = vec![0u16; n];
|
||||||
|
// Measure column header labels
|
||||||
for ci in 0..n {
|
for ci in 0..n {
|
||||||
let header = layout.col_label(ci);
|
let header = layout.col_label(ci);
|
||||||
let w = header.width() as u16;
|
let w = header.width() as u16;
|
||||||
@@ -83,6 +85,8 @@ impl<'a> GridWidget<'a> {
|
|||||||
widths[ci] = w;
|
widths[ci] = w;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Measure cell content
|
||||||
|
if layout.is_records_mode() {
|
||||||
for ri in 0..layout.row_count() {
|
for ri in 0..layout.row_count() {
|
||||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||||
let s = self.records_cell_text(&layout, ri, ci);
|
let s = self.records_cell_text(&layout, ri, ci);
|
||||||
@@ -92,30 +96,33 @@ impl<'a> GridWidget<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Add 2 cells of right-padding; cap at MAX_COL_WIDTH.
|
} else {
|
||||||
|
// Pivot mode: measure formatted cell values
|
||||||
|
for ri in 0..layout.row_count() {
|
||||||
|
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||||
|
if let Some(key) = layout.cell_key(ri, ci) {
|
||||||
|
let value =
|
||||||
|
self.model.evaluate_aggregated(&key, &layout.none_cats);
|
||||||
|
let s = format_value(value.as_ref(), fmt_comma, fmt_decimals);
|
||||||
|
let w = s.width() as u16;
|
||||||
|
if w > *wref {
|
||||||
|
*wref = w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// +1 for gap between columns
|
||||||
widths
|
widths
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|w| (w + 2).min(MAX_COL_WIDTH))
|
.map(|w| (w + 1).max(MIN_COL_WIDTH).min(MAX_COL_WIDTH))
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
|
||||||
vec![COL_WIDTH; layout.col_count()]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sub-column widths for row header area
|
// ── Adaptive row header widths ───────────────────────────────
|
||||||
let sub_col_w = ROW_HEADER_WIDTH / n_row_levels as u16;
|
// Measure the widest label at each row-header level.
|
||||||
let sub_widths: Vec<u16> = (0..n_row_levels)
|
let data_row_items: Vec<&Vec<String>> = layout
|
||||||
.map(|d| {
|
.row_items
|
||||||
if d < n_row_levels - 1 {
|
|
||||||
sub_col_w
|
|
||||||
} else {
|
|
||||||
ROW_HEADER_WIDTH.saturating_sub(sub_col_w * (n_row_levels as u16 - 1))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Flat lists of data-only tuples for repeat-suppression in headers
|
|
||||||
let data_col_items: Vec<&Vec<String>> = layout
|
|
||||||
.col_items
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|e| {
|
.filter_map(|e| {
|
||||||
if let AxisEntry::DataItem(v) = e {
|
if let AxisEntry::DataItem(v) = e {
|
||||||
@@ -125,8 +132,23 @@ impl<'a> GridWidget<'a> {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let data_row_items: Vec<&Vec<String>> = layout
|
|
||||||
.row_items
|
let sub_widths: Vec<u16> = (0..n_row_levels)
|
||||||
|
.map(|d| {
|
||||||
|
let max_label = data_row_items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.get(d))
|
||||||
|
.map(|s| s.width() as u16)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
(max_label + 1).max(MIN_ROW_HEADER_W).min(MAX_ROW_HEADER_W)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let row_header_width: u16 = sub_widths.iter().sum();
|
||||||
|
|
||||||
|
// Flat list of data-only column tuples for repeat-suppression in headers
|
||||||
|
let data_col_items: Vec<&Vec<String>> = layout
|
||||||
|
.col_items
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|e| {
|
.filter_map(|e| {
|
||||||
if let AxisEntry::DataItem(v) = e {
|
if let AxisEntry::DataItem(v) = e {
|
||||||
@@ -143,11 +165,11 @@ impl<'a> GridWidget<'a> {
|
|||||||
.any(|e| matches!(e, AxisEntry::GroupHeader { .. }));
|
.any(|e| matches!(e, AxisEntry::GroupHeader { .. }));
|
||||||
|
|
||||||
// Compute how many columns fit starting from col_offset.
|
// Compute how many columns fit starting from col_offset.
|
||||||
let data_area_width = area.width.saturating_sub(ROW_HEADER_WIDTH);
|
let data_area_width = area.width.saturating_sub(row_header_width);
|
||||||
let mut acc = 0u16;
|
let mut acc = 0u16;
|
||||||
let mut last = col_offset;
|
let mut last = col_offset;
|
||||||
for ci in col_offset..layout.col_count() {
|
for ci in col_offset..layout.col_count() {
|
||||||
let w = *col_widths.get(ci).unwrap_or(&COL_WIDTH);
|
let w = *col_widths.get(ci).unwrap_or(&MIN_COL_WIDTH);
|
||||||
if acc + w > data_area_width {
|
if acc + w > data_area_width {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -160,16 +182,16 @@ impl<'a> GridWidget<'a> {
|
|||||||
let col_x: Vec<u16> = {
|
let col_x: Vec<u16> = {
|
||||||
let mut v = vec![0u16; layout.col_count() + 1];
|
let mut v = vec![0u16; layout.col_count() + 1];
|
||||||
for ci in 0..layout.col_count() {
|
for ci in 0..layout.col_count() {
|
||||||
v[ci + 1] = v[ci] + *col_widths.get(ci).unwrap_or(&COL_WIDTH);
|
v[ci + 1] = v[ci] + *col_widths.get(ci).unwrap_or(&MIN_COL_WIDTH);
|
||||||
}
|
}
|
||||||
v
|
v
|
||||||
};
|
};
|
||||||
let col_x_at = |ci: usize| -> u16 {
|
let col_x_at = |ci: usize| -> u16 {
|
||||||
area.x
|
area.x
|
||||||
+ ROW_HEADER_WIDTH
|
+ row_header_width
|
||||||
+ col_x[ci].saturating_sub(col_x[col_offset])
|
+ col_x[ci].saturating_sub(col_x[col_offset])
|
||||||
};
|
};
|
||||||
let col_w_at = |ci: usize| -> u16 { *col_widths.get(ci).unwrap_or(&COL_WIDTH) };
|
let col_w_at = |ci: usize| -> u16 { *col_widths.get(ci).unwrap_or(&MIN_COL_WIDTH) };
|
||||||
|
|
||||||
let _header_rows = n_col_levels as u16 + 1 + if has_col_groups { 1 } else { 0 };
|
let _header_rows = n_col_levels as u16 + 1 + if has_col_groups { 1 } else { 0 };
|
||||||
|
|
||||||
@@ -187,7 +209,7 @@ impl<'a> GridWidget<'a> {
|
|||||||
buf.set_string(
|
buf.set_string(
|
||||||
area.x,
|
area.x,
|
||||||
y,
|
y,
|
||||||
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
|
format!("{:<width$}", "", width = row_header_width as usize),
|
||||||
Style::default(),
|
Style::default(),
|
||||||
);
|
);
|
||||||
let mut prev_group: Option<String> = None;
|
let mut prev_group: Option<String> = None;
|
||||||
@@ -233,7 +255,7 @@ impl<'a> GridWidget<'a> {
|
|||||||
buf.set_string(
|
buf.set_string(
|
||||||
area.x,
|
area.x,
|
||||||
y,
|
y,
|
||||||
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
|
format!("{:<width$}", "", width = row_header_width as usize),
|
||||||
Style::default(),
|
Style::default(),
|
||||||
);
|
);
|
||||||
for ci in visible_col_range.clone() {
|
for ci in visible_col_range.clone() {
|
||||||
@@ -252,7 +274,17 @@ impl<'a> GridWidget<'a> {
|
|||||||
String::new()
|
String::new()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let styled = if ci == sel_col {
|
// Underline columns that share the same ancestor group as
|
||||||
|
// sel_col through level d. At the bottom level this matches
|
||||||
|
// only sel_col; at higher levels it spans all sub-columns.
|
||||||
|
let in_sel_group = if layout.col_cats.is_empty() {
|
||||||
|
ci == sel_col
|
||||||
|
} else if sel_col < data_col_items.len() && ci < data_col_items.len() {
|
||||||
|
data_col_items[ci][..=d] == data_col_items[sel_col][..=d]
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
let styled = if in_sel_group {
|
||||||
header_style.add_modifier(Modifier::UNDERLINED)
|
header_style.add_modifier(Modifier::UNDERLINED)
|
||||||
} else {
|
} else {
|
||||||
header_style
|
header_style
|
||||||
@@ -301,8 +333,8 @@ impl<'a> GridWidget<'a> {
|
|||||||
y,
|
y,
|
||||||
format!(
|
format!(
|
||||||
"{:<width$}",
|
"{:<width$}",
|
||||||
truncate(&label, ROW_HEADER_WIDTH as usize),
|
truncate(&label, row_header_width as usize),
|
||||||
width = ROW_HEADER_WIDTH as usize
|
width = row_header_width as usize
|
||||||
),
|
),
|
||||||
group_header_style,
|
group_header_style,
|
||||||
);
|
);
|
||||||
@@ -340,9 +372,9 @@ impl<'a> GridWidget<'a> {
|
|||||||
if is_sel_row {
|
if is_sel_row {
|
||||||
let row_w = (area.x + area.width).saturating_sub(area.x);
|
let row_w = (area.x + area.width).saturating_sub(area.x);
|
||||||
buf.set_string(
|
buf.set_string(
|
||||||
area.x + ROW_HEADER_WIDTH,
|
area.x + row_header_width,
|
||||||
y,
|
y,
|
||||||
" ".repeat(row_w.saturating_sub(ROW_HEADER_WIDTH) as usize),
|
" ".repeat(row_w.saturating_sub(row_header_width) as usize),
|
||||||
Style::default().bg(ROW_HIGHLIGHT_BG),
|
Style::default().bg(ROW_HIGHLIGHT_BG),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -407,7 +439,12 @@ impl<'a> GridWidget<'a> {
|
|||||||
// "drill to edit". Records mode cells are always
|
// "drill to edit". Records mode cells are always
|
||||||
// directly editable, as are plain pivot cells.
|
// directly editable, as are plain pivot cells.
|
||||||
let is_aggregated = !layout.is_records_mode()
|
let is_aggregated = !layout.is_records_mode()
|
||||||
&& !layout.none_cats.is_empty();
|
&& layout.none_cats.iter().any(|c| {
|
||||||
|
self.model
|
||||||
|
.category(c)
|
||||||
|
.map(|cat| cat.kind.is_regular())
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
let mut cell_style = if is_selected {
|
let mut cell_style = if is_selected {
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Black)
|
.fg(Color::Black)
|
||||||
@@ -479,7 +516,7 @@ impl<'a> GridWidget<'a> {
|
|||||||
buf.set_string(
|
buf.set_string(
|
||||||
area.x,
|
area.x,
|
||||||
y,
|
y,
|
||||||
format!("{:<width$}", "Total", width = ROW_HEADER_WIDTH as usize),
|
format!("{:<width$}", "Total", width = row_header_width as usize),
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Yellow)
|
.fg(Color::Yellow)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
@@ -667,6 +704,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal model: Type on Row, Month on Column.
|
/// Minimal model: Type on Row, Month on Column.
|
||||||
|
/// Every cell has a value so rows/cols survive pruning.
|
||||||
fn two_cat_model() -> Model {
|
fn two_cat_model() -> Model {
|
||||||
let mut m = Model::new("Test");
|
let mut m = Model::new("Test");
|
||||||
m.add_category("Type").unwrap(); // → Row
|
m.add_category("Type").unwrap(); // → Row
|
||||||
@@ -679,6 +717,15 @@ mod tests {
|
|||||||
c.add_item("Jan");
|
c.add_item("Jan");
|
||||||
c.add_item("Feb");
|
c.add_item("Feb");
|
||||||
}
|
}
|
||||||
|
// Fill every cell so nothing is pruned as empty.
|
||||||
|
for t in ["Food", "Clothing"] {
|
||||||
|
for mo in ["Jan", "Feb"] {
|
||||||
|
m.set_cell(
|
||||||
|
coord(&[("Type", t), ("Month", mo)]),
|
||||||
|
CellValue::Number(1.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
m
|
m
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,10 +785,19 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unset_cells_show_no_value() {
|
fn unset_cells_show_no_value() {
|
||||||
let m = two_cat_model();
|
// Build a model without the two_cat_model helper (which fills every cell).
|
||||||
|
let mut m = Model::new("Test");
|
||||||
|
m.add_category("Type").unwrap();
|
||||||
|
m.add_category("Month").unwrap();
|
||||||
|
m.category_mut("Type").unwrap().add_item("Food");
|
||||||
|
m.category_mut("Month").unwrap().add_item("Jan");
|
||||||
|
// Set one cell so the row/col isn't pruned
|
||||||
|
m.set_cell(
|
||||||
|
coord(&[("Type", "Food"), ("Month", "Jan")]),
|
||||||
|
CellValue::Number(1.0),
|
||||||
|
);
|
||||||
let text = buf_text(&render(&m, 80, 24));
|
let text = buf_text(&render(&m, 80, 24));
|
||||||
// No digits should appear in the data area if nothing is set
|
// Should not contain large numbers that weren't set
|
||||||
// (Total row shows "0" — exclude that from this check by looking for non-zero)
|
|
||||||
assert!(!text.contains("100"), "unexpected '100' in:\n{text}");
|
assert!(!text.contains("100"), "unexpected '100' in:\n{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -873,6 +929,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
m.active_view_mut()
|
m.active_view_mut()
|
||||||
.set_axis("Recipient", crate::view::Axis::Row);
|
.set_axis("Recipient", crate::view::Axis::Row);
|
||||||
|
// Populate cells so rows/cols survive pruning
|
||||||
|
for t in ["Food", "Clothing"] {
|
||||||
|
for r in ["Alice", "Bob"] {
|
||||||
|
m.set_cell(
|
||||||
|
coord(&[("Type", t), ("Month", "Jan"), ("Recipient", r)]),
|
||||||
|
CellValue::Number(1.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let text = buf_text(&render(&m, 80, 24));
|
let text = buf_text(&render(&m, 80, 24));
|
||||||
// Multi-level row headers: category values shown separately, not joined with /
|
// Multi-level row headers: category values shown separately, not joined with /
|
||||||
@@ -936,6 +1001,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
m.active_view_mut()
|
m.active_view_mut()
|
||||||
.set_axis("Year", crate::view::Axis::Column);
|
.set_axis("Year", crate::view::Axis::Column);
|
||||||
|
// Populate cells so cols survive pruning
|
||||||
|
for y in ["2024", "2025"] {
|
||||||
|
m.set_cell(
|
||||||
|
coord(&[("Type", "Food"), ("Month", "Jan"), ("Year", y)]),
|
||||||
|
CellValue::Number(1.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let text = buf_text(&render(&m, 80, 24));
|
let text = buf_text(&render(&m, 80, 24));
|
||||||
// Multi-level column headers: category values shown separately, not joined with /
|
// Multi-level column headers: category values shown separately, not joined with /
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+131
-1
@@ -53,6 +53,9 @@ impl GridLayout {
|
|||||||
layout.records = Some(records);
|
layout.records = Some(records);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if view.prune_empty {
|
||||||
|
layout.prune_empty(model);
|
||||||
|
}
|
||||||
layout
|
layout
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,10 +155,11 @@ impl GridLayout {
|
|||||||
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Synthesize col items: one per category + "Value"
|
// Synthesize col items: one per non-virtual category + "Value"
|
||||||
let cat_names: Vec<String> = model
|
let cat_names: Vec<String> = model
|
||||||
.category_names()
|
.category_names()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
.filter(|c| !c.starts_with('_'))
|
||||||
.map(String::from)
|
.map(String::from)
|
||||||
.collect();
|
.collect();
|
||||||
let mut col_items: Vec<AxisEntry> = cat_names
|
let mut col_items: Vec<AxisEntry> = cat_names
|
||||||
@@ -195,6 +199,108 @@ impl GridLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove data rows where every column is empty and data columns
|
||||||
|
/// where every row is empty. Group headers are kept if at least one
|
||||||
|
/// of their data items survives.
|
||||||
|
///
|
||||||
|
/// In records mode every column is shown (the user drilled in to see
|
||||||
|
/// all the raw data). In pivot mode, rows and columns where every
|
||||||
|
/// cell is empty are hidden to reduce clutter.
|
||||||
|
pub fn prune_empty(&mut self, model: &Model) {
|
||||||
|
if self.is_records_mode() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rc = self.row_count();
|
||||||
|
let cc = self.col_count();
|
||||||
|
if rc == 0 || cc == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a row×col grid of "has content?"
|
||||||
|
let mut has_value = vec![vec![false; cc]; rc];
|
||||||
|
for ri in 0..rc {
|
||||||
|
for ci in 0..cc {
|
||||||
|
has_value[ri][ci] = if self.is_records_mode() {
|
||||||
|
let s = self.records_display(ri, ci).unwrap_or_default();
|
||||||
|
!s.is_empty()
|
||||||
|
} else {
|
||||||
|
self.cell_key(ri, ci)
|
||||||
|
.and_then(|k| model.evaluate_aggregated(&k, &self.none_cats))
|
||||||
|
.is_some()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which data-row indices are non-empty?
|
||||||
|
let keep_row: Vec<bool> = (0..rc)
|
||||||
|
.map(|ri| (0..cc).any(|ci| has_value[ri][ci]))
|
||||||
|
.collect();
|
||||||
|
// Which data-col indices are non-empty?
|
||||||
|
let keep_col: Vec<bool> = (0..cc)
|
||||||
|
.map(|ci| (0..rc).any(|ri| has_value[ri][ci]))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Filter row_items, preserving group headers when at least one
|
||||||
|
// subsequent data item survives.
|
||||||
|
let mut new_rows = Vec::new();
|
||||||
|
let mut pending_header: Option<AxisEntry> = None;
|
||||||
|
let mut data_idx = 0usize;
|
||||||
|
for entry in self.row_items.drain(..) {
|
||||||
|
match &entry {
|
||||||
|
AxisEntry::GroupHeader { .. } => {
|
||||||
|
pending_header = Some(entry);
|
||||||
|
}
|
||||||
|
AxisEntry::DataItem(_) => {
|
||||||
|
if data_idx < rc && keep_row[data_idx] {
|
||||||
|
if let Some(h) = pending_header.take() {
|
||||||
|
new_rows.push(h);
|
||||||
|
}
|
||||||
|
new_rows.push(entry);
|
||||||
|
}
|
||||||
|
data_idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.row_items = new_rows;
|
||||||
|
|
||||||
|
// Filter col_items (same logic)
|
||||||
|
let mut new_cols = Vec::new();
|
||||||
|
let mut pending_header: Option<AxisEntry> = None;
|
||||||
|
let mut data_idx = 0usize;
|
||||||
|
for entry in self.col_items.drain(..) {
|
||||||
|
match &entry {
|
||||||
|
AxisEntry::GroupHeader { .. } => {
|
||||||
|
pending_header = Some(entry);
|
||||||
|
}
|
||||||
|
AxisEntry::DataItem(_) => {
|
||||||
|
if data_idx < cc && keep_col[data_idx] {
|
||||||
|
if let Some(h) = pending_header.take() {
|
||||||
|
new_cols.push(h);
|
||||||
|
}
|
||||||
|
new_cols.push(entry);
|
||||||
|
}
|
||||||
|
data_idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.col_items = new_cols;
|
||||||
|
|
||||||
|
// If records mode, also prune the records vec and re-index row_items
|
||||||
|
if let Some(records) = &self.records {
|
||||||
|
let new_records: Vec<_> = keep_row
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, keep)| **keep)
|
||||||
|
.map(|(i, _)| records[i].clone())
|
||||||
|
.collect();
|
||||||
|
let new_row_items: Vec<AxisEntry> = (0..new_records.len())
|
||||||
|
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
||||||
|
.collect();
|
||||||
|
self.row_items = new_row_items;
|
||||||
|
self.records = Some(new_records);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether this layout is in records mode.
|
/// Whether this layout is in records mode.
|
||||||
pub fn is_records_mode(&self) -> bool {
|
pub fn is_records_mode(&self) -> bool {
|
||||||
self.records.is_some()
|
self.records.is_some()
|
||||||
@@ -450,6 +556,30 @@ mod tests {
|
|||||||
m
|
m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prune_empty_removes_all_empty_columns_in_pivot_mode() {
|
||||||
|
let mut m = Model::new("T");
|
||||||
|
m.add_category("Row").unwrap();
|
||||||
|
m.add_category("Col").unwrap();
|
||||||
|
m.category_mut("Row").unwrap().add_item("A");
|
||||||
|
m.category_mut("Col").unwrap().add_item("X");
|
||||||
|
m.category_mut("Col").unwrap().add_item("Y");
|
||||||
|
// Only X has data; Y is entirely empty
|
||||||
|
m.set_cell(
|
||||||
|
CellKey::new(vec![
|
||||||
|
("Row".into(), "A".into()),
|
||||||
|
("Col".into(), "X".into()),
|
||||||
|
]),
|
||||||
|
CellValue::Number(1.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut layout = GridLayout::new(&m, m.active_view());
|
||||||
|
assert_eq!(layout.col_count(), 2); // X and Y before pruning
|
||||||
|
layout.prune_empty(&m);
|
||||||
|
assert_eq!(layout.col_count(), 1); // only X after pruning
|
||||||
|
assert_eq!(layout.col_label(0), "X");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn records_mode_activated_when_index_and_dim_on_axes() {
|
fn records_mode_activated_when_index_and_dim_on_axes() {
|
||||||
let mut m = records_model();
|
let mut m = records_model();
|
||||||
|
|||||||
+50
-5
@@ -4,6 +4,10 @@ use std::collections::{HashMap, HashSet};
|
|||||||
|
|
||||||
use super::axis::Axis;
|
use super::axis::Axis;
|
||||||
|
|
||||||
|
fn default_prune() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct View {
|
pub struct View {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -17,6 +21,9 @@ pub struct View {
|
|||||||
pub collapsed_groups: HashMap<String, HashSet<String>>,
|
pub collapsed_groups: HashMap<String, HashSet<String>>,
|
||||||
/// Number format string (e.g. ",.0f" for comma-separated integer)
|
/// Number format string (e.g. ",.0f" for comma-separated integer)
|
||||||
pub number_format: String,
|
pub number_format: String,
|
||||||
|
/// When true, empty rows/columns are pruned from the display.
|
||||||
|
#[serde(default = "default_prune")]
|
||||||
|
pub prune_empty: bool,
|
||||||
/// Scroll offset for grid
|
/// Scroll offset for grid
|
||||||
pub row_offset: usize,
|
pub row_offset: usize,
|
||||||
pub col_offset: usize,
|
pub col_offset: usize,
|
||||||
@@ -33,6 +40,7 @@ impl View {
|
|||||||
hidden_items: HashMap::new(),
|
hidden_items: HashMap::new(),
|
||||||
collapsed_groups: HashMap::new(),
|
collapsed_groups: HashMap::new(),
|
||||||
number_format: ",.0".to_string(),
|
number_format: ",.0".to_string(),
|
||||||
|
prune_empty: false,
|
||||||
row_offset: 0,
|
row_offset: 0,
|
||||||
col_offset: 0,
|
col_offset: 0,
|
||||||
selected: (0, 0),
|
selected: (0, 0),
|
||||||
@@ -41,16 +49,47 @@ impl View {
|
|||||||
|
|
||||||
pub fn on_category_added(&mut self, cat_name: &str) {
|
pub fn on_category_added(&mut self, cat_name: &str) {
|
||||||
if !self.category_axes.contains_key(cat_name) {
|
if !self.category_axes.contains_key(cat_name) {
|
||||||
// Virtual categories (names starting with `_`) default to Axis::None.
|
// Virtual/underscore categories default to Axis::None.
|
||||||
// Regular categories auto-assign: first → Row, second → Column, rest → Page.
|
// Regular categories auto-assign: first → Row, second → Column, rest → Page.
|
||||||
|
// If a virtual currently holds Row or Column and a regular category needs
|
||||||
|
// the slot, bump the virtual to None.
|
||||||
let axis = if cat_name.starts_with('_') {
|
let axis = if cat_name.starts_with('_') {
|
||||||
Axis::None
|
Axis::None
|
||||||
} else {
|
} else {
|
||||||
let rows = self.categories_on(Axis::Row).len();
|
let regular_rows: Vec<String> = self
|
||||||
let cols = self.categories_on(Axis::Column).len();
|
.categories_on(Axis::Row)
|
||||||
if rows == 0 {
|
.into_iter()
|
||||||
|
.filter(|c| !c.starts_with('_'))
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
let regular_cols: Vec<String> = self
|
||||||
|
.categories_on(Axis::Column)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| !c.starts_with('_'))
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
if regular_rows.is_empty() {
|
||||||
|
// Bump any virtual on Row to None
|
||||||
|
let bump: Vec<String> = self
|
||||||
|
.categories_on(Axis::Row)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| c.starts_with('_'))
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
for c in bump {
|
||||||
|
self.category_axes.insert(c, Axis::None);
|
||||||
|
}
|
||||||
Axis::Row
|
Axis::Row
|
||||||
} else if cols == 0 {
|
} else if regular_cols.is_empty() {
|
||||||
|
let bump: Vec<String> = self
|
||||||
|
.categories_on(Axis::Column)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| c.starts_with('_'))
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
for c in bump {
|
||||||
|
self.category_axes.insert(c, Axis::None);
|
||||||
|
}
|
||||||
Axis::Column
|
Axis::Column
|
||||||
} else {
|
} else {
|
||||||
Axis::Page
|
Axis::Page
|
||||||
@@ -60,6 +99,12 @@ impl View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn on_category_removed(&mut self, cat_name: &str) {
|
||||||
|
self.category_axes.shift_remove(cat_name);
|
||||||
|
self.page_selections.remove(cat_name);
|
||||||
|
self.hidden_items.remove(cat_name);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_axis(&mut self, cat_name: &str, axis: Axis) {
|
pub fn set_axis(&mut self, cat_name: &str, axis: Axis) {
|
||||||
if let Some(a) = self.category_axes.get_mut(cat_name) {
|
if let Some(a) = self.category_axes.get_mut(cat_name) {
|
||||||
*a = axis;
|
*a = axis;
|
||||||
|
|||||||
Reference in New Issue
Block a user