5 Commits

Author SHA1 Message Date
Edward Langley 42d869e4c2 refactor(ui): integrate centralized layout and display logic
Update UI components and view layout to use the new centralized layout and
display logic.

- Update `CategoryPanel` to remove redundant title text.
- Update `ViewPanel` to remove redundant title text.
- Refactor `Effect` implementations to use `display_value` and `Rc` for
  records.
- Update `GridWidget` to use the centralized `layout` and `display_text` .
- Refactor `GridLayout` to support synthetic keys for records mode and
  unified display.
- Update `view` module to re-export `synthetic_record_info` .

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-07 09:29:45 -07:00
Edward Langley d32a6140b8 refactor(core): centralize formatting logic
Move formatting logic to a new `format` module and update `main.rs` and
`persistence` to use it.

- Create `src/format.rs` with shared formatting functions.
- Update `src/main.rs` to include the `format` module.
- Refactor `src/persistence/mod.rs` to use the new `display_text` logic via
  `GridLayout` .

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-07 09:29:45 -07:00
Edward Langley 9251e37180 refactor(ui): optimize record sharing and centralize layout management
Refactor `App` and `DrillState` to use `Rc` for efficient sharing of frozen
records and integrate a persistent `layout` field.

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

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-07 09:29:45 -07:00
Edward Langley 492d309277 feat(command): update keybindings for navigation and editing
Update keybindings to support new navigation commands and improve user
experience.

- Bind `Home` to `jump-first-col` and `End` to `jump-last-col` .
- Bind `PageUp` and `PageDown` to `page-scroll` .
- Update `o` keybinding from `add-record-row` to `open-record-row` .
- Bind `Tab` to `commit-and-advance-right` in editing mode.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-07 09:29:45 -07:00
Edward Langley 85a459289d refactor(command): unify layout access and navigation commands
Refactor `CmdContext` to delegate layout-related information (row/column
counts, categories, cell keys) to a `GridLayout` object.

- Add `layout` field to `CmdContext` .
- Implement helper methods on `CmdContext` to access layout data.
- Consolidate multiple jump commands ( `JumpToFirstRow` , `JumpToLastRow` ,
  `JumpToFirstCol` , `JumpToLastCol` ) into a single `JumpToEdge` command.
- Introduce `ScrollRows` and `PageScroll` commands for improved navigation.
- Update `CursorState` instantiation to use the new context structure.
- Update command registry to use the new unified commands and macros.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-07 09:29:45 -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();