Compare commits
27 Commits
nemotron-b
...
command-al
| Author | SHA1 | Date | |
|---|---|---|---|
| c3ae848f54 | |||
| 1d5a04088b | |||
| a330412732 | |||
| 43015a41d8 | |||
| 29f922aaca | |||
| ee6970fed0 | |||
| 880c471ff6 | |||
| e166049bae | |||
| 2c05b64d51 | |||
| 86ac30c11d | |||
| 7cb09c11da | |||
| 3847cb4bfa | |||
| 3f76d98816 | |||
| 1c463a9f06 | |||
| 4739e667e7 | |||
| 91eaa7046a | |||
| 7ceadaf821 | |||
| 2fc4ea72a4 | |||
| db1aa4c6a5 | |||
| 7c7fbd3289 | |||
| 7d8c4a37a2 | |||
| 5df3f87bc1 | |||
| 95f2d00ae2 | |||
| fc2baf0e7e | |||
| 910e9233a3 | |||
| e193362de8 | |||
| 4d82fd434a |
44
.github/workflows/claude-code-review.yml
vendored
Normal file
44
.github/workflows/claude-code-review.yml
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
50
.github/workflows/claude.yml
vendored
Normal file
50
.github/workflows/claude.yml
vendored
Normal file
@ -0,0 +1,50 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -8,3 +8,7 @@ symbols.json
|
||||
profile.json
|
||||
profile.json.gz
|
||||
bench/*.txt
|
||||
|
||||
# Added by git-smart-commit
|
||||
*.patch
|
||||
*.improv
|
||||
|
||||
1230
src/command/cmd.rs
1230
src/command/cmd.rs
File diff suppressed because it is too large
Load Diff
@ -72,6 +72,8 @@ pub enum Binding {
|
||||
},
|
||||
/// A prefix sub-keymap (Emacs-style).
|
||||
Prefix(Arc<Keymap>),
|
||||
/// A sequence of commands executed in order, concatenating their effects.
|
||||
Sequence(Vec<(&'static str, Vec<String>)>),
|
||||
}
|
||||
|
||||
/// A keymap maps key patterns to bindings (command names or prefix sub-keymaps).
|
||||
@ -121,6 +123,17 @@ impl Keymap {
|
||||
.insert(KeyPattern::Key(key, mods), Binding::Prefix(sub));
|
||||
}
|
||||
|
||||
/// Bind a key to a sequence of commands (executed in order).
|
||||
pub fn bind_seq(
|
||||
&mut self,
|
||||
key: KeyCode,
|
||||
mods: KeyModifiers,
|
||||
steps: Vec<(&'static str, Vec<String>)>,
|
||||
) {
|
||||
self.bindings
|
||||
.insert(KeyPattern::Key(key, mods), Binding::Sequence(steps));
|
||||
}
|
||||
|
||||
/// Bind a catch-all for any Char key.
|
||||
pub fn bind_any_char(&mut self, name: &'static str, args: Vec<String>) {
|
||||
self.bindings
|
||||
@ -143,8 +156,7 @@ impl Keymap {
|
||||
.or_else(|| {
|
||||
// Retry Char keys without modifiers (shift is implicit in the char)
|
||||
if matches!(key, KeyCode::Char(_)) && mods != KeyModifiers::NONE {
|
||||
self.bindings
|
||||
.get(&KeyPattern::Key(key, KeyModifiers::NONE))
|
||||
self.bindings.get(&KeyPattern::Key(key, KeyModifiers::NONE))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@ -174,6 +186,14 @@ impl Keymap {
|
||||
Some(cmd.execute(ctx))
|
||||
}
|
||||
Binding::Prefix(sub) => Some(vec![Box::new(SetTransientKeymap(sub.clone()))]),
|
||||
Binding::Sequence(steps) => {
|
||||
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||
for (name, args) in steps {
|
||||
let cmd = registry.interactive(name, args, ctx).ok()?;
|
||||
effects.extend(cmd.execute(ctx));
|
||||
}
|
||||
Some(effects)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -266,10 +286,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");
|
||||
@ -357,7 +381,11 @@ impl KeymapSet {
|
||||
// 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, "add-record-row");
|
||||
normal.bind_seq(
|
||||
KeyCode::Char('o'),
|
||||
none,
|
||||
vec![("add-record-row", vec![]), ("enter-edit-at-cursor", vec![])],
|
||||
);
|
||||
|
||||
// Records mode toggle and prune toggle
|
||||
normal.bind(KeyCode::Char('R'), none, "toggle-records-mode");
|
||||
@ -381,7 +409,11 @@ impl KeymapSet {
|
||||
normal.bind_prefix(KeyCode::Char('y'), none, Arc::new(y_map));
|
||||
|
||||
let mut z_map = Keymap::new();
|
||||
z_map.bind(KeyCode::Char('Z'), none, "save-and-quit");
|
||||
z_map.bind_seq(
|
||||
KeyCode::Char('Z'),
|
||||
none,
|
||||
vec![("save", vec![]), ("force-quit", vec![])],
|
||||
);
|
||||
normal.bind_prefix(KeyCode::Char('Z'), none, Arc::new(z_map));
|
||||
|
||||
set.insert(ModeKey::Normal, Arc::new(normal));
|
||||
@ -422,9 +454,24 @@ 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()]);
|
||||
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 ───────────────────────────────────────────────
|
||||
@ -505,9 +552,24 @@ 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()]);
|
||||
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 ──────────────────────────────────────────────────
|
||||
@ -560,6 +622,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));
|
||||
@ -630,8 +693,8 @@ impl KeymapSet {
|
||||
let mut sm = Keymap::new();
|
||||
sm.bind(KeyCode::Esc, none, "exit-search-mode");
|
||||
sm.bind(KeyCode::Enter, none, "exit-search-mode");
|
||||
sm.bind(KeyCode::Backspace, none, "search-pop-char");
|
||||
sm.bind_any_char("search-append-char", vec![]);
|
||||
sm.bind_args(KeyCode::Backspace, none, "pop-char", vec!["search".into()]);
|
||||
sm.bind_any_char("append-char", vec!["search".into()]);
|
||||
set.insert(ModeKey::SearchMode, Arc::new(sm));
|
||||
|
||||
// ── Import wizard ────────────────────────────────────────────────
|
||||
|
||||
14
src/draw.rs
14
src/draw.rs
@ -257,6 +257,7 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
|
||||
f.render_widget(
|
||||
GridWidget::new(
|
||||
&app.model,
|
||||
&app.layout,
|
||||
&app.mode,
|
||||
&app.search_query,
|
||||
&app.buffers,
|
||||
@ -282,11 +283,7 @@ fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||
Some((format!("edit: {buf}▌"), Color::Green))
|
||||
}
|
||||
AppMode::FormulaEdit { .. } => {
|
||||
let buf = app
|
||||
.buffers
|
||||
.get("formula")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
let buf = app.buffers.get("formula").map(|s| s.as_str()).unwrap_or("");
|
||||
Some((format!("formula: {buf}▌"), Color::Cyan))
|
||||
}
|
||||
AppMode::CategoryAdd { .. } => {
|
||||
@ -302,11 +299,7 @@ fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||
Some((format!("add item to {category}: {buf}▌"), Color::Green))
|
||||
}
|
||||
AppMode::ExportPrompt { .. } => {
|
||||
let buf = app
|
||||
.buffers
|
||||
.get("export")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
let buf = app.buffers.get("export").map(|s| s.as_str()).unwrap_or("");
|
||||
Some((format!("export path: {buf}▌"), Color::Yellow))
|
||||
}
|
||||
_ => None,
|
||||
@ -349,7 +342,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_welcome(f: &mut Frame, area: Rect) {
|
||||
let popup = centered_popup(area, 58, 20);
|
||||
let inner = draw_popup_frame(f, popup, " Welcome to improvise ", Color::Blue);
|
||||
|
||||
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;
|
||||
|
||||
@ -93,7 +93,6 @@ impl std::fmt::Display for CellValue {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct InternedKey(pub Vec<(Symbol, Symbol)>);
|
||||
|
||||
|
||||
/// Serialized as a list of (key, value) pairs so CellKey doesn't need
|
||||
/// to implement the `Serialize`-as-string requirement for JSON object keys.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@ -180,9 +179,7 @@ impl DataStore {
|
||||
|
||||
/// Iterate over all cells, yielding (CellKey, &CellValue) pairs.
|
||||
pub fn iter_cells(&self) -> impl Iterator<Item = (CellKey, &CellValue)> {
|
||||
self.cells
|
||||
.iter()
|
||||
.map(|(k, v)| (self.to_cell_key(k), v))
|
||||
self.cells.iter().map(|(k, v)| (self.to_cell_key(k), v))
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &CellKey) {
|
||||
|
||||
@ -132,8 +132,7 @@ impl Model {
|
||||
self.data.remove(&k);
|
||||
}
|
||||
// Remove formulas targeting this category
|
||||
self.formulas
|
||||
.retain(|f| f.target_category != name);
|
||||
self.formulas.retain(|f| f.target_category != name);
|
||||
}
|
||||
|
||||
/// Remove an item from a category and all cells that reference it.
|
||||
|
||||
@ -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');
|
||||
|
||||
274
src/ui/app.rs
274
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>,
|
||||
@ -100,11 +102,18 @@ pub struct App {
|
||||
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,
|
||||
@ -131,17 +140,26 @@ impl App {
|
||||
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,
|
||||
@ -158,34 +176,39 @@ 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()
|
||||
}
|
||||
},
|
||||
// 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),
|
||||
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,
|
||||
}
|
||||
@ -195,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)
|
||||
@ -203,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 = {
|
||||
@ -247,7 +273,7 @@ impl App {
|
||||
pub fn hint_text(&self) -> &'static str {
|
||||
match &self.mode {
|
||||
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 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 d:delete Esc:back",
|
||||
@ -371,6 +397,190 @@ 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;
|
||||
|
||||
@ -49,7 +49,7 @@ impl<'a> Widget for CategoryPanel<'a> {
|
||||
let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add;
|
||||
|
||||
let (border_color, title) = if is_active {
|
||||
(Color::Cyan, " Categories n:new d:del Space:axis ")
|
||||
(Color::Cyan, " Categories ")
|
||||
} else {
|
||||
(Color::DarkGray, " Categories ")
|
||||
};
|
||||
|
||||
@ -96,16 +96,12 @@ impl Effect for RemoveFormula {
|
||||
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()
|
||||
};
|
||||
app.rebuild_layout();
|
||||
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 {
|
||||
@ -406,7 +402,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(),
|
||||
});
|
||||
}
|
||||
@ -480,9 +476,10 @@ pub struct SetDrillPendingEdit {
|
||||
impl Effect for SetDrillPendingEdit {
|
||||
fn apply(&self, app: &mut App) {
|
||||
if let Some(drill) = &mut app.drill_state {
|
||||
drill
|
||||
.pending_edits
|
||||
.insert((self.record_idx, self.col_name.clone()), self.new_value.clone());
|
||||
drill.pending_edits.insert(
|
||||
(self.record_idx, self.col_name.clone()),
|
||||
self.new_value.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -838,6 +835,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 {
|
||||
|
||||
280
src/ui/grid.rs
280
src/ui/grid.rs
@ -6,7 +6,6 @@ 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};
|
||||
@ -23,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>,
|
||||
@ -32,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>,
|
||||
@ -39,6 +40,7 @@ impl<'a> GridWidget<'a> {
|
||||
) -> Self {
|
||||
Self {
|
||||
model,
|
||||
layout,
|
||||
mode,
|
||||
search_query,
|
||||
buffers,
|
||||
@ -46,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;
|
||||
@ -71,56 +59,9 @@ impl<'a> GridWidget<'a> {
|
||||
let n_col_levels = layout.col_cats.len().max(1);
|
||||
let n_row_levels = layout.row_cats.len().max(1);
|
||||
|
||||
// ── Adaptive column widths ────────────────────────────────────
|
||||
// Size each column to fit its widest content (header + cell values)
|
||||
// plus 1 char gap. Minimum MIN_COL_WIDTH, capped at MAX_COL_WIDTH.
|
||||
let col_widths: Vec<u16> = {
|
||||
let n = layout.col_count();
|
||||
let mut widths = vec![0u16; n];
|
||||
// Measure column header labels
|
||||
for ci in 0..n {
|
||||
let header = layout.col_label(ci);
|
||||
let w = header.width() as u16;
|
||||
if w > widths[ci] {
|
||||
widths[ci] = w;
|
||||
}
|
||||
}
|
||||
// Measure cell content
|
||||
if layout.is_records_mode() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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
|
||||
.into_iter()
|
||||
.map(|w| (w + 1).max(MIN_COL_WIDTH).min(MAX_COL_WIDTH))
|
||||
.collect()
|
||||
};
|
||||
let col_widths = compute_col_widths(self.model, layout, fmt_comma, fmt_decimals);
|
||||
|
||||
// ── Adaptive row header widths ───────────────────────────────
|
||||
// Measure the widest label at each row-header level.
|
||||
let data_row_items: Vec<&Vec<String>> = layout
|
||||
.row_items
|
||||
.iter()
|
||||
@ -187,9 +128,7 @@ impl<'a> GridWidget<'a> {
|
||||
v
|
||||
};
|
||||
let col_x_at = |ci: usize| -> u16 {
|
||||
area.x
|
||||
+ row_header_width
|
||||
+ col_x[ci].saturating_sub(col_x[col_offset])
|
||||
area.x + 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(&MIN_COL_WIDTH) };
|
||||
|
||||
@ -240,7 +179,11 @@ impl<'a> GridWidget<'a> {
|
||||
buf.set_string(
|
||||
x,
|
||||
y,
|
||||
format!("{:<width$}", truncate(&label, cw.saturating_sub(1)), width = cw),
|
||||
format!(
|
||||
"{:<width$}",
|
||||
truncate(&label, cw.saturating_sub(1)),
|
||||
width = cw
|
||||
),
|
||||
group_style,
|
||||
);
|
||||
}
|
||||
@ -292,7 +235,11 @@ impl<'a> GridWidget<'a> {
|
||||
buf.set_string(
|
||||
x,
|
||||
y,
|
||||
format!("{:>width$}", truncate(&label, cw.saturating_sub(1)), width = cw),
|
||||
format!(
|
||||
"{:>width$}",
|
||||
truncate(&label, cw.saturating_sub(1)),
|
||||
width = cw
|
||||
),
|
||||
styled,
|
||||
);
|
||||
}
|
||||
@ -410,23 +357,17 @@ 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()
|
||||
@ -453,13 +394,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()
|
||||
@ -561,9 +502,9 @@ impl<'a> Widget for GridWidget<'a> {
|
||||
block.render(area, buf);
|
||||
|
||||
// Page axis bar
|
||||
let layout = GridLayout::new(self.model, self.model.active_view());
|
||||
if !layout.page_coords.is_empty() && inner.height > 0 {
|
||||
let page_info: Vec<String> = layout
|
||||
if !self.layout.page_coords.is_empty() && inner.height > 0 {
|
||||
let page_info: Vec<String> = self
|
||||
.layout
|
||||
.page_coords
|
||||
.iter()
|
||||
.map(|(cat, sel)| format!("{cat} = {sel}"))
|
||||
@ -588,52 +529,122 @@ 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();
|
||||
@ -666,6 +677,7 @@ mod tests {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::view::GridLayout;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
@ -674,7 +686,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
|
||||
}
|
||||
|
||||
@ -720,10 +733,7 @@ mod tests {
|
||||
// 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.set_cell(coord(&[("Type", t), ("Month", mo)]), CellValue::Number(1.0));
|
||||
}
|
||||
}
|
||||
m
|
||||
|
||||
@ -4,20 +4,12 @@ use ratatui::{
|
||||
style::{Color, Modifier, Style},
|
||||
widgets::Widget,
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::view::Axis;
|
||||
|
||||
fn axis_display(axis: Axis) -> (&'static str, Color) {
|
||||
match axis {
|
||||
Axis::Row => ("↕", Color::Green),
|
||||
Axis::Column => ("↔", Color::Blue),
|
||||
Axis::Page => ("☰", Color::Magenta),
|
||||
Axis::None => ("∅", Color::DarkGray),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TileBar<'a> {
|
||||
pub model: &'a Model,
|
||||
pub mode: &'a AppMode,
|
||||
@ -32,10 +24,26 @@ impl<'a> TileBar<'a> {
|
||||
tile_cat_idx,
|
||||
}
|
||||
}
|
||||
fn axis_display(axis: Axis) -> (&'static str, Color) {
|
||||
match axis {
|
||||
Axis::Row => ("|", Color::Green),
|
||||
Axis::Column => ("-", Color::Blue),
|
||||
Axis::Page => ("=", Color::Magenta),
|
||||
Axis::None => (".", Color::DarkGray),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Widget for TileBar<'a> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
// Clear the line to avoid stale characters from previous renders
|
||||
buf.set_string(
|
||||
area.x,
|
||||
area.y,
|
||||
" ".repeat(area.width as usize),
|
||||
Style::default(),
|
||||
);
|
||||
|
||||
let view = self.model.active_view();
|
||||
|
||||
let selected_cat_idx = if matches!(self.mode, AppMode::TileSelect) {
|
||||
@ -50,7 +58,7 @@ impl<'a> Widget for TileBar<'a> {
|
||||
|
||||
let cat_names: Vec<&str> = self.model.category_names();
|
||||
for (i, cat_name) in cat_names.iter().enumerate() {
|
||||
let (axis_symbol, axis_color) = axis_display(view.axis_of(cat_name));
|
||||
let (axis_symbol, axis_color) = TileBar::axis_display(view.axis_of(cat_name));
|
||||
let label = format!(" [{cat_name} {axis_symbol}] ");
|
||||
let is_selected = selected_cat_idx == Some(i);
|
||||
|
||||
@ -63,22 +71,23 @@ impl<'a> Widget for TileBar<'a> {
|
||||
Style::default().fg(axis_color)
|
||||
};
|
||||
|
||||
if x + label.len() as u16 > area.x + area.width {
|
||||
let label_w = label.width() as u16;
|
||||
if x + label_w > area.x + area.width {
|
||||
break;
|
||||
}
|
||||
buf.set_string(x, area.y, &label, style);
|
||||
x += label.len() as u16;
|
||||
x += label_w;
|
||||
}
|
||||
|
||||
// Hint
|
||||
if matches!(self.mode, AppMode::TileSelect) {
|
||||
let hint = " [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel";
|
||||
if x + hint.len() as u16 <= area.x + area.width {
|
||||
if x + hint.width() as u16 <= area.x + area.width {
|
||||
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
|
||||
}
|
||||
} else {
|
||||
let hint = " Ctrl+↑↓←→ to move tiles";
|
||||
if x + hint.len() as u16 <= area.x + area.width {
|
||||
if x + hint.width() as u16 <= area.x + area.width {
|
||||
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
@ -175,7 +184,7 @@ impl GridLayout {
|
||||
row_items,
|
||||
col_items,
|
||||
none_cats,
|
||||
records: Some(records),
|
||||
records: Some(Rc::new(records)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -220,14 +229,10 @@ impl GridLayout {
|
||||
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)
|
||||
has_value[ri][ci] = self
|
||||
.cell_key(ri, ci)
|
||||
.and_then(|k| model.evaluate_aggregated(&k, &self.none_cats))
|
||||
.is_some()
|
||||
};
|
||||
.is_some();
|
||||
}
|
||||
}
|
||||
|
||||
@ -297,7 +302,7 @@ impl GridLayout {
|
||||
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
||||
.collect();
|
||||
self.row_items = new_row_items;
|
||||
self.records = Some(new_records);
|
||||
self.records = Some(Rc::new(new_records));
|
||||
}
|
||||
}
|
||||
|
||||
@ -352,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
|
||||
@ -527,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;
|
||||
@ -566,10 +610,7 @@ mod tests {
|
||||
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()),
|
||||
]),
|
||||
CellKey::new(vec![("Row".into(), "A".into()), ("Col".into(), "X".into())]),
|
||||
CellValue::Number(1.0),
|
||||
);
|
||||
|
||||
@ -592,40 +633,73 @@ 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();
|
||||
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();
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user