Compare commits
19 Commits
main
...
nemotron-b
| Author | SHA1 | Date | |
|---|---|---|---|
| 00499fc2bf | |||
| 178983bcbf | |||
| e09ddf71a7 | |||
| f8f8f537c3 | |||
| 334597d825 | |||
| 9329f04082 | |||
| 631067b011 | |||
| bd5dcfe1f7 | |||
| 0249afe33d | |||
| 132f017c79 | |||
| f1a777670f | |||
| 92f351bce3 | |||
| 6f4bc5e798 | |||
| 8e0c06d888 | |||
| 32677141de | |||
| ecc2987963 | |||
| 6d5138d904 | |||
| def3902eb9 | |||
| 9d88ad3205 |
File diff suppressed because it is too large
Load Diff
@ -266,10 +266,14 @@ impl KeymapSet {
|
||||
normal.bind(KeyCode::Char('G'), none, "jump-last-row");
|
||||
normal.bind(KeyCode::Char('0'), none, "jump-first-col");
|
||||
normal.bind(KeyCode::Char('$'), none, "jump-last-col");
|
||||
normal.bind(KeyCode::Home, none, "jump-first-col");
|
||||
normal.bind(KeyCode::End, none, "jump-last-col");
|
||||
|
||||
// Scroll
|
||||
normal.bind_args(KeyCode::Char('d'), ctrl, "scroll-rows", vec!["5".into()]);
|
||||
normal.bind_args(KeyCode::Char('u'), ctrl, "scroll-rows", vec!["-5".into()]);
|
||||
normal.bind_args(KeyCode::PageDown, none, "page-scroll", vec!["1".into()]);
|
||||
normal.bind_args(KeyCode::PageUp, none, "page-scroll", vec!["-1".into()]);
|
||||
|
||||
// Cell operations
|
||||
normal.bind(KeyCode::Char('x'), none, "clear-cell");
|
||||
@ -354,9 +358,14 @@ impl KeymapSet {
|
||||
normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor");
|
||||
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, "view-back");
|
||||
normal.bind(KeyCode::Char('o'), none, "open-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
|
||||
normal.bind(KeyCode::Char('T'), none, "enter-tile-select");
|
||||
@ -417,6 +426,9 @@ impl KeymapSet {
|
||||
fp.bind(KeyCode::Char('o'), none, "enter-formula-edit");
|
||||
fp.bind(KeyCode::Char('d'), 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));
|
||||
|
||||
// ── Category panel ───────────────────────────────────────────────
|
||||
@ -439,7 +451,7 @@ impl KeymapSet {
|
||||
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_args(
|
||||
KeyCode::Char('n'),
|
||||
@ -449,6 +461,27 @@ impl KeymapSet {
|
||||
);
|
||||
cp.bind(KeyCode::Char('a'), none, "open-item-add-at-cursor");
|
||||
cp.bind(KeyCode::Char('o'), none, "open-item-add-at-cursor");
|
||||
cp.bind(KeyCode::Char('d'), none, "delete-category-at-cursor");
|
||||
cp.bind(KeyCode::Delete, none, "delete-category-at-cursor");
|
||||
// C/F/V in panel modes: close panel (toggle-panel-and-focus sees focused=true)
|
||||
cp.bind_args(
|
||||
KeyCode::Char('C'),
|
||||
none,
|
||||
"toggle-panel-and-focus",
|
||||
vec!["category".into()],
|
||||
);
|
||||
cp.bind_args(
|
||||
KeyCode::Char('F'),
|
||||
none,
|
||||
"toggle-panel-and-focus",
|
||||
vec!["formula".into()],
|
||||
);
|
||||
cp.bind_args(
|
||||
KeyCode::Char('V'),
|
||||
none,
|
||||
"toggle-panel-and-focus",
|
||||
vec!["view".into()],
|
||||
);
|
||||
set.insert(ModeKey::CategoryPanel, Arc::new(cp));
|
||||
|
||||
// ── View panel ───────────────────────────────────────────────────
|
||||
@ -476,6 +509,9 @@ impl KeymapSet {
|
||||
vp.bind(KeyCode::Char('o'), none, "create-and-switch-view");
|
||||
vp.bind(KeyCode::Char('d'), 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));
|
||||
|
||||
// ── Tile select ──────────────────────────────────────────────────
|
||||
@ -528,6 +564,7 @@ impl KeymapSet {
|
||||
let mut ed = Keymap::new();
|
||||
ed.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
|
||||
ed.bind(KeyCode::Enter, none, "commit-cell-edit");
|
||||
ed.bind(KeyCode::Tab, none, "commit-and-advance-right");
|
||||
ed.bind_args(KeyCode::Backspace, none, "pop-char", vec!["edit".into()]);
|
||||
ed.bind_any_char("append-char", vec!["edit".into()]);
|
||||
set.insert(ModeKey::Editing, Arc::new(ed));
|
||||
|
||||
94
src/draw.rs
94
src/draw.rs
@ -65,9 +65,16 @@ pub fn run_tui(
|
||||
tui_context.terminal.draw(|f| draw(f, &app))?;
|
||||
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match event::read()? {
|
||||
Event::Key(key) => {
|
||||
app.handle_key(key)?;
|
||||
}
|
||||
Event::Resize(w, h) => {
|
||||
app.term_width = w;
|
||||
app.term_height = h;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
app.autosave_if_needed();
|
||||
@ -161,9 +168,7 @@ fn draw(f: &mut Frame, app: &App) {
|
||||
f.render_widget(ImportWizardWidget::new(wizard), size);
|
||||
}
|
||||
}
|
||||
if matches!(app.mode, AppMode::ExportPrompt { .. }) {
|
||||
draw_export_prompt(f, size, app);
|
||||
}
|
||||
// ExportPrompt now uses the minibuffer at the bottom bar.
|
||||
if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
|
||||
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 {
|
||||
let a = Rect::new(side.x, y, side.width, ph);
|
||||
f.render_widget(
|
||||
CategoryPanel::new(&app.model, &app.mode, app.cat_panel_cursor),
|
||||
CategoryPanel::new(
|
||||
&app.model,
|
||||
&app.mode,
|
||||
app.cat_panel_cursor,
|
||||
&app.expanded_cats,
|
||||
),
|
||||
a,
|
||||
);
|
||||
y += ph;
|
||||
@ -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) {
|
||||
match app.mode {
|
||||
// All text-entry modes use the bottom bar as a minibuffer.
|
||||
let minibuf = match &app.mode {
|
||||
AppMode::CommandMode { .. } => {
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
let popup = centered_popup(area, 58, 20);
|
||||
|
||||
50
src/format.rs
Normal file
50
src/format.rs
Normal file
@ -0,0 +1,50 @@
|
||||
use crate::model::cell::CellValue;
|
||||
|
||||
/// Format a CellValue for display with number formatting options.
|
||||
pub fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String {
|
||||
match v {
|
||||
Some(CellValue::Number(n)) => format_f64(*n, comma, decimals),
|
||||
Some(CellValue::Text(s)) => s.clone(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a number format string like ",.0" into (use_commas, decimal_places).
|
||||
pub fn parse_number_format(fmt: &str) -> (bool, u8) {
|
||||
let comma = fmt.contains(',');
|
||||
let decimals = fmt
|
||||
.rfind('.')
|
||||
.and_then(|i| fmt[i + 1..].parse::<u8>().ok())
|
||||
.unwrap_or(0);
|
||||
(comma, decimals)
|
||||
}
|
||||
|
||||
/// Format an f64 with optional comma grouping and decimal places.
|
||||
pub fn format_f64(n: f64, comma: bool, decimals: u8) -> String {
|
||||
let formatted = format!("{:.prec$}", n, prec = decimals as usize);
|
||||
if !comma {
|
||||
return formatted;
|
||||
}
|
||||
let (int_part, dec_part) = if let Some(dot) = formatted.find('.') {
|
||||
(&formatted[..dot], Some(&formatted[dot..]))
|
||||
} else {
|
||||
(&formatted[..], None)
|
||||
};
|
||||
let is_neg = int_part.starts_with('-');
|
||||
let digits = if is_neg { &int_part[1..] } else { int_part };
|
||||
let mut result = String::new();
|
||||
for (idx, c) in digits.chars().rev().enumerate() {
|
||||
if idx > 0 && idx % 3 == 0 {
|
||||
result.push(',');
|
||||
}
|
||||
result.push(c);
|
||||
}
|
||||
if is_neg {
|
||||
result.push('-');
|
||||
}
|
||||
let mut out: String = result.chars().rev().collect();
|
||||
if let Some(dec) = dec_part {
|
||||
out.push_str(dec);
|
||||
}
|
||||
out
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
mod command;
|
||||
mod draw;
|
||||
mod format;
|
||||
mod formula;
|
||||
mod import;
|
||||
mod model;
|
||||
|
||||
@ -117,6 +117,10 @@ impl Category {
|
||||
id
|
||||
}
|
||||
|
||||
pub fn remove_item(&mut self, name: &str) {
|
||||
self.items.shift_remove(name);
|
||||
}
|
||||
|
||||
pub fn add_item_in_group(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
|
||||
@ -53,10 +53,14 @@ impl Model {
|
||||
next_category_id: 2,
|
||||
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() {
|
||||
view.on_category_added("_Index");
|
||||
view.on_category_added("_Dim");
|
||||
view.set_axis("_Index", crate::view::Axis::Row);
|
||||
view.set_axis("_Dim", crate::view::Axis::Column);
|
||||
}
|
||||
m
|
||||
}
|
||||
@ -107,6 +111,47 @@ impl Model {
|
||||
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> {
|
||||
self.categories.get_mut(name)
|
||||
}
|
||||
@ -527,6 +572,31 @@ mod model_tests {
|
||||
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]
|
||||
fn create_view_copies_category_structure() {
|
||||
let mut m = Model::new("Test");
|
||||
|
||||
@ -458,17 +458,7 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
|
||||
out.push(',');
|
||||
}
|
||||
let row_values: Vec<String> = (0..layout.col_count())
|
||||
.map(|ci| {
|
||||
if layout.is_records_mode() {
|
||||
layout.records_display(ri, ci).unwrap_or_default()
|
||||
} else {
|
||||
layout
|
||||
.cell_key(ri, ci)
|
||||
.and_then(|key| model.evaluate_aggregated(&key, &layout.none_cats))
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
})
|
||||
.map(|ci| layout.display_text(model, ri, ci, false, 0))
|
||||
.collect();
|
||||
out.push_str(&row_values.join(","));
|
||||
out.push('\n');
|
||||
|
||||
281
src/ui/app.rs
281
src/ui/app.rs
@ -5,23 +5,25 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::command::cmd::CmdContext;
|
||||
use crate::command::keymap::{Keymap, KeymapSet};
|
||||
use crate::import::wizard::ImportWizard;
|
||||
use crate::model::cell::CellValue;
|
||||
use crate::model::Model;
|
||||
use crate::persistence;
|
||||
use crate::ui::grid::{
|
||||
compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format,
|
||||
};
|
||||
use crate::view::GridLayout;
|
||||
|
||||
/// Drill-down state: frozen record snapshot + pending edits that have not
|
||||
/// yet been applied to the model.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DrillState {
|
||||
/// Frozen snapshot of records shown in the drill view.
|
||||
pub records: Vec<(
|
||||
crate::model::cell::CellKey,
|
||||
crate::model::cell::CellValue,
|
||||
)>,
|
||||
/// Frozen snapshot of records shown in the drill view (Rc for cheap cloning).
|
||||
pub records: Rc<Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)>>,
|
||||
/// Pending edits keyed by (record_idx, column_name) → new string value.
|
||||
/// column_name is either "Value" or a category name.
|
||||
pub pending_edits: std::collections::HashMap<(usize, String), String>,
|
||||
@ -91,15 +93,27 @@ pub struct App {
|
||||
/// when filters would change. Pending edits are stored alongside and
|
||||
/// applied to the model on commit/navigate-away.
|
||||
pub drill_state: Option<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
|
||||
pub buffers: HashMap<String, String>,
|
||||
/// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.)
|
||||
pub transient_keymap: Option<Arc<Keymap>>,
|
||||
/// Current grid layout, derived from model + view + drill_state.
|
||||
/// Rebuilt via `rebuild_layout()` after state changes.
|
||||
pub layout: GridLayout,
|
||||
keymap_set: KeymapSet,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(model: Model, file_path: Option<PathBuf>) -> Self {
|
||||
let layout = {
|
||||
let view = model.active_view();
|
||||
GridLayout::with_frozen_records(&model, view, None)
|
||||
};
|
||||
Self {
|
||||
model,
|
||||
file_path,
|
||||
@ -121,19 +135,31 @@ impl App {
|
||||
view_back_stack: Vec::new(),
|
||||
view_forward_stack: Vec::new(),
|
||||
drill_state: None,
|
||||
term_width: crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80),
|
||||
term_height: crossterm::terminal::size().map(|(_, h)| h).unwrap_or(24),
|
||||
expanded_cats: std::collections::HashSet::new(),
|
||||
buffers: HashMap::new(),
|
||||
transient_keymap: None,
|
||||
layout,
|
||||
keymap_set: KeymapSet::default_keymaps(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the grid layout from current model, view, and drill state.
|
||||
/// Note: `with_frozen_records` already handles pruning internally.
|
||||
pub fn rebuild_layout(&mut self) {
|
||||
let view = self.model.active_view();
|
||||
let frozen = self.drill_state.as_ref().map(|s| Rc::clone(&s.records));
|
||||
self.layout = GridLayout::with_frozen_records(&self.model, view, frozen);
|
||||
}
|
||||
|
||||
pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> {
|
||||
let view = self.model.active_view();
|
||||
let frozen_records = self.drill_state.as_ref().map(|s| s.records.clone());
|
||||
let layout = GridLayout::with_frozen_records(&self.model, view, frozen_records);
|
||||
let layout = &self.layout;
|
||||
let (sel_row, sel_col) = view.selected;
|
||||
CmdContext {
|
||||
model: &self.model,
|
||||
layout,
|
||||
mode: &self.mode,
|
||||
selected: view.selected,
|
||||
row_offset: view.row_offset,
|
||||
@ -150,27 +176,40 @@ impl App {
|
||||
cat_panel_cursor: self.cat_panel_cursor,
|
||||
view_panel_cursor: self.view_panel_cursor,
|
||||
tile_cat_idx: self.tile_cat_idx,
|
||||
cell_key: layout.cell_key(sel_row, sel_col),
|
||||
row_count: layout.row_count(),
|
||||
col_count: layout.col_count(),
|
||||
none_cats: layout.none_cats.clone(),
|
||||
view_back_stack: self.view_back_stack.clone(),
|
||||
view_forward_stack: self.view_forward_stack.clone(),
|
||||
records_col: if layout.is_records_mode() {
|
||||
Some(layout.col_label(sel_col))
|
||||
view_back_stack: &self.view_back_stack,
|
||||
view_forward_stack: &self.view_forward_stack,
|
||||
display_value: {
|
||||
let key = layout.cell_key(sel_row, sel_col);
|
||||
if let Some(k) = &key {
|
||||
if let Some((idx, dim)) = crate::view::synthetic_record_info(k) {
|
||||
self.drill_state
|
||||
.as_ref()
|
||||
.and_then(|s| s.pending_edits.get(&(idx, dim)).cloned())
|
||||
.or_else(|| layout.resolve_display(k))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
records_value: if layout.is_records_mode() {
|
||||
// Check pending edits first, then fall back to original
|
||||
let col_name = layout.col_label(sel_col);
|
||||
let pending = self.drill_state.as_ref().and_then(|s| {
|
||||
s.pending_edits.get(&(sel_row, col_name.clone())).cloned()
|
||||
});
|
||||
pending.or_else(|| layout.records_display(sel_row, sel_col))
|
||||
self.model
|
||||
.get_cell(k)
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
} else {
|
||||
None
|
||||
String::new()
|
||||
}
|
||||
},
|
||||
visible_rows: (self.term_height as usize).saturating_sub(8),
|
||||
visible_cols: {
|
||||
let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format);
|
||||
let col_widths = compute_col_widths(&self.model, layout, fmt_comma, fmt_decimals);
|
||||
let row_header_width = compute_row_header_width(layout);
|
||||
compute_visible_cols(
|
||||
&col_widths,
|
||||
row_header_width,
|
||||
self.term_width,
|
||||
view.col_offset,
|
||||
)
|
||||
},
|
||||
expanded_cats: &self.expanded_cats,
|
||||
key_code: key,
|
||||
}
|
||||
}
|
||||
@ -179,6 +218,7 @@ impl App {
|
||||
for effect in effects {
|
||||
effect.apply(self);
|
||||
}
|
||||
self.rebuild_layout();
|
||||
}
|
||||
|
||||
/// True when the model has no categories yet (show welcome screen)
|
||||
@ -187,6 +227,8 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
self.rebuild_layout();
|
||||
|
||||
// Transient keymap (prefix key sequence) takes priority
|
||||
if let Some(transient) = self.transient_keymap.take() {
|
||||
let effects = {
|
||||
@ -230,11 +272,11 @@ impl App {
|
||||
/// Hint text for the status bar (context-sensitive)
|
||||
pub fn hint_text(&self) -> &'static str {
|
||||
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::Editing { .. } => "Enter:commit Esc:cancel",
|
||||
AppMode::Normal => "hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd",
|
||||
AppMode::Editing { .. } => "Enter:commit Tab:commit+right Esc:cancel",
|
||||
AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back",
|
||||
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::ItemAdd { .. } => "Enter:add & continue Tab:same Esc:done — type an item name",
|
||||
AppMode::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back",
|
||||
@ -280,6 +322,8 @@ mod tests {
|
||||
col_count: 2,
|
||||
row_offset: 0,
|
||||
col_offset: 0,
|
||||
visible_rows: 20,
|
||||
visible_cols: 8,
|
||||
};
|
||||
crate::command::cmd::EnterAdvance { cursor }
|
||||
}
|
||||
@ -353,6 +397,187 @@ mod tests {
|
||||
assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some("q"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn col_offset_scrolls_when_cursor_moves_past_visible_columns() {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
// Create a model with 8 wide columns. Column item names are 30 chars
|
||||
// each → column widths ~31 chars. With term_width=80, row header ~4,
|
||||
// data area ~76 → only ~2 columns actually fit. But the rough estimate
|
||||
// (80−30)/12 = 4 over-counts, so viewport_effects never scrolls.
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Row").unwrap();
|
||||
m.add_category("Col").unwrap();
|
||||
m.category_mut("Row").unwrap().add_item("R1");
|
||||
for i in 0..8 {
|
||||
let name = format!("VeryLongColumnItemName_{i:03}");
|
||||
m.category_mut("Col").unwrap().add_item(&name);
|
||||
}
|
||||
// Populate a value so the model isn't empty
|
||||
let key = CellKey::new(vec![
|
||||
("Row".to_string(), "R1".to_string()),
|
||||
("Col".to_string(), "VeryLongColumnItemName_000".to_string()),
|
||||
]);
|
||||
m.set_cell(key, CellValue::Number(1.0));
|
||||
|
||||
let mut app = App::new(m, None);
|
||||
app.term_width = 80;
|
||||
|
||||
// Press 'l' (right) 3 times to move cursor to column 3.
|
||||
// Only ~2 columns fit in 76 chars of data area (each col ~26 chars wide),
|
||||
// so column 3 is well off-screen. The buggy estimate (80−30)/12 = 4
|
||||
// thinks 4 columns fit, so it won't scroll until col 4.
|
||||
for _ in 0..3 {
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
app.model.active_view().selected.1,
|
||||
3,
|
||||
"cursor should be at column 3"
|
||||
);
|
||||
assert!(
|
||||
app.model.active_view().col_offset > 0,
|
||||
"col_offset should scroll when cursor moves past visible area (only ~2 cols fit \
|
||||
in 80-char terminal with 26-char-wide columns), but col_offset is {}",
|
||||
app.model.active_view().col_offset
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_jumps_to_first_col() {
|
||||
let mut app = two_col_model();
|
||||
app.model.active_view_mut().selected = (1, 1);
|
||||
app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.model.active_view().selected, (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_jumps_to_last_col() {
|
||||
let mut app = two_col_model();
|
||||
app.model.active_view_mut().selected = (1, 0);
|
||||
app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.model.active_view().selected, (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_down_scrolls_by_three_quarters_visible() {
|
||||
let mut app = two_col_model();
|
||||
// Add enough rows
|
||||
for i in 0..30 {
|
||||
app.model
|
||||
.category_mut("Row")
|
||||
.unwrap()
|
||||
.add_item(&format!("R{i}"));
|
||||
}
|
||||
app.term_height = 28; // ~20 visible rows → delta = 15
|
||||
app.model.active_view_mut().selected = (0, 0);
|
||||
app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.model.active_view().selected.1, 0, "column preserved");
|
||||
assert!(
|
||||
app.model.active_view().selected.0 > 0,
|
||||
"row should advance on PageDown"
|
||||
);
|
||||
// 3/4 of ~20 = 15
|
||||
assert_eq!(app.model.active_view().selected.0, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_up_scrolls_backward() {
|
||||
let mut app = two_col_model();
|
||||
for i in 0..30 {
|
||||
app.model
|
||||
.category_mut("Row")
|
||||
.unwrap()
|
||||
.add_item(&format!("R{i}"));
|
||||
}
|
||||
app.term_height = 28;
|
||||
app.model.active_view_mut().selected = (20, 0);
|
||||
app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.model.active_view().selected.0, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_last_row_scrolls_with_small_terminal() {
|
||||
let mut app = two_col_model();
|
||||
// Total rows: A, B, C + R0..R9 = 13 rows. Last row = 12.
|
||||
for i in 0..10 {
|
||||
app.model.category_mut("Row").unwrap().add_item(&format!("R{i}"));
|
||||
}
|
||||
app.term_height = 13; // ~5 visible rows
|
||||
app.model.active_view_mut().selected = (0, 0);
|
||||
// G jumps to last row (row 12)
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
let last = app.model.active_view().selected.0;
|
||||
assert_eq!(last, 12, "should be at last row");
|
||||
// With only ~5 visible rows and 13 rows, offset should scroll.
|
||||
// Bug: hardcoded 20 means `12 >= 0 + 20` is false → no scroll.
|
||||
let offset = app.model.active_view().row_offset;
|
||||
assert!(
|
||||
offset > 0,
|
||||
"row_offset should scroll when last row is beyond visible area, but is {offset}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_d_scrolls_viewport_with_small_terminal() {
|
||||
let mut app = two_col_model();
|
||||
for i in 0..30 {
|
||||
app.model
|
||||
.category_mut("Row")
|
||||
.unwrap()
|
||||
.add_item(&format!("R{i}"));
|
||||
}
|
||||
app.term_height = 13; // ~5 visible rows
|
||||
app.model.active_view_mut().selected = (0, 0);
|
||||
// Ctrl+d scrolls by 5 rows
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
|
||||
.unwrap();
|
||||
assert_eq!(app.model.active_view().selected.0, 5);
|
||||
// Press Ctrl+d again — now at row 10 with only 5 visible rows,
|
||||
// row_offset should have scrolled (not stay at 0 due to hardcoded 20)
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
|
||||
.unwrap();
|
||||
assert_eq!(app.model.active_view().selected.0, 10);
|
||||
assert!(
|
||||
app.model.active_view().row_offset > 0,
|
||||
"row_offset should scroll with small terminal, but is {}",
|
||||
app.model.active_view().row_offset
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_in_edit_mode_commits_and_moves_right() {
|
||||
let mut app = two_col_model();
|
||||
app.model.active_view_mut().selected = (0, 0);
|
||||
// Enter edit mode
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert!(matches!(app.mode, AppMode::Editing { .. }));
|
||||
// Type a digit
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
// Press Tab — should commit, move right, re-enter edit mode
|
||||
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
// Should be in edit mode on column 1
|
||||
assert!(
|
||||
matches!(app.mode, AppMode::Editing { .. }),
|
||||
"should be in edit mode after Tab, but mode is {:?}",
|
||||
app.mode
|
||||
);
|
||||
assert_eq!(
|
||||
app.model.active_view().selected.1,
|
||||
1,
|
||||
"should have moved to column 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_mode_buffer_cleared_on_reentry() {
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
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::ui::app::AppMode;
|
||||
use crate::ui::cat_tree::{build_cat_tree, CatTreeEntry};
|
||||
use crate::view::Axis;
|
||||
|
||||
fn axis_display(axis: Axis) -> (&'static str, Color) {
|
||||
@ -22,14 +23,21 @@ pub struct CategoryPanel<'a> {
|
||||
pub model: &'a Model,
|
||||
pub mode: &'a AppMode,
|
||||
pub cursor: usize,
|
||||
pub expanded: &'a std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
impl<'a> CategoryPanel<'a> {
|
||||
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
|
||||
pub fn new(
|
||||
model: &'a Model,
|
||||
mode: &'a AppMode,
|
||||
cursor: usize,
|
||||
expanded: &'a std::collections::HashSet<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
model,
|
||||
mode,
|
||||
cursor,
|
||||
expanded,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -40,18 +48,8 @@ impl<'a> Widget for CategoryPanel<'a> {
|
||||
let is_cat_add = matches!(self.mode, AppMode::CategoryAdd { .. });
|
||||
let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add;
|
||||
|
||||
let (border_color, title) = if is_cat_add {
|
||||
(
|
||||
Color::Yellow,
|
||||
" Categories — New category (Enter:add Esc:done) ",
|
||||
)
|
||||
} else if is_item_add {
|
||||
(
|
||||
Color::Green,
|
||||
" Categories — Adding items (Enter:add Esc:done) ",
|
||||
)
|
||||
} else if is_active {
|
||||
(Color::Cyan, " Categories n:new a:add-items Space:axis ")
|
||||
let (border_color, title) = if is_active {
|
||||
(Color::Cyan, " Categories ")
|
||||
} else {
|
||||
(Color::DarkGray, " Categories ")
|
||||
};
|
||||
@ -64,9 +62,9 @@ impl<'a> Widget for CategoryPanel<'a> {
|
||||
block.render(area, buf);
|
||||
|
||||
let view = self.model.active_view();
|
||||
let tree = build_cat_tree(self.model, self.expanded);
|
||||
|
||||
let cat_names: Vec<&str> = self.model.category_names();
|
||||
if cat_names.is_empty() {
|
||||
if tree.is_empty() {
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
inner.y,
|
||||
@ -76,36 +74,14 @@ impl<'a> Widget for CategoryPanel<'a> {
|
||||
return;
|
||||
}
|
||||
|
||||
// How many rows for the list vs the prompt at bottom
|
||||
let prompt_rows = if is_item_add { 2u16 } else { 0 };
|
||||
let list_height = inner.height.saturating_sub(prompt_rows);
|
||||
|
||||
for (i, cat_name) in cat_names.iter().enumerate() {
|
||||
if i as u16 >= list_height {
|
||||
for (i, entry) in tree.iter().enumerate() {
|
||||
if i as u16 >= inner.height {
|
||||
break;
|
||||
}
|
||||
let y = inner.y + i as u16;
|
||||
let is_selected = i == self.cursor && is_active;
|
||||
|
||||
let (axis_str, axis_color) = axis_display(view.axis_of(cat_name));
|
||||
|
||||
let item_count = self
|
||||
.model
|
||||
.category(cat_name)
|
||||
.map(|c| c.items.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Highlight the selected category both in CategoryPanel and ItemAdd modes
|
||||
let is_selected_cat = if is_item_add {
|
||||
if let AppMode::ItemAdd { category, .. } = self.mode {
|
||||
*cat_name == category.as_str()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
i == self.cursor && is_active
|
||||
};
|
||||
|
||||
let base_style = if is_selected_cat {
|
||||
let base_style = if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
@ -114,12 +90,20 @@ impl<'a> Widget for CategoryPanel<'a> {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
if is_selected_cat {
|
||||
if is_selected {
|
||||
let fill = " ".repeat(inner.width as usize);
|
||||
buf.set_string(inner.x, y, &fill, base_style);
|
||||
}
|
||||
|
||||
let name_part = format!(" {cat_name} ({item_count})");
|
||||
match entry {
|
||||
CatTreeEntry::Category {
|
||||
name,
|
||||
item_count,
|
||||
expanded,
|
||||
} => {
|
||||
let indicator = if *expanded { "▼" } else { "▶" };
|
||||
let (axis_str, axis_color) = axis_display(view.axis_of(name));
|
||||
let name_part = format!("{indicator} {name} ({item_count})");
|
||||
let axis_part = format!(" [{axis_str}]");
|
||||
|
||||
buf.set_string(inner.x, y, &name_part, base_style);
|
||||
@ -128,7 +112,7 @@ impl<'a> Widget for CategoryPanel<'a> {
|
||||
inner.x + name_part.len() as u16,
|
||||
y,
|
||||
&axis_part,
|
||||
if is_selected_cat {
|
||||
if is_selected {
|
||||
base_style
|
||||
} else {
|
||||
Style::default().fg(axis_color)
|
||||
@ -136,29 +120,11 @@ impl<'a> Widget for CategoryPanel<'a> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
);
|
||||
CatTreeEntry::Item { item_name, .. } => {
|
||||
let label = format!(" · {item_name}");
|
||||
buf.set_string(inner.x, y, &label, base_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,6 +90,60 @@ 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 = ctx.display_value.clone();
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug)]
|
||||
@ -344,7 +398,7 @@ pub struct StartDrill(pub Vec<(CellKey, CellValue)>);
|
||||
impl Effect for StartDrill {
|
||||
fn apply(&self, app: &mut App) {
|
||||
app.drill_state = Some(super::app::DrillState {
|
||||
records: self.0.clone(),
|
||||
records: std::rc::Rc::new(self.0.clone()),
|
||||
pending_edits: std::collections::HashMap::new(),
|
||||
});
|
||||
}
|
||||
@ -358,6 +412,9 @@ impl Effect for ApplyAndClearDrill {
|
||||
let Some(drill) = app.drill_state.take() else {
|
||||
return;
|
||||
};
|
||||
if drill.pending_edits.is_empty() {
|
||||
return;
|
||||
}
|
||||
// For each pending edit, update the cell
|
||||
for ((record_idx, col_name), new_value) in &drill.pending_edits {
|
||||
let Some((orig_key, _)) = drill.records.get(*record_idx) else {
|
||||
@ -773,6 +830,16 @@ pub enum Panel {
|
||||
View,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
pub fn mode(self) -> AppMode {
|
||||
match self {
|
||||
Panel::Formula => AppMode::FormulaPanel,
|
||||
Panel::Category => AppMode::CategoryPanel,
|
||||
Panel::View => AppMode::ViewPanel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Effect for SetPanelOpen {
|
||||
fn apply(&self, app: &mut App) {
|
||||
match self.panel {
|
||||
|
||||
348
src/ui/grid.rs
348
src/ui/grid.rs
@ -6,15 +6,15 @@ use ratatui::{
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::model::cell::CellValue;
|
||||
use crate::model::Model;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::view::{AxisEntry, GridLayout};
|
||||
|
||||
const ROW_HEADER_WIDTH: u16 = 16;
|
||||
const COL_WIDTH: u16 = 10;
|
||||
const MIN_COL_WIDTH: u16 = 6;
|
||||
/// Minimum column width — enough for short numbers/labels + 1 char gap.
|
||||
const MIN_COL_WIDTH: u16 = 5;
|
||||
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.
|
||||
const ROW_HIGHLIGHT_BG: Color = Color::Indexed(237);
|
||||
const GROUP_EXPANDED: &str = "▼";
|
||||
@ -22,6 +22,7 @@ const GROUP_COLLAPSED: &str = "▶";
|
||||
|
||||
pub struct GridWidget<'a> {
|
||||
pub model: &'a Model,
|
||||
pub layout: &'a GridLayout,
|
||||
pub mode: &'a AppMode,
|
||||
pub search_query: &'a str,
|
||||
pub buffers: &'a std::collections::HashMap<String, String>,
|
||||
@ -31,6 +32,7 @@ pub struct GridWidget<'a> {
|
||||
impl<'a> GridWidget<'a> {
|
||||
pub fn new(
|
||||
model: &'a Model,
|
||||
layout: &'a GridLayout,
|
||||
mode: &'a AppMode,
|
||||
search_query: &'a str,
|
||||
buffers: &'a std::collections::HashMap<String, String>,
|
||||
@ -38,6 +40,7 @@ impl<'a> GridWidget<'a> {
|
||||
) -> Self {
|
||||
Self {
|
||||
model,
|
||||
layout,
|
||||
mode,
|
||||
search_query,
|
||||
buffers,
|
||||
@ -45,23 +48,9 @@ impl<'a> GridWidget<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// In records mode, get the display text for (row, col): pending edit if
|
||||
/// staged, otherwise the underlying record's value for that column.
|
||||
fn records_cell_text(&self, layout: &GridLayout, row: usize, col: usize) -> String {
|
||||
let col_name = layout.col_label(col);
|
||||
let pending = self
|
||||
.drill_state
|
||||
.and_then(|s| s.pending_edits.get(&(row, col_name.clone())).cloned());
|
||||
pending
|
||||
.or_else(|| layout.records_display(row, col))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
|
||||
let view = self.model.active_view();
|
||||
|
||||
let frozen = self.drill_state.map(|s| s.records.clone());
|
||||
let layout = GridLayout::with_frozen_records(self.model, view, frozen);
|
||||
let layout = self.layout;
|
||||
let (sel_row, sel_col) = view.selected;
|
||||
let row_offset = view.row_offset;
|
||||
let col_offset = view.col_offset;
|
||||
@ -70,52 +59,11 @@ impl<'a> GridWidget<'a> {
|
||||
let n_col_levels = layout.col_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
|
||||
// content (pending edit → record value → header label). Otherwise use
|
||||
// the fixed COL_WIDTH. Always at least MIN_COL_WIDTH, capped at MAX.
|
||||
let col_widths: Vec<u16> = if layout.is_records_mode() {
|
||||
let n = layout.col_count();
|
||||
let mut widths = vec![MIN_COL_WIDTH; n];
|
||||
for ci in 0..n {
|
||||
let header = layout.col_label(ci);
|
||||
let w = header.width() as u16;
|
||||
if w > widths[ci] {
|
||||
widths[ci] = w;
|
||||
}
|
||||
}
|
||||
for ri in 0..layout.row_count() {
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
let s = self.records_cell_text(&layout, ri, ci);
|
||||
let w = s.width() as u16;
|
||||
if w > *wref {
|
||||
*wref = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add 2 cells of right-padding; cap at MAX_COL_WIDTH.
|
||||
widths
|
||||
.into_iter()
|
||||
.map(|w| (w + 2).min(MAX_COL_WIDTH))
|
||||
.collect()
|
||||
} else {
|
||||
vec![COL_WIDTH; layout.col_count()]
|
||||
};
|
||||
let col_widths = compute_col_widths(self.model, &layout, fmt_comma, fmt_decimals);
|
||||
|
||||
// Sub-column widths for row header area
|
||||
let sub_col_w = ROW_HEADER_WIDTH / n_row_levels as u16;
|
||||
let sub_widths: Vec<u16> = (0..n_row_levels)
|
||||
.map(|d| {
|
||||
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
|
||||
// ── Adaptive row header widths ───────────────────────────────
|
||||
let data_row_items: Vec<&Vec<String>> = layout
|
||||
.row_items
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let AxisEntry::DataItem(v) = e {
|
||||
@ -125,8 +73,23 @@ impl<'a> GridWidget<'a> {
|
||||
}
|
||||
})
|
||||
.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()
|
||||
.filter_map(|e| {
|
||||
if let AxisEntry::DataItem(v) = e {
|
||||
@ -143,11 +106,11 @@ impl<'a> GridWidget<'a> {
|
||||
.any(|e| matches!(e, AxisEntry::GroupHeader { .. }));
|
||||
|
||||
// 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 last = col_offset;
|
||||
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 {
|
||||
break;
|
||||
}
|
||||
@ -160,16 +123,16 @@ impl<'a> GridWidget<'a> {
|
||||
let col_x: Vec<u16> = {
|
||||
let mut v = vec![0u16; layout.col_count() + 1];
|
||||
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
|
||||
};
|
||||
let col_x_at = |ci: usize| -> u16 {
|
||||
area.x
|
||||
+ ROW_HEADER_WIDTH
|
||||
+ row_header_width
|
||||
+ 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 };
|
||||
|
||||
@ -187,7 +150,7 @@ impl<'a> GridWidget<'a> {
|
||||
buf.set_string(
|
||||
area.x,
|
||||
y,
|
||||
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
|
||||
format!("{:<width$}", "", width = row_header_width as usize),
|
||||
Style::default(),
|
||||
);
|
||||
let mut prev_group: Option<String> = None;
|
||||
@ -233,7 +196,7 @@ impl<'a> GridWidget<'a> {
|
||||
buf.set_string(
|
||||
area.x,
|
||||
y,
|
||||
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
|
||||
format!("{:<width$}", "", width = row_header_width as usize),
|
||||
Style::default(),
|
||||
);
|
||||
for ci in visible_col_range.clone() {
|
||||
@ -252,7 +215,17 @@ impl<'a> GridWidget<'a> {
|
||||
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)
|
||||
} else {
|
||||
header_style
|
||||
@ -301,8 +274,8 @@ impl<'a> GridWidget<'a> {
|
||||
y,
|
||||
format!(
|
||||
"{:<width$}",
|
||||
truncate(&label, ROW_HEADER_WIDTH as usize),
|
||||
width = ROW_HEADER_WIDTH as usize
|
||||
truncate(&label, row_header_width as usize),
|
||||
width = row_header_width as usize
|
||||
),
|
||||
group_header_style,
|
||||
);
|
||||
@ -340,9 +313,9 @@ impl<'a> GridWidget<'a> {
|
||||
if is_sel_row {
|
||||
let row_w = (area.x + area.width).saturating_sub(area.x);
|
||||
buf.set_string(
|
||||
area.x + ROW_HEADER_WIDTH,
|
||||
area.x + row_header_width,
|
||||
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),
|
||||
);
|
||||
}
|
||||
@ -378,23 +351,15 @@ impl<'a> GridWidget<'a> {
|
||||
}
|
||||
let cw = col_w_at(ci) as usize;
|
||||
|
||||
let (cell_str, value) = if layout.is_records_mode() {
|
||||
let s = self.records_cell_text(&layout, ri, ci);
|
||||
// In records mode the value is a string, not aggregated
|
||||
let v = if !s.is_empty() {
|
||||
Some(crate::model::cell::CellValue::Text(s.clone()))
|
||||
// Check pending drill edits first, then use display_text
|
||||
let cell_str = if let Some(ds) = self.drill_state {
|
||||
let col_name = layout.col_label(ci);
|
||||
ds.pending_edits
|
||||
.get(&(ri, col_name))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(s, v)
|
||||
} else {
|
||||
let key = match layout.cell_key(ri, ci) {
|
||||
Some(k) => k,
|
||||
None => continue,
|
||||
};
|
||||
let value = self.model.evaluate_aggregated(&key, &layout.none_cats);
|
||||
let s = format_value(value.as_ref(), fmt_comma, fmt_decimals);
|
||||
(s, value)
|
||||
layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals)
|
||||
};
|
||||
let is_selected = ri == sel_row && ci == sel_col;
|
||||
let is_search_match = !self.search_query.is_empty()
|
||||
@ -407,7 +372,12 @@ impl<'a> GridWidget<'a> {
|
||||
// "drill to edit". Records mode cells are always
|
||||
// directly editable, as are plain pivot cells.
|
||||
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 {
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
@ -416,13 +386,13 @@ impl<'a> GridWidget<'a> {
|
||||
} else if is_search_match {
|
||||
Style::default().fg(Color::Black).bg(Color::Yellow)
|
||||
} else if is_sel_row {
|
||||
let fg = if value.is_none() {
|
||||
let fg = if cell_str.is_empty() {
|
||||
Color::DarkGray
|
||||
} else {
|
||||
Color::White
|
||||
};
|
||||
Style::default().fg(fg).bg(ROW_HIGHLIGHT_BG)
|
||||
} else if value.is_none() {
|
||||
} else if cell_str.is_empty() {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default()
|
||||
@ -479,7 +449,7 @@ impl<'a> GridWidget<'a> {
|
||||
buf.set_string(
|
||||
area.x,
|
||||
y,
|
||||
format!("{:<width$}", "Total", width = ROW_HEADER_WIDTH as usize),
|
||||
format!("{:<width$}", "Total", width = row_header_width as usize),
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@ -551,52 +521,110 @@ impl<'a> Widget for GridWidget<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String {
|
||||
match v {
|
||||
Some(CellValue::Number(n)) => format_f64(*n, comma, decimals),
|
||||
Some(CellValue::Text(s)) => s.clone(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_number_format(fmt: &str) -> (bool, u8) {
|
||||
let comma = fmt.contains(',');
|
||||
let decimals = fmt
|
||||
.rfind('.')
|
||||
.and_then(|i| fmt[i + 1..].parse::<u8>().ok())
|
||||
.unwrap_or(0);
|
||||
(comma, decimals)
|
||||
}
|
||||
|
||||
pub fn format_f64(n: f64, comma: bool, decimals: u8) -> String {
|
||||
let formatted = format!("{:.prec$}", n, prec = decimals as usize);
|
||||
if !comma {
|
||||
return formatted;
|
||||
}
|
||||
// Split integer and decimal parts
|
||||
let (int_part, dec_part) = if let Some(dot) = formatted.find('.') {
|
||||
(&formatted[..dot], Some(&formatted[dot..]))
|
||||
/// Compute adaptive column widths for pivot mode (header labels + cell values).
|
||||
/// Header widths use the widest *individual* level label (not the joined
|
||||
/// multi-level string), matching how the grid renderer draws each level on
|
||||
/// its own row with repeat-suppression.
|
||||
pub fn compute_col_widths(model: &Model, layout: &GridLayout, fmt_comma: bool, fmt_decimals: u8) -> Vec<u16> {
|
||||
let n = layout.col_count();
|
||||
let mut widths = vec![0u16; n];
|
||||
// Measure individual header level labels
|
||||
let data_col_items: Vec<&Vec<String>> = layout
|
||||
.col_items
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let AxisEntry::DataItem(v) = e {
|
||||
Some(v)
|
||||
} else {
|
||||
(&formatted[..], None)
|
||||
};
|
||||
let is_neg = int_part.starts_with('-');
|
||||
let digits = if is_neg { &int_part[1..] } else { int_part };
|
||||
let mut result = String::new();
|
||||
for (idx, c) in digits.chars().rev().enumerate() {
|
||||
if idx > 0 && idx % 3 == 0 {
|
||||
result.push(',');
|
||||
None
|
||||
}
|
||||
result.push(c);
|
||||
})
|
||||
.collect();
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
if let Some(levels) = data_col_items.get(ci) {
|
||||
let max_level_w = levels.iter().map(|s| s.width() as u16).max().unwrap_or(0);
|
||||
if max_level_w > *wref {
|
||||
*wref = max_level_w;
|
||||
}
|
||||
if is_neg {
|
||||
result.push('-');
|
||||
}
|
||||
let mut out: String = result.chars().rev().collect();
|
||||
if let Some(dec) = dec_part {
|
||||
out.push_str(dec);
|
||||
}
|
||||
out
|
||||
// Measure cell content widths (works for both pivot and records modes)
|
||||
for ri in 0..layout.row_count() {
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
let s = layout.display_text(model, ri, ci, fmt_comma, fmt_decimals);
|
||||
let w = s.width() as u16;
|
||||
if w > *wref {
|
||||
*wref = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Measure total row (column sums) — pivot mode only
|
||||
if !layout.is_records_mode() && layout.row_count() > 0 {
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
let total: f64 = (0..layout.row_count())
|
||||
.filter_map(|ri| layout.cell_key(ri, ci))
|
||||
.map(|key| model.evaluate_aggregated_f64(&key, &layout.none_cats))
|
||||
.sum();
|
||||
let s = format_f64(total, fmt_comma, fmt_decimals);
|
||||
let w = s.width() as u16;
|
||||
if w > *wref {
|
||||
*wref = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
widths
|
||||
.into_iter()
|
||||
.map(|w| (w + 1).max(MIN_COL_WIDTH).min(MAX_COL_WIDTH))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compute the total row header width from the layout's row items.
|
||||
pub fn compute_row_header_width(layout: &GridLayout) -> u16 {
|
||||
let n_row_levels = layout.row_cats.len().max(1);
|
||||
let data_row_items: Vec<&Vec<String>> = layout
|
||||
.row_items
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let AxisEntry::DataItem(v) = e {
|
||||
Some(v)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
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();
|
||||
sub_widths.iter().sum()
|
||||
}
|
||||
|
||||
/// Count how many columns fit starting from `col_offset` given the available width.
|
||||
pub fn compute_visible_cols(col_widths: &[u16], row_header_width: u16, term_width: u16, col_offset: usize) -> usize {
|
||||
// Account for grid border (2 chars)
|
||||
let data_area_width = term_width.saturating_sub(2).saturating_sub(row_header_width);
|
||||
let mut acc = 0u16;
|
||||
let mut count = 0usize;
|
||||
for ci in col_offset..col_widths.len() {
|
||||
let w = col_widths[ci];
|
||||
if acc + w > data_area_width {
|
||||
break;
|
||||
}
|
||||
acc += w;
|
||||
count += 1;
|
||||
}
|
||||
count.max(1)
|
||||
}
|
||||
|
||||
// Re-export shared formatting functions
|
||||
pub use crate::format::{format_f64, parse_number_format};
|
||||
|
||||
fn truncate(s: &str, max_width: usize) -> String {
|
||||
let w = s.width();
|
||||
@ -637,7 +665,8 @@ mod tests {
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let bufs = std::collections::HashMap::new();
|
||||
GridWidget::new(model, &AppMode::Normal, "", &bufs, None).render(area, &mut buf);
|
||||
let layout = GridLayout::new(model, model.active_view());
|
||||
GridWidget::new(model, &layout, &AppMode::Normal, "", &bufs, None).render(area, &mut buf);
|
||||
buf
|
||||
}
|
||||
|
||||
@ -667,6 +696,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Minimal model: Type on Row, Month on Column.
|
||||
/// Every cell has a value so rows/cols survive pruning.
|
||||
fn two_cat_model() -> Model {
|
||||
let mut m = Model::new("Test");
|
||||
m.add_category("Type").unwrap(); // → Row
|
||||
@ -679,6 +709,15 @@ mod tests {
|
||||
c.add_item("Jan");
|
||||
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
|
||||
}
|
||||
|
||||
@ -738,10 +777,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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));
|
||||
// No digits should appear in the data area if nothing is set
|
||||
// (Total row shows "0" — exclude that from this check by looking for non-zero)
|
||||
// Should not contain large numbers that weren't set
|
||||
assert!(!text.contains("100"), "unexpected '100' in:\n{text}");
|
||||
}
|
||||
|
||||
@ -873,6 +921,15 @@ mod tests {
|
||||
}
|
||||
m.active_view_mut()
|
||||
.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));
|
||||
// Multi-level row headers: category values shown separately, not joined with /
|
||||
@ -936,6 +993,13 @@ mod tests {
|
||||
}
|
||||
m.active_view_mut()
|
||||
.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));
|
||||
// Multi-level column headers: category values shown separately, not joined with /
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
pub mod app;
|
||||
pub mod cat_tree;
|
||||
pub mod category_panel;
|
||||
pub mod effect;
|
||||
pub mod formula_panel;
|
||||
|
||||
@ -36,7 +36,7 @@ impl<'a> Widget for ViewPanel<'a> {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style)
|
||||
.title(" Views [Enter] switch [n]ew [d]elete ");
|
||||
.title(" Views ");
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
|
||||
@ -1,7 +1,17 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::view::{Axis, View};
|
||||
|
||||
/// Extract (record_index, dim_name) from a synthetic records-mode CellKey.
|
||||
/// Returns None for normal pivot-mode keys.
|
||||
pub fn synthetic_record_info(key: &CellKey) -> Option<(usize, String)> {
|
||||
let idx: usize = key.get("_Index")?.parse().ok()?;
|
||||
let dim = key.get("_Dim")?.to_string();
|
||||
Some((idx, dim))
|
||||
}
|
||||
|
||||
/// One entry on a grid axis: either a visual group header or a data-item tuple.
|
||||
///
|
||||
/// `GroupHeader` entries are always visible so the user can see the group label
|
||||
@ -30,8 +40,8 @@ pub struct GridLayout {
|
||||
/// Categories on `Axis::None` — hidden, implicitly aggregated.
|
||||
pub none_cats: Vec<String>,
|
||||
/// In records mode: the filtered cell list, one per row.
|
||||
/// None for normal pivot views.
|
||||
pub records: Option<Vec<(CellKey, CellValue)>>,
|
||||
/// None for normal pivot views. Rc for cheap sharing.
|
||||
pub records: Option<Rc<Vec<(CellKey, CellValue)>>>,
|
||||
}
|
||||
|
||||
impl GridLayout {
|
||||
@ -40,12 +50,11 @@ impl GridLayout {
|
||||
pub fn with_frozen_records(
|
||||
model: &Model,
|
||||
view: &View,
|
||||
frozen_records: Option<Vec<(CellKey, CellValue)>>,
|
||||
frozen_records: Option<Rc<Vec<(CellKey, CellValue)>>>,
|
||||
) -> Self {
|
||||
let mut layout = Self::new(model, view);
|
||||
if layout.is_records_mode() {
|
||||
if let Some(records) = frozen_records {
|
||||
// Re-build with the frozen records instead
|
||||
let row_items: Vec<AxisEntry> = (0..records.len())
|
||||
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
||||
.collect();
|
||||
@ -53,6 +62,9 @@ impl GridLayout {
|
||||
layout.records = Some(records);
|
||||
}
|
||||
}
|
||||
if view.prune_empty {
|
||||
layout.prune_empty(model);
|
||||
}
|
||||
layout
|
||||
}
|
||||
|
||||
@ -152,10 +164,11 @@ impl GridLayout {
|
||||
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
||||
.collect();
|
||||
|
||||
// Synthesize col items: one per category + "Value"
|
||||
// Synthesize col items: one per non-virtual category + "Value"
|
||||
let cat_names: Vec<String> = model
|
||||
.category_names()
|
||||
.into_iter()
|
||||
.filter(|c| !c.starts_with('_'))
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let mut col_items: Vec<AxisEntry> = cat_names
|
||||
@ -171,7 +184,7 @@ impl GridLayout {
|
||||
row_items,
|
||||
col_items,
|
||||
none_cats,
|
||||
records: Some(records),
|
||||
records: Some(Rc::new(records)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,6 +208,104 @@ 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] = 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(Rc::new(new_records));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this layout is in records mode.
|
||||
pub fn is_records_mode(&self) -> bool {
|
||||
self.records.is_some()
|
||||
@ -246,18 +357,57 @@ impl GridLayout {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Resolve the display string for a synthetic records-mode CellKey.
|
||||
/// Returns None for non-synthetic (pivot) keys.
|
||||
pub fn resolve_display(&self, key: &CellKey) -> Option<String> {
|
||||
let (idx, dim) = synthetic_record_info(key)?;
|
||||
let records = self.records.as_ref()?;
|
||||
let (orig_key, value) = records.get(idx)?;
|
||||
if dim == "Value" {
|
||||
Some(value.to_string())
|
||||
} else {
|
||||
Some(orig_key.get(&dim).unwrap_or("").to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified display text for a cell at (row, col). Handles both pivot and
|
||||
/// records modes. In pivot mode, evaluates and formats the cell value.
|
||||
/// In records mode, resolves via the frozen records snapshot.
|
||||
pub fn display_text(
|
||||
&self,
|
||||
model: &Model,
|
||||
row: usize,
|
||||
col: usize,
|
||||
fmt_comma: bool,
|
||||
fmt_decimals: u8,
|
||||
) -> String {
|
||||
if self.is_records_mode() {
|
||||
self.records_display(row, col).unwrap_or_default()
|
||||
} else {
|
||||
self.cell_key(row, col)
|
||||
.and_then(|key| model.evaluate_aggregated(&key, &self.none_cats))
|
||||
.map(|v| crate::format::format_value(Some(&v), fmt_comma, fmt_decimals))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the CellKey for the data cell at (row, col), including the active
|
||||
/// page-axis filter. Returns None if row or col is out of bounds.
|
||||
/// In records mode: returns the real underlying CellKey when the column
|
||||
/// is "Value" (editable); returns None for coord columns (read-only).
|
||||
/// In records mode: returns a synthetic `(_Index, _Dim)` key for every column.
|
||||
pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> {
|
||||
if let Some(records) = &self.records {
|
||||
// Records mode: only the Value column maps to a real, editable cell.
|
||||
if self.col_label(col) == "Value" {
|
||||
return records.get(row).map(|(k, _)| k.clone());
|
||||
} else {
|
||||
if self.records.is_some() {
|
||||
let records = self.records.as_ref().unwrap();
|
||||
if row >= records.len() {
|
||||
return None;
|
||||
}
|
||||
let col_label = self.col_label(col);
|
||||
if col_label.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(CellKey::new(vec![
|
||||
("_Index".to_string(), row.to_string()),
|
||||
("_Dim".to_string(), col_label),
|
||||
]));
|
||||
}
|
||||
let row_item = self
|
||||
.row_items
|
||||
@ -421,7 +571,7 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry>
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AxisEntry, GridLayout};
|
||||
use super::{synthetic_record_info, AxisEntry, GridLayout};
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::view::Axis;
|
||||
@ -450,6 +600,30 @@ mod tests {
|
||||
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]
|
||||
fn records_mode_activated_when_index_and_dim_on_axes() {
|
||||
let mut m = records_model();
|
||||
@ -462,40 +636,66 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_mode_cell_key_editable_for_value_column() {
|
||||
fn records_mode_cell_key_returns_synthetic_for_all_columns() {
|
||||
let mut m = records_model();
|
||||
let v = m.active_view_mut();
|
||||
v.set_axis("_Index", Axis::Row);
|
||||
v.set_axis("_Dim", Axis::Column);
|
||||
let layout = GridLayout::new(&m, m.active_view());
|
||||
assert!(layout.is_records_mode());
|
||||
// Find the "Value" column index
|
||||
let cols: Vec<String> = (0..layout.col_count()).map(|i| layout.col_label(i)).collect();
|
||||
// All columns return synthetic keys
|
||||
let value_col = cols.iter().position(|c| c == "Value").unwrap();
|
||||
// cell_key should be Some for Value column
|
||||
let key = layout.cell_key(0, value_col);
|
||||
assert!(key.is_some(), "Value column should be editable");
|
||||
// cell_key should be None for coord columns
|
||||
let key = layout.cell_key(0, value_col).unwrap();
|
||||
assert_eq!(key.get("_Index"), Some("0"));
|
||||
assert_eq!(key.get("_Dim"), Some("Value"));
|
||||
|
||||
let region_col = cols.iter().position(|c| c == "Region").unwrap();
|
||||
assert!(
|
||||
layout.cell_key(0, region_col).is_none(),
|
||||
"Region column should not be editable"
|
||||
);
|
||||
let key = layout.cell_key(0, region_col).unwrap();
|
||||
assert_eq!(key.get("_Index"), Some("0"));
|
||||
assert_eq!(key.get("_Dim"), Some("Region"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_mode_cell_key_maps_to_real_cell() {
|
||||
fn records_mode_resolve_display_returns_values() {
|
||||
let mut m = records_model();
|
||||
let v = m.active_view_mut();
|
||||
v.set_axis("_Index", Axis::Row);
|
||||
v.set_axis("_Dim", Axis::Column);
|
||||
let layout = GridLayout::new(&m, m.active_view());
|
||||
let cols: Vec<String> = (0..layout.col_count()).map(|i| layout.col_label(i)).collect();
|
||||
|
||||
// Value column resolves to the cell value
|
||||
let value_col = cols.iter().position(|c| c == "Value").unwrap();
|
||||
// The CellKey at (0, Value) should look up a real cell value
|
||||
let key = layout.cell_key(0, value_col).unwrap();
|
||||
let val = m.evaluate(&key);
|
||||
assert!(val.is_some(), "cell_key should resolve to a real cell");
|
||||
let display = layout.resolve_display(&key);
|
||||
assert!(display.is_some(), "Value column should resolve");
|
||||
|
||||
// Category column resolves to the coordinate value
|
||||
let region_col = cols.iter().position(|c| c == "Region").unwrap();
|
||||
let key = layout.cell_key(0, region_col).unwrap();
|
||||
let display = layout.resolve_display(&key).unwrap();
|
||||
assert!(!display.is_empty(), "Region column should resolve to a value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_record_info_returns_none_for_pivot_keys() {
|
||||
let key = CellKey::new(vec![
|
||||
("Region".to_string(), "East".to_string()),
|
||||
("Product".to_string(), "Shoes".to_string()),
|
||||
]);
|
||||
assert!(synthetic_record_info(&key).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_record_info_extracts_index_and_dim() {
|
||||
let key = CellKey::new(vec![
|
||||
("_Index".to_string(), "3".to_string()),
|
||||
("_Dim".to_string(), "Region".to_string()),
|
||||
]);
|
||||
let (idx, dim) = synthetic_record_info(&key).unwrap();
|
||||
assert_eq!(idx, 3);
|
||||
assert_eq!(dim, "Region");
|
||||
}
|
||||
|
||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||
|
||||
@ -3,5 +3,5 @@ pub mod layout;
|
||||
pub mod types;
|
||||
|
||||
pub use axis::Axis;
|
||||
pub use layout::{AxisEntry, GridLayout};
|
||||
pub use layout::{synthetic_record_info, AxisEntry, GridLayout};
|
||||
pub use types::View;
|
||||
|
||||
@ -4,6 +4,10 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::axis::Axis;
|
||||
|
||||
fn default_prune() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct View {
|
||||
pub name: String,
|
||||
@ -17,6 +21,9 @@ pub struct View {
|
||||
pub collapsed_groups: HashMap<String, HashSet<String>>,
|
||||
/// Number format string (e.g. ",.0f" for comma-separated integer)
|
||||
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
|
||||
pub row_offset: usize,
|
||||
pub col_offset: usize,
|
||||
@ -33,6 +40,7 @@ impl View {
|
||||
hidden_items: HashMap::new(),
|
||||
collapsed_groups: HashMap::new(),
|
||||
number_format: ",.0".to_string(),
|
||||
prune_empty: false,
|
||||
row_offset: 0,
|
||||
col_offset: 0,
|
||||
selected: (0, 0),
|
||||
@ -41,16 +49,47 @@ impl View {
|
||||
|
||||
pub fn on_category_added(&mut self, cat_name: &str) {
|
||||
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.
|
||||
// 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('_') {
|
||||
Axis::None
|
||||
} else {
|
||||
let rows = self.categories_on(Axis::Row).len();
|
||||
let cols = self.categories_on(Axis::Column).len();
|
||||
if rows == 0 {
|
||||
let regular_rows: Vec<String> = self
|
||||
.categories_on(Axis::Row)
|
||||
.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
|
||||
} 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
|
||||
} else {
|
||||
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) {
|
||||
if let Some(a) = self.category_axes.get_mut(cat_name) {
|
||||
*a = axis;
|
||||
|
||||
Reference in New Issue
Block a user