Lines
79.2 %
Functions
59.18 %
Branches
100 %
//! Generic list-display tab for Accounts, Transactions, Commodities.
use cli_core::render::schema::{self, EntitySchema};
use cli_core::render::{ListRow, RenderError, WireValue, parse_wire, reparse_list};
use ratatui::Frame;
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState};
use crate::tabs::fetch::Fetch;
pub struct ListTab {
pub request_form: String,
schema: &'static EntitySchema,
pub state: Fetch<Vec<ListRow>>,
pub selected: usize,
pub scroll: usize,
}
impl ListTab {
pub fn new(request_form: impl Into<String>, schema: &'static EntitySchema) -> Self {
Self {
request_form: request_form.into(),
schema,
state: Fetch::Idle,
selected: 0,
scroll: 0,
/// Update loading id after the eval assigned a real envelope id.
pub fn set_loading_id(&mut self, id: i64) {
self.state = Fetch::Loading { id };
/// Process a routed reply wire frame and update fetch state.
pub fn on_reply(&mut self, wire: &str) {
self.state = parse_reply(wire, self.schema);
/// Return the entity id of the focused row, or `None` when not loaded or no id.
pub fn selected_id(&self) -> Option<&str> {
let Fetch::Loaded(ref rows) = self.state else {
return None;
};
rows.get(self.selected).and_then(|r| r.id.as_deref())
pub fn select_next(&mut self) {
if let Fetch::Loaded(ref rows) = self.state
&& self.selected + 1 < rows.len()
{
self.selected += 1;
pub fn select_prev(&mut self) {
self.selected = self.selected.saturating_sub(1);
/// Reset to idle so the next tab-switch re-fetches.
pub fn reset(&mut self) {
self.state = Fetch::Idle;
self.selected = 0;
self.scroll = 0;
/// Draw the tab using the current fetch state.
pub fn draw(&self, frame: &mut Frame, area: Rect, title: &str) {
let block = Block::default().borders(Borders::ALL).title(title);
match &self.state {
Fetch::Idle => {
frame.render_widget(Paragraph::new("Waiting...").block(block), area);
Fetch::Loading { .. } => {
frame.render_widget(Paragraph::new("Loading...").block(block), area);
Fetch::Error(e) => {
let msg = format!("[error] {e}");
frame.render_widget(Paragraph::new(msg).block(block), area);
Fetch::Loaded(rows) => {
let hdrs = schema::headers(self.schema);
draw_table(frame, area, block, &hdrs, rows, self.selected);
fn parse_reply(wire: &str, entity_schema: &EntitySchema) -> Fetch<Vec<ListRow>> {
match parse_wire(wire) {
Ok(WireValue::Value(scripting::nomiscript::Value::String(ref s))) => {
match reparse_list(s) {
Ok(v) => Fetch::Loaded(schema::project_rows(&v, entity_schema)),
Err(RenderError::ListReparse(msg)) => Fetch::Error(msg),
Err(e) => Fetch::Error(e.to_string()),
Ok(WireValue::Value(ref v)) => Fetch::Loaded(schema::project_rows(v, entity_schema)),
Err(RenderError::Server { code, message }) => Fetch::Error(format!("[{code}] {message}")),
fn draw_table(
frame: &mut Frame,
area: Rect,
block: Block,
headers: &[&str],
rows: &[ListRow],
selected: usize,
) {
let header_cells: Vec<Cell> = headers
.iter()
.map(|h| Cell::from(*h).style(Style::default().add_modifier(Modifier::BOLD)))
.collect();
let header = Row::new(header_cells).style(Style::default().fg(Color::Yellow));
let data_rows: Vec<Row> = rows
.map(|row| {
Row::new(
row.cells
.map(|c| Cell::from(c.clone()))
.collect::<Vec<_>>(),
)
})
let col_count = headers.len().max(1);
let widths: Vec<Constraint> = (0..col_count)
.map(|_| Constraint::Ratio(1, col_count as u32))
let table = Table::new(data_rows, widths)
.header(header)
.block(block)
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut state = TableState::default();
state.select(Some(selected));
frame.render_stateful_widget(table, area, &mut state);
#[cfg(test)]
mod tests {
use cli_core::render::schema;
use super::*;
fn wire_value(id: u64, val: &str) -> String {
format!("(:id {id} :value {val})")
fn wire_string(id: u64, s: &str) -> String {
format!("(:id {id} :value \"{s}\")")
fn wire_error(id: u64, code: &str, msg: &str) -> String {
format!("(:id {id} :error (:code {code} :message \"{msg}\"))")
fn list_row(cells: Vec<&str>) -> ListRow {
ListRow {
id: None,
cells: cells.into_iter().map(String::from).collect(),
#[test]
fn on_reply_scalar_number_becomes_single_row() {
let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
tab.on_reply(&wire_value(1, "42"));
assert!(matches!(tab.state, Fetch::Loaded(ref rows) if !rows.is_empty()));
fn on_reply_server_error_becomes_error_state() {
tab.on_reply(&wire_error(1, "db", "connection failed"));
assert!(matches!(tab.state, Fetch::Error(ref s) if s.contains("db")));
fn on_reply_string_that_is_not_a_list_is_single_row() {
tab.on_reply(&wire_string(1, "just-a-uuid"));
assert!(!matches!(tab.state, Fetch::Idle));
fn select_next_advances_when_loaded() {
tab.state = Fetch::Loaded(vec![
list_row(vec!["a"]),
list_row(vec!["b"]),
list_row(vec!["c"]),
]);
assert_eq!(tab.selected, 0);
tab.select_next();
assert_eq!(tab.selected, 1);
assert_eq!(tab.selected, 2);
assert_eq!(tab.selected, 2, "must not advance past last row");
fn select_prev_saturates_at_zero() {
tab.selected = 0;
tab.select_prev();
fn reset_returns_to_idle() {
tab.state = Fetch::Loaded(vec![list_row(vec!["x"])]);
tab.selected = 3;
tab.reset();
assert_eq!(tab.state, Fetch::Idle);
assert_eq!(tab.scroll, 0);
fn set_loading_id_updates_state() {
tab.set_loading_id(42);
assert_eq!(tab.state, Fetch::Loading { id: 42 });
// --- L4: structured ListRow tests ---
// Real list-accounts replies are typed records with a leading `:account`
// tag (exactly as `render_entity` emits), so `:id` sits after the tag.
const TYPED_ACCOUNTS: &str = r#"((:account :id "uuid-1" :name "Checking" :parent "") (:account :id "uuid-2" :name "Savings" :parent ""))"#;
/// Wire reply carrying the typed-record list as the `:value` string payload.
fn wire_accounts_list() -> String {
format!(
r#"(:id 1 :value "{}")"#,
TYPED_ACCOUNTS.replace('"', "\\\"")
fn on_reply_list_accounts_rows_carry_ids() {
tab.on_reply(&wire_accounts_list());
let Fetch::Loaded(ref rows) = tab.state else {
panic!("expected Loaded state");
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].id.as_deref(), Some("uuid-1"));
assert_eq!(rows[1].id.as_deref(), Some("uuid-2"));
fn on_reply_list_accounts_projects_schema_cells() {
// Name filled, Type/Parent blank (absent from these records)
assert_eq!(rows[0].cells, vec!["Checking", "", ""]);
assert_eq!(rows[1].cells, vec!["Savings", "", ""]);
fn selected_id_returns_focused_row_id() {
id: Some("id-A".into()),
cells: vec!["A".into()],
},
id: Some("id-B".into()),
cells: vec!["B".into()],
assert_eq!(tab.selected_id(), Some("id-A"));
tab.selected = 1;
assert_eq!(tab.selected_id(), Some("id-B"));
fn selected_id_none_when_not_loaded() {
let tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
assert_eq!(tab.selected_id(), None);
fn selected_id_none_when_row_has_no_id() {
tab.state = Fetch::Loaded(vec![ListRow {
cells: vec!["x".into()],
}]);
fn element_without_id_field_has_none_id_cells_still_rendered() {
let list_str = r#"((:account :name "NoId" :parent ""))"#;
let wire = format!(r#"(:id 1 :value "{}")"#, list_str.replace('"', "\\\""));
tab.on_reply(&wire);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, None);
assert!(!rows[0].cells.is_empty());
fn select_next_prev_move_selected_id() {
id: Some("id-0".into()),
cells: vec!["zero".into()],
id: Some("id-1".into()),
cells: vec!["one".into()],
id: Some("id-2".into()),
cells: vec!["two".into()],
assert_eq!(tab.selected_id(), Some("id-0"));
assert_eq!(tab.selected_id(), Some("id-1"));
assert_eq!(tab.selected_id(), Some("id-2"));