4 Commits

Author SHA1 Message Date
Edward Langley 00499fc2bf test(cmd): update tests for layout refactor
Updated unit tests in src/command/cmd.rs to use the new GridLayout-based
CmdContext and layout accessors. Tests now construct CmdContext with a
layout argument and verify behavior of navigation and selection commands
under the refactored layout logic. No functional changes to command logic;
only test updates.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (bartowski/nvidia_Nemotron-Cascade-2-30B-A3B-GGUF)
2026-04-07 09:16:25 -07:00
Edward Langley 178983bcbf feat(ui): add new edge/jump commands and keymap
Introduced new commands: JumpToEdge (first/last row/col), PageScroll, and
OpenRecordRow. Updated command registry to use these commands and unified
key handling. Added format module for formatting functions. Updated main.rs
to include format module. Updated keymap to bind new commands and page
scroll.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (bartowski/nvidia_Nemotron-Cascade-2-30B-A3B-GGUF)
2026-04-07 09:16:25 -07:00
Edward Langley e09ddf71a7 refactor!(ui): use GridLayout for layout and display
Rebuilt App to hold a GridLayout and recompute it on state changes. Updated
cmd_context to use layout and display_value. Replaced manual width
calculations with compute_col_widths and compute_visible_cols. Updated
GridWidget to use layout and drill_state. Added Panel::mode helper and
updated UI titles. Fixed display logic for records mode using
layout.display_text.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (bartowski/nvidia_Nemotron-Cascade-2-30B-A3B-GGUF)
2026-04-07 09:16:25 -07:00
Edward Langley f8f8f537c3 refactor!(cmd): move CmdContext logic to GridLayout
Refactored CmdContext to delegate row/col counts, cell_key, none_cats, view
stacks, and records handling to GridLayout. Updated all command
implementations to use layout methods. Updated tests to construct
CmdContext with layout. Changed GridLayout to store records as Rc and added
synthetic_record_info helper. Updated view/layout.rs and view/mod.rs
accordingly.

BREAKING CHANGE: CmdContext fields changed; external callers must update to use layout
methods. GridLayout records field changed to Rc.
Co-Authored-By: fiddlerwoaroof/git-smart-commit (bartowski/nvidia_Nemotron-Cascade-2-30B-A3B-GGUF)
2026-04-07 09:16:25 -07:00
9 changed files with 110 additions and 234 deletions
-4
View File
@@ -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
+53 -120
View File
@@ -123,7 +123,12 @@ 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(&mut self, prototype: &dyn Cmd, parse: ParseFn, interactive: InteractiveFn) { pub fn register(
&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),
@@ -312,14 +317,7 @@ impl Cmd for MoveSelection {
let col_max = self.cursor.col_count.saturating_sub(1); let col_max = self.cursor.col_count.saturating_sub(1);
let nr = (self.cursor.row as i32 + self.dr).clamp(0, row_max as i32) as usize; let nr = (self.cursor.row as i32 + self.dr).clamp(0, row_max as i32) as usize;
let nc = (self.cursor.col as i32 + self.dc).clamp(0, col_max as i32) as usize; let nc = (self.cursor.col as i32 + self.dc).clamp(0, col_max as i32) as usize;
viewport_effects( viewport_effects(nr, nc, self.cursor.row_offset, self.cursor.col_offset, self.cursor.visible_rows, self.cursor.visible_cols)
nr,
nc,
self.cursor.row_offset,
self.cursor.col_offset,
self.cursor.visible_rows,
self.cursor.visible_cols,
)
} }
} }
@@ -338,27 +336,16 @@ 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 { let r = if self.end { self.cursor.row_count.saturating_sub(1) } else { 0 };
self.cursor.row_count.saturating_sub(1)
} else {
0
};
(r, self.cursor.col) (r, self.cursor.col)
} else { } else {
let c = if self.end { let c = if self.end { self.cursor.col_count.saturating_sub(1) } else { 0 };
self.cursor.col_count.saturating_sub(1)
} else {
0
};
(self.cursor.row, c) (self.cursor.row, c)
}; };
viewport_effects( viewport_effects(
nr, nr, nc,
nc, self.cursor.row_offset, self.cursor.col_offset,
self.cursor.row_offset, self.cursor.visible_rows, self.cursor.visible_cols,
self.cursor.col_offset,
self.cursor.visible_rows,
self.cursor.visible_cols,
) )
} }
} }
@@ -724,7 +711,10 @@ 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.page_selection(cat).unwrap_or("").to_string(); let sel = view
.page_selection(cat)
.unwrap_or("")
.to_string();
(cat.clone(), sel) (cat.clone(), sel)
}) })
.filter(|(_, v)| !v.is_empty()) .filter(|(_, v)| !v.is_empty())
@@ -782,14 +772,7 @@ impl Cmd for EnterAdvance {
} else { } else {
(r, c) // already at bottom-right; stay (r, c) // already at bottom-right; stay
}; };
viewport_effects( viewport_effects(nr, nc, self.cursor.row_offset, self.cursor.col_offset, self.cursor.visible_rows, self.cursor.visible_cols)
nr,
nc,
self.cursor.row_offset,
self.cursor.col_offset,
self.cursor.visible_rows,
self.cursor.visible_cols,
)
} }
} }
@@ -1441,9 +1424,9 @@ impl Cmd for TogglePruneEmpty {
} }
} }
/// Toggle between records mode and pivot mode using the view stack. /// Toggle between records mode (_Index on Row, _Dim on Column) and
/// Entering records mode creates a `_Records` view and switches to it. /// pivot mode (auto-assigned axes). In records mode every cell is shown
/// Leaving records mode navigates back to the previous view. /// as a flat row; in pivot mode the view is a cross-tab.
#[derive(Debug)] #[derive(Debug)]
pub struct ToggleRecordsMode; pub struct ToggleRecordsMode;
impl Cmd for ToggleRecordsMode { impl Cmd for ToggleRecordsMode {
@@ -1933,10 +1916,7 @@ 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( effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Text(value.to_string()))));
key.clone(),
CellValue::Text(value.to_string()),
)));
effects.push(effect::mark_dirty()); effects.push(effect::mark_dirty());
} }
} }
@@ -2440,9 +2420,7 @@ 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 { &ClearCellCommand { key: CellKey::new(vec![]) },
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());
@@ -2473,11 +2451,7 @@ pub fn default_registry() -> CmdRegistry {
// ── Navigation ─────────────────────────────────────────────────────── // ── Navigation ───────────────────────────────────────────────────────
r.register( r.register(
&MoveSelection { &MoveSelection { dr: 0, dc: 0, cursor: CursorState::default() },
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())?;
@@ -2512,28 +2486,9 @@ 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 { &JumpToEdge { cursor: CursorState::default(), is_row: $is_row, end: $end, cmd_name: $name },
cursor: CursorState::default(), |_| Ok(Box::new(JumpToEdge { cursor: CursorState::default(), is_row: $is_row, end: $end, cmd_name: $name })),
is_row: $is_row, |_, ctx| Ok(Box::new(JumpToEdge { cursor: CursorState::from_ctx(ctx), is_row: $is_row, end: $end, cmd_name: $name })),
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,
}))
},
); );
}; };
} }
@@ -2542,10 +2497,7 @@ 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 { &ScrollRows { delta: 0, cursor: CursorState::default() },
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())?;
@@ -2573,10 +2525,7 @@ pub fn default_registry() -> CmdRegistry {
}, },
); );
r.register( r.register(
&PageScroll { &PageScroll { direction: 0, cursor: CursorState::default() },
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())?;
@@ -2595,9 +2544,7 @@ pub fn default_registry() -> CmdRegistry {
}, },
); );
r.register( r.register(
&EnterAdvance { &EnterAdvance { cursor: CursorState::default() },
cursor: CursorState::default(),
},
|_| { |_| {
Ok(Box::new(EnterAdvance { Ok(Box::new(EnterAdvance {
cursor: CursorState { cursor: CursorState {
@@ -2621,9 +2568,7 @@ pub fn default_registry() -> CmdRegistry {
// ── Cell operations ────────────────────────────────────────────────── // ── Cell operations ──────────────────────────────────────────────────
r.register( r.register(
&YankCell { &YankCell { key: CellKey::new(vec![]) },
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());
@@ -2667,16 +2612,20 @@ 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 { &EnterEditMode { initial_value: String::new() },
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: ctx.display_value.clone(), initial_value: current,
})) }))
}, },
); );
@@ -2744,11 +2693,7 @@ pub fn default_registry() -> CmdRegistry {
// ── Panel operations ───────────────────────────────────────────────── // ── Panel operations ─────────────────────────────────────────────────
r.register( r.register(
&TogglePanelAndFocus { &TogglePanelAndFocus { panel: Panel::Formula, open: true, focused: true },
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)?;
@@ -2771,14 +2716,8 @@ 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!( Panel::Formula => matches!(ctx.mode, AppMode::FormulaPanel | AppMode::FormulaEdit { .. }),
ctx.mode, Panel::Category => matches!(ctx.mode, AppMode::CategoryPanel | AppMode::CategoryAdd { .. } | AppMode::ItemAdd { .. }),
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 {
@@ -2794,10 +2733,7 @@ pub fn default_registry() -> CmdRegistry {
}, },
); );
r.register( r.register(
&TogglePanelVisibility { &TogglePanelVisibility { panel: Panel::Formula, currently_open: false },
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])?;
@@ -2821,11 +2757,7 @@ pub fn default_registry() -> CmdRegistry {
}, },
); );
r.register( r.register(
&CyclePanelFocus { &CyclePanelFocus { formula_open: false, category_open: false, view_open: false },
formula_open: false,
category_open: false,
view_open: false,
},
|_| { |_| {
Ok(Box::new(CyclePanelFocus { Ok(Box::new(CyclePanelFocus {
formula_open: false, formula_open: false,
@@ -2842,12 +2774,7 @@ pub fn default_registry() -> CmdRegistry {
}, },
); );
r.register( r.register(
&MovePanelCursor { &MovePanelCursor { panel: Panel::Formula, delta: 0, current: 0, max: 0 },
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])?;
@@ -2876,7 +2803,9 @@ pub fn default_registry() -> CmdRegistry {
})) }))
}, },
); );
r.register_nullary(|| Box::new(DeleteFormulaAtCursor)); r.register_nullary(|| {
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));
@@ -2904,8 +2833,12 @@ pub fn default_registry() -> CmdRegistry {
}); });
// ── Grid operations ────────────────────────────────────────────────── // ── Grid operations ──────────────────────────────────────────────────
r.register_nullary(|| Box::new(ToggleGroupUnderCursor)); r.register_nullary(|| {
r.register_nullary(|| Box::new(ToggleColGroupUnderCursor)); Box::new(ToggleGroupUnderCursor)
});
r.register_nullary(|| {
Box::new(ToggleColGroupUnderCursor)
});
r.register_nullary(|| Box::new(HideSelectedRowItem)); r.register_nullary(|| Box::new(HideSelectedRowItem));
// ── Text buffer ────────────────────────────────────────────────────── // ── Text buffer ──────────────────────────────────────────────────────
+8 -37
View File
@@ -143,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
} }
@@ -425,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 ───────────────────────────────────────────────
@@ -523,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 ──────────────────────────────────────────────────
+11 -3
View File
@@ -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);
+4 -1
View File
@@ -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) {
+2 -1
View File
@@ -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.
+16 -34
View File
@@ -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
+5 -22
View File
@@ -32,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) {
@@ -66,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);
@@ -79,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));
} }
} }
+6 -7
View File
@@ -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();