refactor: make active_view and axis_of infallible

Both functions previously returned Option despite their invariants
guaranteeing a value: active_view always names an existing view
(maintained by new/switch_view/delete_view), and axis_of only returns
None for categories never registered with the view (a programming error).

Callers no longer need to handle the impossible None case, eliminating
~15 match/if-let Option guards across app.rs, dispatch.rs, grid.rs,
tile_bar.rs, and category_panel.rs.

Also adds Model::evaluate_f64 (returns 0.0 for empty cells) and collapses
the double match-on-axis pattern in tile_bar/category_panel into a single
axis_display(Axis) helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ed L
2026-03-24 09:00:25 -07:00
parent a2e519efcc
commit 6038cb2d81
9 changed files with 168 additions and 208 deletions

View File

@ -209,10 +209,9 @@ impl App {
// 0 = first col, $ = last col
(KeyCode::Char('0'), KeyModifiers::NONE) => {
if let Some(view) = self.model.active_view_mut() {
view.selected.1 = 0;
view.col_offset = 0;
}
let view = self.model.active_view_mut();
view.selected.1 = 0;
view.col_offset = 0;
}
(KeyCode::Char('$'), _) => { self.jump_to_last_col(); }
@ -304,7 +303,8 @@ impl App {
match (first, key.code) {
// gg = first row
('g', KeyCode::Char('g')) => {
if let Some(view) = self.model.active_view_mut() {
{
let view = self.model.active_view_mut();
view.selected = (0, view.selected.1);
view.row_offset = 0;
}
@ -510,9 +510,7 @@ impl App {
if rest.is_empty() {
self.status_msg = "Usage: :set-format <fmt> e.g. ,.0 ,.2 .4".to_string();
} else {
if let Some(view) = self.model.active_view_mut() {
view.number_format = rest.to_string();
}
self.model.active_view_mut().number_format = rest.to_string();
self.status_msg = format!("Number format set to '{rest}'");
self.dirty = true;
}
@ -636,9 +634,7 @@ impl App {
}
KeyCode::Enter | KeyCode::Char(' ') => {
if let Some(cat_name) = cat_names.get(self.cat_panel_cursor) {
if let Some(view) = self.model.active_view_mut() {
view.cycle_axis(cat_name);
}
self.model.active_view_mut().cycle_axis(cat_name);
}
}
// n — add a new category
@ -805,7 +801,7 @@ impl App {
}
KeyCode::Enter | KeyCode::Char(' ') => {
if let Some(name) = cat_names.get(cat_idx) {
if let Some(view) = self.model.active_view_mut() { view.cycle_axis(name); }
self.model.active_view_mut().cycle_axis(name);
self.dirty = true;
}
self.mode = AppMode::Normal;
@ -917,57 +913,42 @@ impl App {
fn move_selection(&mut self, dr: i32, dc: i32) {
let (row_max, col_max) = {
let view = match self.model.active_view() { Some(v) => v, None => return };
let layout = GridLayout::new(&self.model, view);
let layout = GridLayout::new(&self.model, self.model.active_view());
(layout.row_count().saturating_sub(1), layout.col_count().saturating_sub(1))
};
if let Some(view) = self.model.active_view_mut() {
let (r, c) = view.selected;
let nr = (r as i32 + dr).clamp(0, row_max as i32) as usize;
let nc = (c as i32 + dc).clamp(0, col_max as i32) as usize;
view.selected = (nr, nc);
// Keep cursor in visible area (approximate viewport: 20 rows, 8 cols)
if nr < view.row_offset { view.row_offset = nr; }
if nr >= view.row_offset + 20 { view.row_offset = nr.saturating_sub(19); }
if nc < view.col_offset { view.col_offset = nc; }
if nc >= view.col_offset + 8 { view.col_offset = nc.saturating_sub(7); }
}
let view = self.model.active_view_mut();
let (r, c) = view.selected;
let nr = (r as i32 + dr).clamp(0, row_max as i32) as usize;
let nc = (c as i32 + dc).clamp(0, col_max as i32) as usize;
view.selected = (nr, nc);
// Keep cursor in visible area (approximate viewport: 20 rows, 8 cols)
if nr < view.row_offset { view.row_offset = nr; }
if nr >= view.row_offset + 20 { view.row_offset = nr.saturating_sub(19); }
if nc < view.col_offset { view.col_offset = nc; }
if nc >= view.col_offset + 8 { view.col_offset = nc.saturating_sub(7); }
}
fn jump_to_last_row(&mut self) {
let count = {
let view = match self.model.active_view() { Some(v) => v, None => return };
GridLayout::new(&self.model, view).row_count().saturating_sub(1)
};
if let Some(view) = self.model.active_view_mut() {
view.selected.0 = count;
if count >= view.row_offset + 20 { view.row_offset = count.saturating_sub(19); }
}
let count = GridLayout::new(&self.model, self.model.active_view()).row_count().saturating_sub(1);
let view = self.model.active_view_mut();
view.selected.0 = count;
if count >= view.row_offset + 20 { view.row_offset = count.saturating_sub(19); }
}
fn jump_to_last_col(&mut self) {
let count = {
let view = match self.model.active_view() { Some(v) => v, None => return };
GridLayout::new(&self.model, view).col_count().saturating_sub(1)
};
if let Some(view) = self.model.active_view_mut() {
view.selected.1 = count;
if count >= view.col_offset + 8 { view.col_offset = count.saturating_sub(7); }
}
let count = GridLayout::new(&self.model, self.model.active_view()).col_count().saturating_sub(1);
let view = self.model.active_view_mut();
view.selected.1 = count;
if count >= view.col_offset + 8 { view.col_offset = count.saturating_sub(7); }
}
fn scroll_rows(&mut self, delta: i32) {
let row_max = {
let view = match self.model.active_view() { Some(v) => v, None => return };
GridLayout::new(&self.model, view).row_count().saturating_sub(1)
};
if let Some(view) = self.model.active_view_mut() {
let nr = (view.selected.0 as i32 + delta).clamp(0, row_max as i32) as usize;
view.selected.0 = nr;
if nr < view.row_offset { view.row_offset = nr; }
if nr >= view.row_offset + 20 { view.row_offset = nr.saturating_sub(19); }
}
let row_max = GridLayout::new(&self.model, self.model.active_view()).row_count().saturating_sub(1);
let view = self.model.active_view_mut();
let nr = (view.selected.0 as i32 + delta).clamp(0, row_max as i32) as usize;
view.selected.0 = nr;
if nr < view.row_offset { view.row_offset = nr; }
if nr >= view.row_offset + 20 { view.row_offset = nr.saturating_sub(19); }
}
/// Navigate to the next (`forward=true`) or previous (`forward=false`) cell
@ -976,10 +957,7 @@ impl App {
let query = self.search_query.to_lowercase();
if query.is_empty() { return; }
let view = match self.model.active_view() {
Some(v) => v,
None => return,
};
let view = self.model.active_view();
let layout = GridLayout::new(&self.model, view);
let (cur_row, cur_col) = view.selected;
@ -1022,7 +1000,8 @@ impl App {
if let Some(flat) = target_flat {
let ri = flat / total_cols;
let ci = flat % total_cols;
if let Some(view) = self.model.active_view_mut() {
{
let view = self.model.active_view_mut();
view.selected = (ri, ci);
// Adjust scroll offsets to keep cursor visible
if ri < view.row_offset { view.row_offset = ri; }
@ -1038,15 +1017,14 @@ impl App {
/// Gather (cat_name, items, current_idx) for all non-empty page categories.
fn page_cat_data(&self) -> Vec<(String, Vec<String>, usize)> {
let page_cats: Vec<String> = self.model.active_view()
.map(|v| v.categories_on(Axis::Page).into_iter().map(String::from).collect())
.unwrap_or_default();
.categories_on(Axis::Page).into_iter().map(String::from).collect();
page_cats.into_iter().filter_map(|cat| {
let items: Vec<String> = self.model.category(&cat)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default();
if items.is_empty() { return None; }
let current = self.model.active_view()
.and_then(|v| v.page_selection(&cat))
.page_selection(&cat)
.map(String::from)
.or_else(|| items.first().cloned())
.unwrap_or_default();
@ -1066,10 +1044,9 @@ impl App {
indices[i] += 1;
if indices[i] >= data[i].1.len() { indices[i] = 0; } else { carry = false; }
}
if let Some(view) = self.model.active_view_mut() {
for (i, (cat, items, _)) in data.iter().enumerate() {
view.set_page_selection(cat, &items[indices[i]]);
}
let view = self.model.active_view_mut();
for (i, (cat, items, _)) in data.iter().enumerate() {
view.set_page_selection(cat, &items[indices[i]]);
}
}
@ -1088,17 +1065,16 @@ impl App {
borrow = false;
}
}
if let Some(view) = self.model.active_view_mut() {
for (i, (cat, items, _)) in data.iter().enumerate() {
view.set_page_selection(cat, &items[indices[i]]);
}
let view = self.model.active_view_mut();
for (i, (cat, items, _)) in data.iter().enumerate() {
view.set_page_selection(cat, &items[indices[i]]);
}
}
// ── Cell key resolution ──────────────────────────────────────────────────
pub fn selected_cell_key(&self) -> Option<CellKey> {
let view = self.model.active_view()?;
let view = self.model.active_view();
let (sel_row, sel_col) = view.selected;
GridLayout::new(&self.model, view).cell_key(sel_row, sel_col)
}

View File

@ -9,6 +9,14 @@ use crate::model::Model;
use crate::view::Axis;
use crate::ui::app::AppMode;
fn axis_display(axis: Axis) -> (&'static str, Color) {
match axis {
Axis::Row => ("Row ↕", Color::Green),
Axis::Column => ("Col ↔", Color::Blue),
Axis::Page => ("Page ☰", Color::Magenta),
}
}
pub struct CategoryPanel<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
@ -44,10 +52,7 @@ impl<'a> Widget for CategoryPanel<'a> {
let inner = block.inner(area);
block.render(area, buf);
let view = match self.model.active_view() {
Some(v) => v,
None => return,
};
let view = self.model.active_view();
let cat_names: Vec<&str> = self.model.category_names();
if cat_names.is_empty() {
@ -65,19 +70,7 @@ impl<'a> Widget for CategoryPanel<'a> {
if i as u16 >= list_height { break; }
let y = inner.y + i as u16;
let axis = view.axis_of(cat_name);
let axis_str = match axis {
Some(Axis::Row) => "Row ↕",
Some(Axis::Column) => "Col ↔",
Some(Axis::Page) => "Page ☰",
None => "none",
};
let axis_color = match axis {
Some(Axis::Row) => Color::Green,
Some(Axis::Column) => Color::Blue,
Some(Axis::Page) => Color::Magenta,
None => Color::DarkGray,
};
let (axis_str, axis_color) = axis_display(view.axis_of(cat_name));
let item_count = self.model.category(cat_name).map(|c| c.items.len()).unwrap_or(0);

View File

@ -27,10 +27,7 @@ impl<'a> GridWidget<'a> {
}
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
let view = match self.model.active_view() {
Some(v) => v,
None => return,
};
let view = self.model.active_view();
let layout = GridLayout::new(self.model, view);
let (sel_row, sel_col) = view.selected;
@ -97,9 +94,7 @@ impl<'a> GridWidget<'a> {
};
let value = self.model.evaluate(&key);
let cell_str = value.as_ref()
.map(|v| format_value(v, fmt_comma, fmt_decimals))
.unwrap_or_default();
let cell_str = format_value(value.as_ref(), fmt_comma, fmt_decimals);
let is_selected = ri == sel_row && ci == sel_col;
let is_search_match = !self.search_query.is_empty()
&& cell_str.to_lowercase().contains(&self.search_query.to_lowercase());
@ -151,7 +146,7 @@ impl<'a> GridWidget<'a> {
if x >= area.x + area.width { break; }
let total: f64 = (0..layout.row_count())
.filter_map(|ri| layout.cell_key(ri, ci))
.map(|key| self.model.evaluate(&key).and_then(|v| v.as_f64()).unwrap_or(0.0))
.map(|key| self.model.evaluate_f64(&key))
.sum();
let total_str = format_f64(total, fmt_comma, fmt_decimals);
buf.set_string(x, y,
@ -176,34 +171,33 @@ impl<'a> Widget for GridWidget<'a> {
block.render(area, buf);
// Page axis bar
if let Some(view) = self.model.active_view() {
let layout = GridLayout::new(self.model, view);
if !layout.page_coords.is_empty() && inner.height > 0 {
let page_info: Vec<String> = layout.page_coords.iter()
.map(|(cat, sel)| format!("{cat} = {sel}"))
.collect();
let page_str = format!(" [{}] ", page_info.join(" | "));
buf.set_string(inner.x, inner.y,
&page_str,
Style::default().fg(Color::Magenta));
let layout = GridLayout::new(self.model, self.model.active_view());
if !layout.page_coords.is_empty() && inner.height > 0 {
let page_info: Vec<String> = layout.page_coords.iter()
.map(|(cat, sel)| format!("{cat} = {sel}"))
.collect();
let page_str = format!(" [{}] ", page_info.join(" | "));
buf.set_string(inner.x, inner.y,
&page_str,
Style::default().fg(Color::Magenta));
let grid_area = Rect {
y: inner.y + 1,
height: inner.height.saturating_sub(1),
..inner
};
self.render_grid(grid_area, buf);
} else {
self.render_grid(inner, buf);
}
let grid_area = Rect {
y: inner.y + 1,
height: inner.height.saturating_sub(1),
..inner
};
self.render_grid(grid_area, buf);
} else {
self.render_grid(inner, buf);
}
}
}
fn format_value(v: &CellValue, comma: bool, decimals: u8) -> String {
fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String {
match v {
CellValue::Number(n) => format_f64(*n, comma, decimals),
CellValue::Text(s) => s.clone(),
Some(CellValue::Number(n)) => format_f64(*n, comma, decimals),
Some(CellValue::Text(s)) => s.clone(),
None => String::new(),
}
}
@ -395,9 +389,7 @@ mod tests {
c.add_item("Alice");
c.add_item("Bob");
}
if let Some(v) = m.active_view_mut() {
v.set_page_selection("Payer", "Bob");
}
m.active_view_mut().set_page_selection("Payer", "Bob");
let text = buf_text(&render(&m, 80, 24));
assert!(text.contains("Payer = Bob"), "expected 'Payer = Bob' in:\n{text}");
}
@ -451,9 +443,7 @@ mod tests {
if let Some(c) = m.category_mut("Type") { c.add_item("Food"); c.add_item("Clothing"); }
if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); }
if let Some(c) = m.category_mut("Recipient") { c.add_item("Alice"); c.add_item("Bob"); }
if let Some(v) = m.active_view_mut() {
v.set_axis("Recipient", crate::view::Axis::Row);
}
m.active_view_mut().set_axis("Recipient", crate::view::Axis::Row);
let text = buf_text(&render(&m, 80, 24));
// Cross-product rows: Food/Alice, Food/Bob, Clothing/Alice, Clothing/Bob
@ -472,9 +462,7 @@ mod tests {
if let Some(c) = m.category_mut("Type") { c.add_item("Food"); }
if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); }
if let Some(c) = m.category_mut("Recipient") { c.add_item("Alice"); c.add_item("Bob"); }
if let Some(v) = m.active_view_mut() {
v.set_axis("Recipient", crate::view::Axis::Row);
}
m.active_view_mut().set_axis("Recipient", crate::view::Axis::Row);
// Set data at the full 3-coordinate key
m.set_cell(
coord(&[("Month", "Jan"), ("Recipient", "Alice"), ("Type", "Food")]),
@ -493,9 +481,7 @@ mod tests {
if let Some(c) = m.category_mut("Type") { c.add_item("Food"); }
if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); }
if let Some(c) = m.category_mut("Year") { c.add_item("2024"); c.add_item("2025"); }
if let Some(v) = m.active_view_mut() {
v.set_axis("Year", crate::view::Axis::Column);
}
m.active_view_mut().set_axis("Year", crate::view::Axis::Column);
let text = buf_text(&render(&m, 80, 24));
assert!(text.contains("Jan/2024"), "expected 'Jan/2024' in:\n{text}");

View File

@ -9,6 +9,14 @@ use crate::model::Model;
use crate::view::Axis;
use crate::ui::app::AppMode;
fn axis_display(axis: Axis) -> (&'static str, Color) {
match axis {
Axis::Row => ("", Color::Green),
Axis::Column => ("", Color::Blue),
Axis::Page => ("", Color::Magenta),
}
}
pub struct TileBar<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
@ -22,10 +30,7 @@ impl<'a> TileBar<'a> {
impl<'a> Widget for TileBar<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let view = match self.model.active_view() {
Some(v) => v,
None => return,
};
let view = self.model.active_view();
let selected_cat_idx = if let AppMode::TileSelect { cat_idx } = self.mode {
Some(*cat_idx)
@ -39,26 +44,14 @@ impl<'a> Widget for TileBar<'a> {
let cat_names: Vec<&str> = self.model.category_names();
for (i, cat_name) in cat_names.iter().enumerate() {
let axis = view.axis_of(cat_name);
let axis_symbol = match axis {
Some(Axis::Row) => "",
Some(Axis::Column) => "",
Some(Axis::Page) => "",
None => "",
};
let (axis_symbol, axis_color) = axis_display(view.axis_of(cat_name));
let label = format!(" [{cat_name} {axis_symbol}] ");
let is_selected = selected_cat_idx == Some(i);
let style = if is_selected {
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
match axis {
Some(Axis::Row) => Style::default().fg(Color::Green),
Some(Axis::Column) => Style::default().fg(Color::Blue),
Some(Axis::Page) => Style::default().fg(Color::Magenta),
None => Style::default().fg(Color::DarkGray),
}
Style::default().fg(axis_color)
};
if x + label.len() as u16 > area.x + area.width { break; }