17 Commits

Author SHA1 Message Date
Edward Langley 7cb09c11da docs(cmd): update ToggleRecordsMode documentation
Update documentation for ToggleRecordsMode to clarify its behavior with the view
stack.

- Clarify that entering records mode creates a `_Records` view
- Clarify that leaving records mode navigates back to the previous view

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:40 -07:00
Edward Langley 3847cb4bfa chore: update .gitignore
Update .gitignore to include patch and improv files.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:40 -07:00
Edward Langley 3f76d98816 style: cleanup formatting and code style across the project
Clean up formatting and code style across the project.

- Remove unnecessary whitespace and empty lines in `src/model/cell.rs` ,
  `src/model/types.rs` , and `src/draw.rs` .
- Reformat long lines and function calls in `src/command/cmd.rs` ,
  `src/ui/grid.rs` , and `src/ui/tile_bar.rs` for better readability.
- Consolidate imports and simplify expressions in `src/command/keymap.rs` and
  `src/ui/app.rs` .

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:40 -07:00
Edward Langley 1c463a9f06 refactor(ui): improve TileBar rendering and width calculation
Update TileBar to use UnicodeWidthStr for accurate text width calculation and
improve axis display.

- Use `unicode_width::UnicodeWidthStr` for calculating label and hint widths
- Move `axis_display` to a static method on `TileBar`
- Update axis symbols for better visual clarity (e.g., '|', '-', '=', '.')
- Clear the tile bar area before rendering to prevent stale characters

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:40 -07:00
Edward Langley 4739e667e7 refactor(ui): use pre-computed layout in GridWidget
Refactor GridWidget and other UI components to use the pre-computed layout from
App instead of re-creating it on every render. This improves performance and
ensures consistency.

- Update GridWidget to take a reference to GridLayout
- Update GridWidget::new and render to use the provided layout
- Update App::cmd_context and other call sites to pass the layout
- Update tests to provide the layout to GridWidget

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:40 -07:00
Edward Langley 91eaa7046a refactor(view): use Rc for sharing records in GridLayout
Refactor GridLayout to use Rc for sharing records and simplify its structure.
This improves performance when cloning the layout and ensures consistent data
access.

- Use Rc<Vec<(CellKey, CellValue)>> for records in GridLayout
- Update with_frozen_records and other methods to handle Rc
- Simplify layout construction and sorting logic

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:40 -07:00
Edward Langley 7ceadaf821 refactor(ui): improve layout management and record sharing in App
Refactor App and DrillState to use Rc for sharing records, and introduce
rebuild_layout() to manage GridLayout lifecycle. This ensures the layout is
consistently updated when the model or drill state changes.

- Use Rc<Vec<(CellKey, CellValue)>> for DrillState.records to allow cheap
  cloning
- Add layout field to App
- Implement rebuild_layout() in App to refresh the GridLayout
- Update App::new and App::cmd_context to use the new layout management
- Ensure layout is rebuilt after applying effects and handling key events

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:39 -07:00
Edward Langley 2fc4ea72a4 feat(cmd): improve ToggleRecordsMode using view stack navigation
Improve ToggleRecordsMode by using the view stack for navigation. Instead of
manually calculating axes, it now creates a `_Records` view and switches to it,
or navigates back to the previous view if already in records mode.

- Use `ctx.layout.is_records_mode()` to detect state
- Use `effect::ViewBack` to exit records mode
- Use `effect::CreateView` and `effect::SwitchView` to enter records mode
- Simplify axis setting logic for records mode

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:39 -07:00
Edward Langley db1aa4c6a5 refactor(cmd): delegate cell information to GridLayout in CmdContext
Refactor CmdContext to use GridLayout for cell-related information instead of
storing redundant fields. This simplifies the context structure and ensures
commands always use the most up-to-date layout information.

- Add layout field to CmdContext
- Remove redundant row_count, col_count, cell_key, and none_cats fields
- Implement helper methods on CmdContext to delegate to layout
- Update all command implementations and tests to use the new methods

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:39 -07:00
Edward Langley 7c7fbd3289 refactor(ui): simplify panel mode mapping and add scroll tests
Adds a `mode()` method to the `Panel` enum to map panels to their
corresponding `AppMode`. Simplifies `TogglePanelAndFocus` in `cmd.rs`
to use this method instead of a manual match block.

Also adds regression tests in `app.rs` to verify that viewport
scrolling now correctly handles small terminal heights.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
2026-04-06 21:56:47 -07:00
Edward Langley 7d8c4a37a2 feat(cmd): unify jump-to-edge and fix viewport scrolling
Replaces separate jump-to-edge commands with a unified `JumpToEdge`
command. Simplifies the command registry using a macro and updates
`ScrollRows` to use a shared `viewport_effects` helper for consistent
scrolling behavior.

This fixes a bug where viewport scrolling was based on a hardcoded
constant (20) instead of the actual visible row count.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
2026-04-06 21:56:47 -07:00
Edward Langley 5df3f87bc1 refactor(view): unify cell handling for records and pivot modes
Unifies cell text retrieval and formatting across pivot and records modes.
Introduces `GridLayout::display_text` to centralize how cell content is
resolved, reducing duplication in `GridWidget` and `export_csv`.
Moves formatting logic from `src/ui/grid.rs` to a new dedicated `src/format.rs`
module to improve reusability.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
2026-04-06 21:56:47 -07:00
Edward Langley 95f2d00ae2 test: update cmd context in tests
Update the test context setup in `src/command/cmd.rs` to use the new
`display_value` field instead of the removed `records_col` and `records_value`
fields.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
2026-04-06 21:21:30 -07:00
Edward Langley fc2baf0e7e style: simplify panel titles
Clean up UI panel titles by removing redundant keyboard hints that are
already present in the app's status bar or are no longer needed.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
2026-04-06 21:21:30 -07:00
Edward Langley 910e9233a3 fix: use precise column widths for viewport scrolling
Improve grid viewport calculations by using actual column widths instead of
rough estimates. This ensures that the viewport scrolls correctly when the
cursor moves past the visible area.

- Move column width and visible column calculations to public functions in
  `src/ui/grid.rs`.
- Update `App::cmd_context` to use these precise calculations for `visible_cols`
  .
- Add a regression test to verify that `col_offset` scrolls when cursor moves
  past visible columns.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
2026-04-06 21:21:30 -07:00
Edward Langley e193362de8 feat: add page scrolling, open-row, and tab-advance
Add new navigation and editing capabilities to improve data entry and
browsing efficiency.

- Implement `PageScroll` command for PageUp/PageDown navigation.
- Implement `OpenRecordRow` command (Vim-style 'o') to add a row and enter edit
  mode.
- Implement `CommitAndAdvanceRight` command for Excel-style Tab navigation.
- Add keybindings for Home, End, PageUp, PageDown, and Tab.
- Update UI hints to reflect new Tab functionality.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
2026-04-06 21:21:29 -07:00
Edward Langley 4d82fd434a refactor!: unify records and pivot mode cell handling
Refactor records mode to use synthetic CellKeys (_Index, _Dim) for all columns,
allowing uniform handling of display values and edits across both pivot and
records modes.

- Introduce `synthetic_record_info` to extract metadata from synthetic keys.
- Update `GridLayout::cell_key` to return synthetic keys in records mode.
- Add `GridLayout::resolve_display` to handle value resolution for synthetic
  keys.
- Replace `records_col` and `records_value` in `CmdContext` with a unified
  `display_value`.
- Update `EditOrDrill` and `AddRecordRow` to use synthetic key detection.
- Refactor `CommitCellEdit` to use a shared `commit_cell_value` helper.

BREAKING CHANGE: CmdContext fields `records_col` and `records_value` are replaced by
`display_value` .
Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
2026-04-06 21:21:29 -07:00
9 changed files with 234 additions and 110 deletions
+4
View File
@@ -8,3 +8,7 @@ symbols.json
profile.json profile.json
profile.json.gz profile.json.gz
bench/*.txt bench/*.txt
# Added by git-smart-commit
*.patch
*.improv
+120 -53
View File
@@ -123,12 +123,7 @@ impl CmdRegistry {
/// Register a command with both a text parser and an interactive constructor. /// Register a command with both a text parser and an interactive constructor.
/// The name is derived from a prototype command instance. /// The name is derived from a prototype command instance.
pub fn register( pub fn register(&mut self, prototype: &dyn Cmd, parse: ParseFn, interactive: InteractiveFn) {
&mut self,
prototype: &dyn Cmd,
parse: ParseFn,
interactive: InteractiveFn,
) {
self.entries.push(CmdEntry { self.entries.push(CmdEntry {
name: prototype.name(), name: prototype.name(),
parse: Box::new(parse), parse: Box::new(parse),
@@ -317,7 +312,14 @@ impl Cmd for MoveSelection {
let col_max = self.cursor.col_count.saturating_sub(1); let col_max = self.cursor.col_count.saturating_sub(1);
let nr = (self.cursor.row as i32 + self.dr).clamp(0, row_max as i32) as usize; let nr = (self.cursor.row as i32 + self.dr).clamp(0, row_max as i32) as usize;
let nc = (self.cursor.col as i32 + self.dc).clamp(0, col_max as i32) as usize; let nc = (self.cursor.col as i32 + self.dc).clamp(0, col_max as i32) as usize;
viewport_effects(nr, nc, self.cursor.row_offset, self.cursor.col_offset, self.cursor.visible_rows, self.cursor.visible_cols) viewport_effects(
nr,
nc,
self.cursor.row_offset,
self.cursor.col_offset,
self.cursor.visible_rows,
self.cursor.visible_cols,
)
} }
} }
@@ -336,16 +338,27 @@ impl Cmd for JumpToEdge {
} }
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let (nr, nc) = if self.is_row { let (nr, nc) = if self.is_row {
let r = if self.end { self.cursor.row_count.saturating_sub(1) } else { 0 }; let r = if self.end {
self.cursor.row_count.saturating_sub(1)
} else {
0
};
(r, self.cursor.col) (r, self.cursor.col)
} else { } else {
let c = if self.end { self.cursor.col_count.saturating_sub(1) } else { 0 }; let c = if self.end {
self.cursor.col_count.saturating_sub(1)
} else {
0
};
(self.cursor.row, c) (self.cursor.row, c)
}; };
viewport_effects( viewport_effects(
nr, nc, nr,
self.cursor.row_offset, self.cursor.col_offset, nc,
self.cursor.visible_rows, self.cursor.visible_cols, self.cursor.row_offset,
self.cursor.col_offset,
self.cursor.visible_rows,
self.cursor.visible_cols,
) )
} }
} }
@@ -711,10 +724,7 @@ impl Cmd for AddRecordRow {
let coords: Vec<(String, String)> = page_cats let coords: Vec<(String, String)> = page_cats
.iter() .iter()
.map(|cat| { .map(|cat| {
let sel = view let sel = view.page_selection(cat).unwrap_or("").to_string();
.page_selection(cat)
.unwrap_or("")
.to_string();
(cat.clone(), sel) (cat.clone(), sel)
}) })
.filter(|(_, v)| !v.is_empty()) .filter(|(_, v)| !v.is_empty())
@@ -772,7 +782,14 @@ impl Cmd for EnterAdvance {
} else { } else {
(r, c) // already at bottom-right; stay (r, c) // already at bottom-right; stay
}; };
viewport_effects(nr, nc, self.cursor.row_offset, self.cursor.col_offset, self.cursor.visible_rows, self.cursor.visible_cols) viewport_effects(
nr,
nc,
self.cursor.row_offset,
self.cursor.col_offset,
self.cursor.visible_rows,
self.cursor.visible_cols,
)
} }
} }
@@ -1424,9 +1441,9 @@ impl Cmd for TogglePruneEmpty {
} }
} }
/// Toggle between records mode (_Index on Row, _Dim on Column) and /// Toggle between records mode and pivot mode using the view stack.
/// pivot mode (auto-assigned axes). In records mode every cell is shown /// Entering records mode creates a `_Records` view and switches to it.
/// as a flat row; in pivot mode the view is a cross-tab. /// Leaving records mode navigates back to the previous view.
#[derive(Debug)] #[derive(Debug)]
pub struct ToggleRecordsMode; pub struct ToggleRecordsMode;
impl Cmd for ToggleRecordsMode { impl Cmd for ToggleRecordsMode {
@@ -1916,7 +1933,10 @@ fn commit_cell_value(key: &CellKey, value: &str, effects: &mut Vec<Box<dyn Effec
effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Number(n)))); effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Number(n))));
effects.push(effect::mark_dirty()); effects.push(effect::mark_dirty());
} else { } else {
effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Text(value.to_string())))); effects.push(Box::new(effect::SetCell(
key.clone(),
CellValue::Text(value.to_string()),
)));
effects.push(effect::mark_dirty()); effects.push(effect::mark_dirty());
} }
} }
@@ -2420,7 +2440,9 @@ pub fn default_registry() -> CmdRegistry {
r.register_pure(&AddItemInGroupCmd(vec![]), AddItemInGroupCmd::parse); r.register_pure(&AddItemInGroupCmd(vec![]), AddItemInGroupCmd::parse);
r.register_pure(&SetCellCmd(vec![]), SetCellCmd::parse); r.register_pure(&SetCellCmd(vec![]), SetCellCmd::parse);
r.register( r.register(
&ClearCellCommand { key: CellKey::new(vec![]) }, &ClearCellCommand {
key: CellKey::new(vec![]),
},
|args| { |args| {
if args.is_empty() { if args.is_empty() {
return Err("clear-cell requires at least one Cat/Item coordinate".into()); return Err("clear-cell requires at least one Cat/Item coordinate".into());
@@ -2451,7 +2473,11 @@ pub fn default_registry() -> CmdRegistry {
// ── Navigation ─────────────────────────────────────────────────────── // ── Navigation ───────────────────────────────────────────────────────
r.register( r.register(
&MoveSelection { dr: 0, dc: 0, cursor: CursorState::default() }, &MoveSelection {
dr: 0,
dc: 0,
cursor: CursorState::default(),
},
|args| { |args| {
require_args("move-selection", args, 2)?; require_args("move-selection", args, 2)?;
let dr = args[0].parse::<i32>().map_err(|e| e.to_string())?; let dr = args[0].parse::<i32>().map_err(|e| e.to_string())?;
@@ -2486,9 +2512,28 @@ pub fn default_registry() -> CmdRegistry {
macro_rules! reg_jump { macro_rules! reg_jump {
($r:expr, $is_row:expr, $end:expr, $name:expr) => { ($r:expr, $is_row:expr, $end:expr, $name:expr) => {
$r.register( $r.register(
&JumpToEdge { cursor: CursorState::default(), is_row: $is_row, end: $end, cmd_name: $name }, &JumpToEdge {
|_| Ok(Box::new(JumpToEdge { cursor: CursorState::default(), is_row: $is_row, end: $end, cmd_name: $name })), cursor: CursorState::default(),
|_, ctx| Ok(Box::new(JumpToEdge { cursor: CursorState::from_ctx(ctx), is_row: $is_row, end: $end, cmd_name: $name })), is_row: $is_row,
end: $end,
cmd_name: $name,
},
|_| {
Ok(Box::new(JumpToEdge {
cursor: CursorState::default(),
is_row: $is_row,
end: $end,
cmd_name: $name,
}))
},
|_, ctx| {
Ok(Box::new(JumpToEdge {
cursor: CursorState::from_ctx(ctx),
is_row: $is_row,
end: $end,
cmd_name: $name,
}))
},
); );
}; };
} }
@@ -2497,7 +2542,10 @@ pub fn default_registry() -> CmdRegistry {
reg_jump!(r, false, false, "jump-first-col"); reg_jump!(r, false, false, "jump-first-col");
reg_jump!(r, false, true, "jump-last-col"); reg_jump!(r, false, true, "jump-last-col");
r.register( r.register(
&ScrollRows { delta: 0, cursor: CursorState::default() }, &ScrollRows {
delta: 0,
cursor: CursorState::default(),
},
|args| { |args| {
require_args("scroll-rows", args, 1)?; require_args("scroll-rows", args, 1)?;
let n = args[0].parse::<i32>().map_err(|e| e.to_string())?; let n = args[0].parse::<i32>().map_err(|e| e.to_string())?;
@@ -2525,7 +2573,10 @@ pub fn default_registry() -> CmdRegistry {
}, },
); );
r.register( r.register(
&PageScroll { direction: 0, cursor: CursorState::default() }, &PageScroll {
direction: 0,
cursor: CursorState::default(),
},
|args| { |args| {
require_args("page-scroll", args, 1)?; require_args("page-scroll", args, 1)?;
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?; let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
@@ -2544,7 +2595,9 @@ pub fn default_registry() -> CmdRegistry {
}, },
); );
r.register( r.register(
&EnterAdvance { cursor: CursorState::default() }, &EnterAdvance {
cursor: CursorState::default(),
},
|_| { |_| {
Ok(Box::new(EnterAdvance { Ok(Box::new(EnterAdvance {
cursor: CursorState { cursor: CursorState {
@@ -2568,7 +2621,9 @@ pub fn default_registry() -> CmdRegistry {
// ── Cell operations ────────────────────────────────────────────────── // ── Cell operations ──────────────────────────────────────────────────
r.register( r.register(
&YankCell { key: CellKey::new(vec![]) }, &YankCell {
key: CellKey::new(vec![]),
},
|args| { |args| {
if args.is_empty() { if args.is_empty() {
return Err("yank requires at least one Cat/Item coordinate".into()); return Err("yank requires at least one Cat/Item coordinate".into());
@@ -2612,20 +2667,16 @@ pub fn default_registry() -> CmdRegistry {
r.register_nullary(|| Box::new(SaveCmd)); r.register_nullary(|| Box::new(SaveCmd));
r.register_nullary(|| Box::new(EnterSearchMode)); r.register_nullary(|| Box::new(EnterSearchMode));
r.register( r.register(
&EnterEditMode { initial_value: String::new() }, &EnterEditMode {
initial_value: String::new(),
},
|args| { |args| {
let val = args.first().cloned().unwrap_or_default(); let val = args.first().cloned().unwrap_or_default();
Ok(Box::new(EnterEditMode { initial_value: val })) Ok(Box::new(EnterEditMode { initial_value: val }))
}, },
|_args, ctx| { |_args, ctx| {
let current = ctx
.cell_key
.as_ref()
.and_then(|k| ctx.model.get_cell(k).cloned())
.map(|v| v.to_string())
.unwrap_or_default();
Ok(Box::new(EnterEditMode { Ok(Box::new(EnterEditMode {
initial_value: current, initial_value: ctx.display_value.clone(),
})) }))
}, },
); );
@@ -2693,7 +2744,11 @@ pub fn default_registry() -> CmdRegistry {
// ── Panel operations ───────────────────────────────────────────────── // ── Panel operations ─────────────────────────────────────────────────
r.register( r.register(
&TogglePanelAndFocus { panel: Panel::Formula, open: true, focused: true }, &TogglePanelAndFocus {
panel: Panel::Formula,
open: true,
focused: true,
},
|args| { |args| {
// Parse: toggle-panel-and-focus <panel> [open] [focused] // Parse: toggle-panel-and-focus <panel> [open] [focused]
require_args("toggle-panel-and-focus", args, 1)?; require_args("toggle-panel-and-focus", args, 1)?;
@@ -2716,8 +2771,14 @@ pub fn default_registry() -> CmdRegistry {
Panel::View => ctx.view_panel_open, Panel::View => ctx.view_panel_open,
}; };
let currently_focused = match panel { let currently_focused = match panel {
Panel::Formula => matches!(ctx.mode, AppMode::FormulaPanel | AppMode::FormulaEdit { .. }), Panel::Formula => matches!(
Panel::Category => matches!(ctx.mode, AppMode::CategoryPanel | AppMode::CategoryAdd { .. } | AppMode::ItemAdd { .. }), ctx.mode,
AppMode::FormulaPanel | AppMode::FormulaEdit { .. }
),
Panel::Category => matches!(
ctx.mode,
AppMode::CategoryPanel | AppMode::CategoryAdd { .. } | AppMode::ItemAdd { .. }
),
Panel::View => matches!(ctx.mode, AppMode::ViewPanel), Panel::View => matches!(ctx.mode, AppMode::ViewPanel),
}; };
let (open, focused) = if currently_open && currently_focused { let (open, focused) = if currently_open && currently_focused {
@@ -2733,7 +2794,10 @@ pub fn default_registry() -> CmdRegistry {
}, },
); );
r.register( r.register(
&TogglePanelVisibility { panel: Panel::Formula, currently_open: false }, &TogglePanelVisibility {
panel: Panel::Formula,
currently_open: false,
},
|args| { |args| {
require_args("toggle-panel-visibility", args, 1)?; require_args("toggle-panel-visibility", args, 1)?;
let panel = parse_panel(&args[0])?; let panel = parse_panel(&args[0])?;
@@ -2757,7 +2821,11 @@ pub fn default_registry() -> CmdRegistry {
}, },
); );
r.register( r.register(
&CyclePanelFocus { formula_open: false, category_open: false, view_open: false }, &CyclePanelFocus {
formula_open: false,
category_open: false,
view_open: false,
},
|_| { |_| {
Ok(Box::new(CyclePanelFocus { Ok(Box::new(CyclePanelFocus {
formula_open: false, formula_open: false,
@@ -2774,7 +2842,12 @@ pub fn default_registry() -> CmdRegistry {
}, },
); );
r.register( r.register(
&MovePanelCursor { panel: Panel::Formula, delta: 0, current: 0, max: 0 }, &MovePanelCursor {
panel: Panel::Formula,
delta: 0,
current: 0,
max: 0,
},
|args| { |args| {
require_args("move-panel-cursor", args, 2)?; require_args("move-panel-cursor", args, 2)?;
let panel = parse_panel(&args[0])?; let panel = parse_panel(&args[0])?;
@@ -2803,9 +2876,7 @@ pub fn default_registry() -> CmdRegistry {
})) }))
}, },
); );
r.register_nullary(|| { r.register_nullary(|| Box::new(DeleteFormulaAtCursor));
Box::new(DeleteFormulaAtCursor)
});
r.register_nullary(|| Box::new(AddRecordRow)); r.register_nullary(|| Box::new(AddRecordRow));
r.register_nullary(|| Box::new(OpenRecordRow)); r.register_nullary(|| Box::new(OpenRecordRow));
r.register_nullary(|| Box::new(TogglePruneEmpty)); r.register_nullary(|| Box::new(TogglePruneEmpty));
@@ -2833,12 +2904,8 @@ pub fn default_registry() -> CmdRegistry {
}); });
// ── Grid operations ────────────────────────────────────────────────── // ── Grid operations ──────────────────────────────────────────────────
r.register_nullary(|| { r.register_nullary(|| Box::new(ToggleGroupUnderCursor));
Box::new(ToggleGroupUnderCursor) r.register_nullary(|| Box::new(ToggleColGroupUnderCursor));
});
r.register_nullary(|| {
Box::new(ToggleColGroupUnderCursor)
});
r.register_nullary(|| Box::new(HideSelectedRowItem)); r.register_nullary(|| Box::new(HideSelectedRowItem));
// ── Text buffer ────────────────────────────────────────────────────── // ── Text buffer ──────────────────────────────────────────────────────
+37 -8
View File
@@ -143,8 +143,7 @@ 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 self.bindings.get(&KeyPattern::Key(key, KeyModifiers::NONE))
.get(&KeyPattern::Key(key, KeyModifiers::NONE))
} else { } else {
None None
} }
@@ -426,9 +425,24 @@ impl KeymapSet {
fp.bind(KeyCode::Char('o'), none, "enter-formula-edit"); fp.bind(KeyCode::Char('o'), none, "enter-formula-edit");
fp.bind(KeyCode::Char('d'), none, "delete-formula-at-cursor"); fp.bind(KeyCode::Char('d'), none, "delete-formula-at-cursor");
fp.bind(KeyCode::Delete, none, "delete-formula-at-cursor"); fp.bind(KeyCode::Delete, none, "delete-formula-at-cursor");
fp.bind_args(KeyCode::Char('F'), none, "toggle-panel-and-focus", vec!["formula".into()]); fp.bind_args(
fp.bind_args(KeyCode::Char('C'), none, "toggle-panel-and-focus", vec!["category".into()]); KeyCode::Char('F'),
fp.bind_args(KeyCode::Char('V'), none, "toggle-panel-and-focus", vec!["view".into()]); none,
"toggle-panel-and-focus",
vec!["formula".into()],
);
fp.bind_args(
KeyCode::Char('C'),
none,
"toggle-panel-and-focus",
vec!["category".into()],
);
fp.bind_args(
KeyCode::Char('V'),
none,
"toggle-panel-and-focus",
vec!["view".into()],
);
set.insert(ModeKey::FormulaPanel, Arc::new(fp)); set.insert(ModeKey::FormulaPanel, Arc::new(fp));
// ── Category panel ─────────────────────────────────────────────── // ── Category panel ───────────────────────────────────────────────
@@ -509,9 +523,24 @@ impl KeymapSet {
vp.bind(KeyCode::Char('o'), none, "create-and-switch-view"); vp.bind(KeyCode::Char('o'), none, "create-and-switch-view");
vp.bind(KeyCode::Char('d'), none, "delete-view-at-cursor"); vp.bind(KeyCode::Char('d'), none, "delete-view-at-cursor");
vp.bind(KeyCode::Delete, none, "delete-view-at-cursor"); vp.bind(KeyCode::Delete, none, "delete-view-at-cursor");
vp.bind_args(KeyCode::Char('V'), none, "toggle-panel-and-focus", vec!["view".into()]); vp.bind_args(
vp.bind_args(KeyCode::Char('C'), none, "toggle-panel-and-focus", vec!["category".into()]); KeyCode::Char('V'),
vp.bind_args(KeyCode::Char('F'), none, "toggle-panel-and-focus", vec!["formula".into()]); none,
"toggle-panel-and-focus",
vec!["view".into()],
);
vp.bind_args(
KeyCode::Char('C'),
none,
"toggle-panel-and-focus",
vec!["category".into()],
);
vp.bind_args(
KeyCode::Char('F'),
none,
"toggle-panel-and-focus",
vec!["formula".into()],
);
set.insert(ModeKey::ViewPanel, Arc::new(vp)); set.insert(ModeKey::ViewPanel, Arc::new(vp));
// ── Tile select ────────────────────────────────────────────────── // ── Tile select ──────────────────────────────────────────────────
+3 -11
View File
@@ -257,6 +257,7 @@ 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,
@@ -282,11 +283,7 @@ 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 let buf = app.buffers.get("formula").map(|s| s.as_str()).unwrap_or("");
.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 { .. } => {
@@ -302,11 +299,7 @@ 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 let buf = app.buffers.get("export").map(|s| s.as_str()).unwrap_or("");
.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,
@@ -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); 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);
+1 -4
View File
@@ -93,7 +93,6 @@ 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)]
@@ -180,9 +179,7 @@ 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 self.cells.iter().map(|(k, v)| (self.to_cell_key(k), v))
.iter()
.map(|(k, v)| (self.to_cell_key(k), v))
} }
pub fn remove(&mut self, key: &CellKey) { pub fn remove(&mut self, key: &CellKey) {
+1 -2
View File
@@ -132,8 +132,7 @@ impl Model {
self.data.remove(&k); self.data.remove(&k);
} }
// Remove formulas targeting this category // Remove formulas targeting this category
self.formulas self.formulas.retain(|f| f.target_category != name);
.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.
+34 -16
View File
@@ -128,9 +128,7 @@ impl<'a> GridWidget<'a> {
v v
}; };
let col_x_at = |ci: usize| -> u16 { let col_x_at = |ci: usize| -> u16 {
area.x area.x + row_header_width + col_x[ci].saturating_sub(col_x[col_offset])
+ 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) };
@@ -181,7 +179,11 @@ impl<'a> GridWidget<'a> {
buf.set_string( buf.set_string(
x, x,
y, y,
format!("{:<width$}", truncate(&label, cw.saturating_sub(1)), width = cw), format!(
"{:<width$}",
truncate(&label, cw.saturating_sub(1)),
width = cw
),
group_style, group_style,
); );
} }
@@ -233,7 +235,11 @@ impl<'a> GridWidget<'a> {
buf.set_string( buf.set_string(
x, x,
y, y,
format!("{:>width$}", truncate(&label, cw.saturating_sub(1)), width = cw), format!(
"{:>width$}",
truncate(&label, cw.saturating_sub(1)),
width = cw
),
styled, styled,
); );
} }
@@ -357,7 +363,9 @@ impl<'a> GridWidget<'a> {
ds.pending_edits ds.pending_edits
.get(&(ri, col_name)) .get(&(ri, col_name))
.cloned() .cloned()
.unwrap_or_else(|| layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals)) .unwrap_or_else(|| {
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)
}; };
@@ -494,9 +502,9 @@ impl<'a> Widget for GridWidget<'a> {
block.render(area, buf); block.render(area, buf);
// Page axis bar // Page axis bar
let layout = GridLayout::new(self.model, self.model.active_view()); if !self.layout.page_coords.is_empty() && inner.height > 0 {
if !layout.page_coords.is_empty() && inner.height > 0 { let page_info: Vec<String> = self
let page_info: Vec<String> = layout .layout
.page_coords .page_coords
.iter() .iter()
.map(|(cat, sel)| format!("{cat} = {sel}")) .map(|(cat, sel)| format!("{cat} = {sel}"))
@@ -525,7 +533,12 @@ 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(model: &Model, layout: &GridLayout, fmt_comma: bool, fmt_decimals: u8) -> Vec<u16> { pub fn compute_col_widths(
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
@@ -607,9 +620,16 @@ 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(col_widths: &[u16], row_header_width: u16, term_width: u16, col_offset: usize) -> usize { 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) // Account for grid border (2 chars)
let data_area_width = term_width.saturating_sub(2).saturating_sub(row_header_width); let data_area_width = term_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() {
@@ -657,6 +677,7 @@ 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 ───────────────────────────────────────────────────────────────
@@ -712,10 +733,7 @@ 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( m.set_cell(coord(&[("Type", t), ("Month", mo)]), CellValue::Number(1.0));
coord(&[("Type", t), ("Month", mo)]),
CellValue::Number(1.0),
);
} }
} }
m m
+22 -5
View File
@@ -32,10 +32,26 @@ 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) {
@@ -50,7 +66,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) = 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 label = format!(" [{cat_name} {axis_symbol}] ");
let is_selected = selected_cat_idx == Some(i); let is_selected = selected_cat_idx == Some(i);
@@ -63,22 +79,23 @@ impl<'a> Widget for TileBar<'a> {
Style::default().fg(axis_color) 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; break;
} }
buf.set_string(x, area.y, &label, style); buf.set_string(x, area.y, &label, style);
x += label.len() as u16; x += label_w;
} }
// 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.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)); 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.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)); buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
} }
} }
+8 -7
View File
@@ -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())
@@ -610,10 +610,7 @@ 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![ CellKey::new(vec![("Row".into(), "A".into()), ("Col".into(), "X".into())]),
("Row".into(), "A".into()),
("Col".into(), "X".into()),
]),
CellValue::Number(1.0), CellValue::Number(1.0),
); );
@@ -643,7 +640,9 @@ 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()).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 // 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();
@@ -663,7 +662,9 @@ 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()).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 // 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();