Compare commits
5 Commits
command-al
...
gemma-bran
| Author | SHA1 | Date | |
|---|---|---|---|
| 42d869e4c2 | |||
| d32a6140b8 | |||
| 9251e37180 | |||
| 492d309277 | |||
| 85a459289d |
44
.github/workflows/claude-code-review.yml
vendored
44
.github/workflows/claude-code-review.yml
vendored
@ -1,44 +0,0 @@
|
|||||||
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
50
.github/workflows/claude.yml
vendored
@ -1,50 +0,0 @@
|
|||||||
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,7 +8,3 @@ symbols.json
|
|||||||
profile.json
|
profile.json
|
||||||
profile.json.gz
|
profile.json.gz
|
||||||
bench/*.txt
|
bench/*.txt
|
||||||
|
|
||||||
# Added by git-smart-commit
|
|
||||||
*.patch
|
|
||||||
*.improv
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -72,8 +72,6 @@ pub enum Binding {
|
|||||||
},
|
},
|
||||||
/// A prefix sub-keymap (Emacs-style).
|
/// A prefix sub-keymap (Emacs-style).
|
||||||
Prefix(Arc<Keymap>),
|
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).
|
/// A keymap maps key patterns to bindings (command names or prefix sub-keymaps).
|
||||||
@ -123,17 +121,6 @@ impl Keymap {
|
|||||||
.insert(KeyPattern::Key(key, mods), Binding::Prefix(sub));
|
.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.
|
/// Bind a catch-all for any Char key.
|
||||||
pub fn bind_any_char(&mut self, name: &'static str, args: Vec<String>) {
|
pub fn bind_any_char(&mut self, name: &'static str, args: Vec<String>) {
|
||||||
self.bindings
|
self.bindings
|
||||||
@ -156,7 +143,8 @@ impl Keymap {
|
|||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
// Retry Char keys without modifiers (shift is implicit in the char)
|
// Retry Char keys without modifiers (shift is implicit in the char)
|
||||||
if matches!(key, KeyCode::Char(_)) && mods != KeyModifiers::NONE {
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@ -186,14 +174,6 @@ impl Keymap {
|
|||||||
Some(cmd.execute(ctx))
|
Some(cmd.execute(ctx))
|
||||||
}
|
}
|
||||||
Binding::Prefix(sub) => Some(vec![Box::new(SetTransientKeymap(sub.clone()))]),
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -381,11 +361,7 @@ impl KeymapSet {
|
|||||||
// Drill into aggregated cell / view history / add row
|
// Drill into aggregated cell / view history / add row
|
||||||
normal.bind(KeyCode::Char('>'), none, "drill-into-cell");
|
normal.bind(KeyCode::Char('>'), none, "drill-into-cell");
|
||||||
normal.bind(KeyCode::Char('<'), none, "view-back");
|
normal.bind(KeyCode::Char('<'), none, "view-back");
|
||||||
normal.bind_seq(
|
normal.bind(KeyCode::Char('o'), none, "open-record-row");
|
||||||
KeyCode::Char('o'),
|
|
||||||
none,
|
|
||||||
vec![("add-record-row", vec![]), ("enter-edit-at-cursor", vec![])],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Records mode toggle and prune toggle
|
// Records mode toggle and prune toggle
|
||||||
normal.bind(KeyCode::Char('R'), none, "toggle-records-mode");
|
normal.bind(KeyCode::Char('R'), none, "toggle-records-mode");
|
||||||
@ -409,11 +385,7 @@ impl KeymapSet {
|
|||||||
normal.bind_prefix(KeyCode::Char('y'), none, Arc::new(y_map));
|
normal.bind_prefix(KeyCode::Char('y'), none, Arc::new(y_map));
|
||||||
|
|
||||||
let mut z_map = Keymap::new();
|
let mut z_map = Keymap::new();
|
||||||
z_map.bind_seq(
|
z_map.bind(KeyCode::Char('Z'), none, "save-and-quit");
|
||||||
KeyCode::Char('Z'),
|
|
||||||
none,
|
|
||||||
vec![("save", vec![]), ("force-quit", vec![])],
|
|
||||||
);
|
|
||||||
normal.bind_prefix(KeyCode::Char('Z'), none, Arc::new(z_map));
|
normal.bind_prefix(KeyCode::Char('Z'), none, Arc::new(z_map));
|
||||||
|
|
||||||
set.insert(ModeKey::Normal, Arc::new(normal));
|
set.insert(ModeKey::Normal, Arc::new(normal));
|
||||||
@ -454,24 +426,9 @@ impl KeymapSet {
|
|||||||
fp.bind(KeyCode::Char('o'), none, "enter-formula-edit");
|
fp.bind(KeyCode::Char('o'), none, "enter-formula-edit");
|
||||||
fp.bind(KeyCode::Char('d'), none, "delete-formula-at-cursor");
|
fp.bind(KeyCode::Char('d'), none, "delete-formula-at-cursor");
|
||||||
fp.bind(KeyCode::Delete, none, "delete-formula-at-cursor");
|
fp.bind(KeyCode::Delete, none, "delete-formula-at-cursor");
|
||||||
fp.bind_args(
|
fp.bind_args(KeyCode::Char('F'), none, "toggle-panel-and-focus", vec!["formula".into()]);
|
||||||
KeyCode::Char('F'),
|
fp.bind_args(KeyCode::Char('C'), none, "toggle-panel-and-focus", vec!["category".into()]);
|
||||||
none,
|
fp.bind_args(KeyCode::Char('V'), none, "toggle-panel-and-focus", vec!["view".into()]);
|
||||||
"toggle-panel-and-focus",
|
|
||||||
vec!["formula".into()],
|
|
||||||
);
|
|
||||||
fp.bind_args(
|
|
||||||
KeyCode::Char('C'),
|
|
||||||
none,
|
|
||||||
"toggle-panel-and-focus",
|
|
||||||
vec!["category".into()],
|
|
||||||
);
|
|
||||||
fp.bind_args(
|
|
||||||
KeyCode::Char('V'),
|
|
||||||
none,
|
|
||||||
"toggle-panel-and-focus",
|
|
||||||
vec!["view".into()],
|
|
||||||
);
|
|
||||||
set.insert(ModeKey::FormulaPanel, Arc::new(fp));
|
set.insert(ModeKey::FormulaPanel, Arc::new(fp));
|
||||||
|
|
||||||
// ── Category panel ───────────────────────────────────────────────
|
// ── Category panel ───────────────────────────────────────────────
|
||||||
@ -552,24 +509,9 @@ impl KeymapSet {
|
|||||||
vp.bind(KeyCode::Char('o'), none, "create-and-switch-view");
|
vp.bind(KeyCode::Char('o'), none, "create-and-switch-view");
|
||||||
vp.bind(KeyCode::Char('d'), none, "delete-view-at-cursor");
|
vp.bind(KeyCode::Char('d'), none, "delete-view-at-cursor");
|
||||||
vp.bind(KeyCode::Delete, none, "delete-view-at-cursor");
|
vp.bind(KeyCode::Delete, none, "delete-view-at-cursor");
|
||||||
vp.bind_args(
|
vp.bind_args(KeyCode::Char('V'), none, "toggle-panel-and-focus", vec!["view".into()]);
|
||||||
KeyCode::Char('V'),
|
vp.bind_args(KeyCode::Char('C'), none, "toggle-panel-and-focus", vec!["category".into()]);
|
||||||
none,
|
vp.bind_args(KeyCode::Char('F'), none, "toggle-panel-and-focus", vec!["formula".into()]);
|
||||||
"toggle-panel-and-focus",
|
|
||||||
vec!["view".into()],
|
|
||||||
);
|
|
||||||
vp.bind_args(
|
|
||||||
KeyCode::Char('C'),
|
|
||||||
none,
|
|
||||||
"toggle-panel-and-focus",
|
|
||||||
vec!["category".into()],
|
|
||||||
);
|
|
||||||
vp.bind_args(
|
|
||||||
KeyCode::Char('F'),
|
|
||||||
none,
|
|
||||||
"toggle-panel-and-focus",
|
|
||||||
vec!["formula".into()],
|
|
||||||
);
|
|
||||||
set.insert(ModeKey::ViewPanel, Arc::new(vp));
|
set.insert(ModeKey::ViewPanel, Arc::new(vp));
|
||||||
|
|
||||||
// ── Tile select ──────────────────────────────────────────────────
|
// ── Tile select ──────────────────────────────────────────────────
|
||||||
@ -693,8 +635,8 @@ impl KeymapSet {
|
|||||||
let mut sm = Keymap::new();
|
let mut sm = Keymap::new();
|
||||||
sm.bind(KeyCode::Esc, none, "exit-search-mode");
|
sm.bind(KeyCode::Esc, none, "exit-search-mode");
|
||||||
sm.bind(KeyCode::Enter, none, "exit-search-mode");
|
sm.bind(KeyCode::Enter, none, "exit-search-mode");
|
||||||
sm.bind_args(KeyCode::Backspace, none, "pop-char", vec!["search".into()]);
|
sm.bind(KeyCode::Backspace, none, "search-pop-char");
|
||||||
sm.bind_any_char("append-char", vec!["search".into()]);
|
sm.bind_any_char("search-append-char", vec![]);
|
||||||
set.insert(ModeKey::SearchMode, Arc::new(sm));
|
set.insert(ModeKey::SearchMode, Arc::new(sm));
|
||||||
|
|
||||||
// ── Import wizard ────────────────────────────────────────────────
|
// ── Import wizard ────────────────────────────────────────────────
|
||||||
|
|||||||
14
src/draw.rs
14
src/draw.rs
@ -257,7 +257,6 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
f.render_widget(
|
f.render_widget(
|
||||||
GridWidget::new(
|
GridWidget::new(
|
||||||
&app.model,
|
&app.model,
|
||||||
&app.layout,
|
|
||||||
&app.mode,
|
&app.mode,
|
||||||
&app.search_query,
|
&app.search_query,
|
||||||
&app.buffers,
|
&app.buffers,
|
||||||
@ -283,7 +282,11 @@ fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
Some((format!("edit: {buf}▌"), Color::Green))
|
Some((format!("edit: {buf}▌"), Color::Green))
|
||||||
}
|
}
|
||||||
AppMode::FormulaEdit { .. } => {
|
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))
|
Some((format!("formula: {buf}▌"), Color::Cyan))
|
||||||
}
|
}
|
||||||
AppMode::CategoryAdd { .. } => {
|
AppMode::CategoryAdd { .. } => {
|
||||||
@ -299,7 +302,11 @@ fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
Some((format!("add item to {category}: {buf}▌"), Color::Green))
|
Some((format!("add item to {category}: {buf}▌"), Color::Green))
|
||||||
}
|
}
|
||||||
AppMode::ExportPrompt { .. } => {
|
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))
|
Some((format!("export path: {buf}▌"), Color::Yellow))
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
@ -342,6 +349,7 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
f.render_widget(Paragraph::new(line).style(mode_style(&app.mode)), area);
|
f.render_widget(Paragraph::new(line).style(mode_style(&app.mode)), area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn draw_welcome(f: &mut Frame, area: Rect) {
|
fn draw_welcome(f: &mut Frame, area: Rect) {
|
||||||
let popup = centered_popup(area, 58, 20);
|
let popup = centered_popup(area, 58, 20);
|
||||||
let inner = draw_popup_frame(f, popup, " Welcome to improvise ", Color::Blue);
|
let inner = draw_popup_frame(f, popup, " Welcome to improvise ", Color::Blue);
|
||||||
|
|||||||
@ -93,6 +93,7 @@ impl std::fmt::Display for CellValue {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct InternedKey(pub Vec<(Symbol, Symbol)>);
|
pub struct InternedKey(pub Vec<(Symbol, Symbol)>);
|
||||||
|
|
||||||
|
|
||||||
/// Serialized as a list of (key, value) pairs so CellKey doesn't need
|
/// Serialized as a list of (key, value) pairs so CellKey doesn't need
|
||||||
/// to implement the `Serialize`-as-string requirement for JSON object keys.
|
/// to implement the `Serialize`-as-string requirement for JSON object keys.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@ -179,7 +180,9 @@ impl DataStore {
|
|||||||
|
|
||||||
/// Iterate over all cells, yielding (CellKey, &CellValue) pairs.
|
/// Iterate over all cells, yielding (CellKey, &CellValue) pairs.
|
||||||
pub fn iter_cells(&self) -> impl Iterator<Item = (CellKey, &CellValue)> {
|
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) {
|
pub fn remove(&mut self, key: &CellKey) {
|
||||||
|
|||||||
@ -132,7 +132,8 @@ impl Model {
|
|||||||
self.data.remove(&k);
|
self.data.remove(&k);
|
||||||
}
|
}
|
||||||
// Remove formulas targeting this category
|
// 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.
|
/// Remove an item from a category and all cells that reference it.
|
||||||
|
|||||||
@ -506,10 +506,7 @@ mod tests {
|
|||||||
let mut app = two_col_model();
|
let mut app = two_col_model();
|
||||||
// Total rows: A, B, C + R0..R9 = 13 rows. Last row = 12.
|
// Total rows: A, B, C + R0..R9 = 13 rows. Last row = 12.
|
||||||
for i in 0..10 {
|
for i in 0..10 {
|
||||||
app.model
|
app.model.category_mut("Row").unwrap().add_item(&format!("R{i}"));
|
||||||
.category_mut("Row")
|
|
||||||
.unwrap()
|
|
||||||
.add_item(&format!("R{i}"));
|
|
||||||
}
|
}
|
||||||
app.term_height = 13; // ~5 visible rows
|
app.term_height = 13; // ~5 visible rows
|
||||||
app.model.active_view_mut().selected = (0, 0);
|
app.model.active_view_mut().selected = (0, 0);
|
||||||
|
|||||||
@ -96,11 +96,7 @@ impl Effect for RemoveFormula {
|
|||||||
pub struct EnterEditAtCursor;
|
pub struct EnterEditAtCursor;
|
||||||
impl Effect for EnterEditAtCursor {
|
impl Effect for EnterEditAtCursor {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.rebuild_layout();
|
let ctx = app.cmd_context(crossterm::event::KeyCode::Null, crossterm::event::KeyModifiers::NONE);
|
||||||
let ctx = app.cmd_context(
|
|
||||||
crossterm::event::KeyCode::Null,
|
|
||||||
crossterm::event::KeyModifiers::NONE,
|
|
||||||
);
|
|
||||||
let value = ctx.display_value.clone();
|
let value = ctx.display_value.clone();
|
||||||
drop(ctx);
|
drop(ctx);
|
||||||
app.buffers.insert("edit".to_string(), value);
|
app.buffers.insert("edit".to_string(), value);
|
||||||
@ -476,10 +472,9 @@ pub struct SetDrillPendingEdit {
|
|||||||
impl Effect for SetDrillPendingEdit {
|
impl Effect for SetDrillPendingEdit {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
if let Some(drill) = &mut app.drill_state {
|
if let Some(drill) = &mut app.drill_state {
|
||||||
drill.pending_edits.insert(
|
drill
|
||||||
(self.record_idx, self.col_name.clone()),
|
.pending_edits
|
||||||
self.new_value.clone(),
|
.insert((self.record_idx, self.col_name.clone()), self.new_value.clone());
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,7 +59,7 @@ impl<'a> GridWidget<'a> {
|
|||||||
let n_col_levels = layout.col_cats.len().max(1);
|
let n_col_levels = layout.col_cats.len().max(1);
|
||||||
let n_row_levels = layout.row_cats.len().max(1);
|
let n_row_levels = layout.row_cats.len().max(1);
|
||||||
|
|
||||||
let col_widths = compute_col_widths(self.model, layout, fmt_comma, fmt_decimals);
|
let col_widths = compute_col_widths(self.model, &layout, fmt_comma, fmt_decimals);
|
||||||
|
|
||||||
// ── Adaptive row header widths ───────────────────────────────
|
// ── Adaptive row header widths ───────────────────────────────
|
||||||
let data_row_items: Vec<&Vec<String>> = layout
|
let data_row_items: Vec<&Vec<String>> = layout
|
||||||
@ -128,7 +128,9 @@ impl<'a> GridWidget<'a> {
|
|||||||
v
|
v
|
||||||
};
|
};
|
||||||
let col_x_at = |ci: usize| -> u16 {
|
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) };
|
let col_w_at = |ci: usize| -> u16 { *col_widths.get(ci).unwrap_or(&MIN_COL_WIDTH) };
|
||||||
|
|
||||||
@ -179,11 +181,7 @@ impl<'a> GridWidget<'a> {
|
|||||||
buf.set_string(
|
buf.set_string(
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
format!(
|
format!("{:<width$}", truncate(&label, cw.saturating_sub(1)), width = cw),
|
||||||
"{:<width$}",
|
|
||||||
truncate(&label, cw.saturating_sub(1)),
|
|
||||||
width = cw
|
|
||||||
),
|
|
||||||
group_style,
|
group_style,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -235,11 +233,7 @@ impl<'a> GridWidget<'a> {
|
|||||||
buf.set_string(
|
buf.set_string(
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
format!(
|
format!("{:>width$}", truncate(&label, cw.saturating_sub(1)), width = cw),
|
||||||
"{:>width$}",
|
|
||||||
truncate(&label, cw.saturating_sub(1)),
|
|
||||||
width = cw
|
|
||||||
),
|
|
||||||
styled,
|
styled,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -363,9 +357,7 @@ impl<'a> GridWidget<'a> {
|
|||||||
ds.pending_edits
|
ds.pending_edits
|
||||||
.get(&(ri, col_name))
|
.get(&(ri, col_name))
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals))
|
||||||
layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals)
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals)
|
layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals)
|
||||||
};
|
};
|
||||||
@ -502,9 +494,9 @@ impl<'a> Widget for GridWidget<'a> {
|
|||||||
block.render(area, buf);
|
block.render(area, buf);
|
||||||
|
|
||||||
// Page axis bar
|
// Page axis bar
|
||||||
if !self.layout.page_coords.is_empty() && inner.height > 0 {
|
let layout = GridLayout::new(self.model, self.model.active_view());
|
||||||
let page_info: Vec<String> = self
|
if !layout.page_coords.is_empty() && inner.height > 0 {
|
||||||
.layout
|
let page_info: Vec<String> = layout
|
||||||
.page_coords
|
.page_coords
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(cat, sel)| format!("{cat} = {sel}"))
|
.map(|(cat, sel)| format!("{cat} = {sel}"))
|
||||||
@ -533,12 +525,7 @@ impl<'a> Widget for GridWidget<'a> {
|
|||||||
/// Header widths use the widest *individual* level label (not the joined
|
/// Header widths use the widest *individual* level label (not the joined
|
||||||
/// multi-level string), matching how the grid renderer draws each level on
|
/// multi-level string), matching how the grid renderer draws each level on
|
||||||
/// its own row with repeat-suppression.
|
/// its own row with repeat-suppression.
|
||||||
pub fn compute_col_widths(
|
pub fn compute_col_widths(model: &Model, layout: &GridLayout, fmt_comma: bool, fmt_decimals: u8) -> Vec<u16> {
|
||||||
model: &Model,
|
|
||||||
layout: &GridLayout,
|
|
||||||
fmt_comma: bool,
|
|
||||||
fmt_decimals: u8,
|
|
||||||
) -> Vec<u16> {
|
|
||||||
let n = layout.col_count();
|
let n = layout.col_count();
|
||||||
let mut widths = vec![0u16; n];
|
let mut widths = vec![0u16; n];
|
||||||
// Measure individual header level labels
|
// Measure individual header level labels
|
||||||
@ -620,16 +607,9 @@ pub fn compute_row_header_width(layout: &GridLayout) -> u16 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Count how many columns fit starting from `col_offset` given the available width.
|
/// Count how many columns fit starting from `col_offset` given the available width.
|
||||||
pub fn compute_visible_cols(
|
pub fn compute_visible_cols(col_widths: &[u16], row_header_width: u16, term_width: u16, col_offset: usize) -> usize {
|
||||||
col_widths: &[u16],
|
|
||||||
row_header_width: u16,
|
|
||||||
term_width: u16,
|
|
||||||
col_offset: usize,
|
|
||||||
) -> usize {
|
|
||||||
// Account for grid border (2 chars)
|
// Account for grid border (2 chars)
|
||||||
let data_area_width = term_width
|
let data_area_width = term_width.saturating_sub(2).saturating_sub(row_header_width);
|
||||||
.saturating_sub(2)
|
|
||||||
.saturating_sub(row_header_width);
|
|
||||||
let mut acc = 0u16;
|
let mut acc = 0u16;
|
||||||
let mut count = 0usize;
|
let mut count = 0usize;
|
||||||
for ci in col_offset..col_widths.len() {
|
for ci in col_offset..col_widths.len() {
|
||||||
@ -677,7 +657,6 @@ mod tests {
|
|||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
use crate::view::GridLayout;
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -733,7 +712,10 @@ mod tests {
|
|||||||
// Fill every cell so nothing is pruned as empty.
|
// Fill every cell so nothing is pruned as empty.
|
||||||
for t in ["Food", "Clothing"] {
|
for t in ["Food", "Clothing"] {
|
||||||
for mo in ["Jan", "Feb"] {
|
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
|
m
|
||||||
|
|||||||
@ -4,12 +4,20 @@ use ratatui::{
|
|||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
widgets::Widget,
|
widgets::Widget,
|
||||||
};
|
};
|
||||||
use unicode_width::UnicodeWidthStr;
|
|
||||||
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
use crate::view::Axis;
|
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 struct TileBar<'a> {
|
||||||
pub model: &'a Model,
|
pub model: &'a Model,
|
||||||
pub mode: &'a AppMode,
|
pub mode: &'a AppMode,
|
||||||
@ -24,26 +32,10 @@ impl<'a> TileBar<'a> {
|
|||||||
tile_cat_idx,
|
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> {
|
impl<'a> Widget for TileBar<'a> {
|
||||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
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 view = self.model.active_view();
|
||||||
|
|
||||||
let selected_cat_idx = if matches!(self.mode, AppMode::TileSelect) {
|
let selected_cat_idx = if matches!(self.mode, AppMode::TileSelect) {
|
||||||
@ -58,7 +50,7 @@ impl<'a> Widget for TileBar<'a> {
|
|||||||
|
|
||||||
let cat_names: Vec<&str> = self.model.category_names();
|
let cat_names: Vec<&str> = self.model.category_names();
|
||||||
for (i, cat_name) in cat_names.iter().enumerate() {
|
for (i, cat_name) in cat_names.iter().enumerate() {
|
||||||
let (axis_symbol, axis_color) = TileBar::axis_display(view.axis_of(cat_name));
|
let (axis_symbol, axis_color) = axis_display(view.axis_of(cat_name));
|
||||||
let label = format!(" [{cat_name} {axis_symbol}] ");
|
let label = format!(" [{cat_name} {axis_symbol}] ");
|
||||||
let is_selected = selected_cat_idx == Some(i);
|
let is_selected = selected_cat_idx == Some(i);
|
||||||
|
|
||||||
@ -71,23 +63,22 @@ impl<'a> Widget for TileBar<'a> {
|
|||||||
Style::default().fg(axis_color)
|
Style::default().fg(axis_color)
|
||||||
};
|
};
|
||||||
|
|
||||||
let label_w = label.width() as u16;
|
if x + label.len() as u16 > area.x + area.width {
|
||||||
if x + label_w > area.x + area.width {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
buf.set_string(x, area.y, &label, style);
|
buf.set_string(x, area.y, &label, style);
|
||||||
x += label_w;
|
x += label.len() as u16;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hint
|
// Hint
|
||||||
if matches!(self.mode, AppMode::TileSelect) {
|
if matches!(self.mode, AppMode::TileSelect) {
|
||||||
let hint = " [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel";
|
let hint = " [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel";
|
||||||
if x + hint.width() as u16 <= area.x + area.width {
|
if x + hint.len() as u16 <= area.x + area.width {
|
||||||
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
|
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let hint = " Ctrl+↑↓←→ to move tiles";
|
let hint = " Ctrl+↑↓←→ to move tiles";
|
||||||
if x + hint.width() as u16 <= area.x + area.width {
|
if x + hint.len() as u16 <= area.x + area.width {
|
||||||
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
|
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -157,7 +157,7 @@ impl GridLayout {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
// Sort for deterministic ordering
|
// Sort for deterministic ordering
|
||||||
records.sort_by(|a, b| a.0 .0.cmp(&b.0 .0));
|
records.sort_by(|a, b| a.0.0.cmp(&b.0.0));
|
||||||
|
|
||||||
// Synthesize row items: one per record, labeled with its index
|
// Synthesize row items: one per record, labeled with its index
|
||||||
let row_items: Vec<AxisEntry> = (0..records.len())
|
let row_items: Vec<AxisEntry> = (0..records.len())
|
||||||
@ -200,7 +200,7 @@ impl GridLayout {
|
|||||||
// col_item is a category name
|
// col_item is a category name
|
||||||
let found = record
|
let found = record
|
||||||
.0
|
.0
|
||||||
.0
|
.0
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(c, _)| c == &col_item)
|
.find(|(c, _)| c == &col_item)
|
||||||
.map(|(_, v)| v.clone());
|
.map(|(_, v)| v.clone());
|
||||||
@ -610,7 +610,10 @@ mod tests {
|
|||||||
m.category_mut("Col").unwrap().add_item("Y");
|
m.category_mut("Col").unwrap().add_item("Y");
|
||||||
// Only X has data; Y is entirely empty
|
// Only X has data; Y is entirely empty
|
||||||
m.set_cell(
|
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),
|
CellValue::Number(1.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -640,9 +643,7 @@ mod tests {
|
|||||||
v.set_axis("_Dim", Axis::Column);
|
v.set_axis("_Dim", Axis::Column);
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&m, m.active_view());
|
||||||
assert!(layout.is_records_mode());
|
assert!(layout.is_records_mode());
|
||||||
let cols: Vec<String> = (0..layout.col_count())
|
let cols: Vec<String> = (0..layout.col_count()).map(|i| layout.col_label(i)).collect();
|
||||||
.map(|i| layout.col_label(i))
|
|
||||||
.collect();
|
|
||||||
// All columns return synthetic keys
|
// All columns return synthetic keys
|
||||||
let value_col = cols.iter().position(|c| c == "Value").unwrap();
|
let value_col = cols.iter().position(|c| c == "Value").unwrap();
|
||||||
let key = layout.cell_key(0, value_col).unwrap();
|
let key = layout.cell_key(0, value_col).unwrap();
|
||||||
@ -662,9 +663,7 @@ mod tests {
|
|||||||
v.set_axis("_Index", Axis::Row);
|
v.set_axis("_Index", Axis::Row);
|
||||||
v.set_axis("_Dim", Axis::Column);
|
v.set_axis("_Dim", Axis::Column);
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&m, m.active_view());
|
||||||
let cols: Vec<String> = (0..layout.col_count())
|
let cols: Vec<String> = (0..layout.col_count()).map(|i| layout.col_label(i)).collect();
|
||||||
.map(|i| layout.col_label(i))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Value column resolves to the cell value
|
// Value column resolves to the cell value
|
||||||
let value_col = cols.iter().position(|c| c == "Value").unwrap();
|
let value_col = cols.iter().position(|c| c == "Value").unwrap();
|
||||||
@ -676,10 +675,7 @@ mod tests {
|
|||||||
let region_col = cols.iter().position(|c| c == "Region").unwrap();
|
let region_col = cols.iter().position(|c| c == "Region").unwrap();
|
||||||
let key = layout.cell_key(0, region_col).unwrap();
|
let key = layout.cell_key(0, region_col).unwrap();
|
||||||
let display = layout.resolve_display(&key).unwrap();
|
let display = layout.resolve_display(&key).unwrap();
|
||||||
assert!(
|
assert!(!display.is_empty(), "Region column should resolve to a value");
|
||||||
!display.is_empty(),
|
|
||||||
"Region column should resolve to a value"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user