Skip to main content

tui/tabs/
list.rs

1//! Generic list-display tab for Accounts, Transactions, Commodities.
2
3use cli_core::render::schema::{self, EntitySchema};
4use cli_core::render::{ListRow, RenderError, WireValue, parse_wire, reparse_list};
5use ratatui::Frame;
6use ratatui::layout::{Constraint, Rect};
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState};
9
10use crate::tabs::fetch::Fetch;
11
12pub struct ListTab {
13    pub request_form: String,
14    schema: &'static EntitySchema,
15    pub state: Fetch<Vec<ListRow>>,
16    pub selected: usize,
17    pub scroll: usize,
18}
19
20impl ListTab {
21    pub fn new(request_form: impl Into<String>, schema: &'static EntitySchema) -> Self {
22        Self {
23            request_form: request_form.into(),
24            schema,
25            state: Fetch::Idle,
26            selected: 0,
27            scroll: 0,
28        }
29    }
30
31    /// Update loading id after the eval assigned a real envelope id.
32    pub fn set_loading_id(&mut self, id: i64) {
33        self.state = Fetch::Loading { id };
34    }
35
36    /// Process a routed reply wire frame and update fetch state.
37    pub fn on_reply(&mut self, wire: &str) {
38        self.state = parse_reply(wire, self.schema);
39    }
40
41    /// Return the entity id of the focused row, or `None` when not loaded or no id.
42    pub fn selected_id(&self) -> Option<&str> {
43        let Fetch::Loaded(ref rows) = self.state else {
44            return None;
45        };
46        rows.get(self.selected).and_then(|r| r.id.as_deref())
47    }
48
49    pub fn select_next(&mut self) {
50        if let Fetch::Loaded(ref rows) = self.state
51            && self.selected + 1 < rows.len()
52        {
53            self.selected += 1;
54        }
55    }
56
57    pub fn select_prev(&mut self) {
58        self.selected = self.selected.saturating_sub(1);
59    }
60
61    /// Reset to idle so the next tab-switch re-fetches.
62    pub fn reset(&mut self) {
63        self.state = Fetch::Idle;
64        self.selected = 0;
65        self.scroll = 0;
66    }
67
68    /// Draw the tab using the current fetch state.
69    pub fn draw(&self, frame: &mut Frame, area: Rect, title: &str) {
70        let block = Block::default().borders(Borders::ALL).title(title);
71        match &self.state {
72            Fetch::Idle => {
73                frame.render_widget(Paragraph::new("Waiting...").block(block), area);
74            }
75            Fetch::Loading { .. } => {
76                frame.render_widget(Paragraph::new("Loading...").block(block), area);
77            }
78            Fetch::Error(e) => {
79                let msg = format!("[error] {e}");
80                frame.render_widget(Paragraph::new(msg).block(block), area);
81            }
82            Fetch::Loaded(rows) => {
83                let hdrs = schema::headers(self.schema);
84                draw_table(frame, area, block, &hdrs, rows, self.selected);
85            }
86        }
87    }
88}
89
90fn parse_reply(wire: &str, entity_schema: &EntitySchema) -> Fetch<Vec<ListRow>> {
91    match parse_wire(wire) {
92        Ok(WireValue::Value(scripting::nomiscript::Value::String(ref s))) => {
93            match reparse_list(s) {
94                Ok(v) => Fetch::Loaded(schema::project_rows(&v, entity_schema)),
95                Err(RenderError::ListReparse(msg)) => Fetch::Error(msg),
96                Err(e) => Fetch::Error(e.to_string()),
97            }
98        }
99        Ok(WireValue::Value(ref v)) => Fetch::Loaded(schema::project_rows(v, entity_schema)),
100        Err(RenderError::Server { code, message }) => Fetch::Error(format!("[{code}] {message}")),
101        Err(e) => Fetch::Error(e.to_string()),
102    }
103}
104
105fn draw_table(
106    frame: &mut Frame,
107    area: Rect,
108    block: Block,
109    headers: &[&str],
110    rows: &[ListRow],
111    selected: usize,
112) {
113    let header_cells: Vec<Cell> = headers
114        .iter()
115        .map(|h| Cell::from(*h).style(Style::default().add_modifier(Modifier::BOLD)))
116        .collect();
117    let header = Row::new(header_cells).style(Style::default().fg(Color::Yellow));
118    let data_rows: Vec<Row> = rows
119        .iter()
120        .map(|row| {
121            Row::new(
122                row.cells
123                    .iter()
124                    .map(|c| Cell::from(c.clone()))
125                    .collect::<Vec<_>>(),
126            )
127        })
128        .collect();
129    let col_count = headers.len().max(1);
130    let widths: Vec<Constraint> = (0..col_count)
131        .map(|_| Constraint::Ratio(1, col_count as u32))
132        .collect();
133    let table = Table::new(data_rows, widths)
134        .header(header)
135        .block(block)
136        .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
137    let mut state = TableState::default();
138    state.select(Some(selected));
139    frame.render_stateful_widget(table, area, &mut state);
140}
141
142#[cfg(test)]
143mod tests {
144    use cli_core::render::schema;
145
146    use super::*;
147
148    fn wire_value(id: u64, val: &str) -> String {
149        format!("(:id {id} :value {val})")
150    }
151
152    fn wire_string(id: u64, s: &str) -> String {
153        format!("(:id {id} :value \"{s}\")")
154    }
155
156    fn wire_error(id: u64, code: &str, msg: &str) -> String {
157        format!("(:id {id} :error (:code {code} :message \"{msg}\"))")
158    }
159
160    fn list_row(cells: Vec<&str>) -> ListRow {
161        ListRow {
162            id: None,
163            cells: cells.into_iter().map(String::from).collect(),
164        }
165    }
166
167    #[test]
168    fn on_reply_scalar_number_becomes_single_row() {
169        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
170        tab.on_reply(&wire_value(1, "42"));
171        assert!(matches!(tab.state, Fetch::Loaded(ref rows) if !rows.is_empty()));
172    }
173
174    #[test]
175    fn on_reply_nil_list_is_loaded_and_empty() {
176        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
177        tab.on_reply(&wire_value(1, "nil"));
178        assert!(matches!(tab.state, Fetch::Loaded(ref rows) if rows.is_empty()));
179    }
180
181    #[test]
182    fn on_reply_server_error_becomes_error_state() {
183        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
184        tab.on_reply(&wire_error(1, "db", "connection failed"));
185        assert!(matches!(tab.state, Fetch::Error(ref s) if s.contains("db")));
186    }
187
188    #[test]
189    fn on_reply_string_that_is_not_a_list_is_single_row() {
190        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
191        tab.on_reply(&wire_string(1, "just-a-uuid"));
192        assert!(!matches!(tab.state, Fetch::Idle));
193    }
194
195    #[test]
196    fn select_next_advances_when_loaded() {
197        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
198        tab.state = Fetch::Loaded(vec![
199            list_row(vec!["a"]),
200            list_row(vec!["b"]),
201            list_row(vec!["c"]),
202        ]);
203        assert_eq!(tab.selected, 0);
204        tab.select_next();
205        assert_eq!(tab.selected, 1);
206        tab.select_next();
207        assert_eq!(tab.selected, 2);
208        tab.select_next();
209        assert_eq!(tab.selected, 2, "must not advance past last row");
210    }
211
212    #[test]
213    fn select_prev_saturates_at_zero() {
214        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
215        tab.selected = 0;
216        tab.select_prev();
217        assert_eq!(tab.selected, 0);
218    }
219
220    #[test]
221    fn reset_returns_to_idle() {
222        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
223        tab.state = Fetch::Loaded(vec![list_row(vec!["x"])]);
224        tab.selected = 3;
225        tab.reset();
226        assert_eq!(tab.state, Fetch::Idle);
227        assert_eq!(tab.selected, 0);
228        assert_eq!(tab.scroll, 0);
229    }
230
231    #[test]
232    fn set_loading_id_updates_state() {
233        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
234        tab.set_loading_id(42);
235        assert_eq!(tab.state, Fetch::Loading { id: 42 });
236    }
237
238    // --- L4: structured ListRow tests ---
239
240    // Real list-accounts replies are typed records with a leading `:account`
241    // tag (exactly as `render_entity` emits), so `:id` sits after the tag.
242    const TYPED_ACCOUNTS: &str = r#"((:account :id "uuid-1" :name "Checking" :parent "") (:account :id "uuid-2" :name "Savings" :parent ""))"#;
243
244    /// Wire reply carrying the typed-record list as the `:value` string payload.
245    fn wire_accounts_list() -> String {
246        format!(
247            r#"(:id 1 :value "{}")"#,
248            TYPED_ACCOUNTS.replace('"', "\\\"")
249        )
250    }
251
252    #[test]
253    fn on_reply_list_accounts_rows_carry_ids() {
254        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
255        tab.on_reply(&wire_accounts_list());
256        let Fetch::Loaded(ref rows) = tab.state else {
257            panic!("expected Loaded state");
258        };
259        assert_eq!(rows.len(), 2);
260        assert_eq!(rows[0].id.as_deref(), Some("uuid-1"));
261        assert_eq!(rows[1].id.as_deref(), Some("uuid-2"));
262    }
263
264    #[test]
265    fn on_reply_list_accounts_projects_schema_cells() {
266        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
267        tab.on_reply(&wire_accounts_list());
268        let Fetch::Loaded(ref rows) = tab.state else {
269            panic!("expected Loaded state");
270        };
271        assert_eq!(rows.len(), 2);
272        // Name filled, Type/Parent blank (absent from these records)
273        assert_eq!(rows[0].cells, vec!["Checking", "", ""]);
274        assert_eq!(rows[1].cells, vec!["Savings", "", ""]);
275    }
276
277    #[test]
278    fn selected_id_returns_focused_row_id() {
279        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
280        tab.state = Fetch::Loaded(vec![
281            ListRow {
282                id: Some("id-A".into()),
283                cells: vec!["A".into()],
284            },
285            ListRow {
286                id: Some("id-B".into()),
287                cells: vec!["B".into()],
288            },
289        ]);
290        tab.selected = 0;
291        assert_eq!(tab.selected_id(), Some("id-A"));
292        tab.selected = 1;
293        assert_eq!(tab.selected_id(), Some("id-B"));
294    }
295
296    #[test]
297    fn selected_id_none_when_not_loaded() {
298        let tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
299        assert_eq!(tab.selected_id(), None);
300    }
301
302    #[test]
303    fn selected_id_none_when_row_has_no_id() {
304        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
305        tab.state = Fetch::Loaded(vec![ListRow {
306            id: None,
307            cells: vec!["x".into()],
308        }]);
309        assert_eq!(tab.selected_id(), None);
310    }
311
312    #[test]
313    fn element_without_id_field_has_none_id_cells_still_rendered() {
314        let list_str = r#"((:account :name "NoId" :parent ""))"#;
315        let wire = format!(r#"(:id 1 :value "{}")"#, list_str.replace('"', "\\\""));
316        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
317        tab.on_reply(&wire);
318        let Fetch::Loaded(ref rows) = tab.state else {
319            panic!("expected Loaded state");
320        };
321        assert_eq!(rows.len(), 1);
322        assert_eq!(rows[0].id, None);
323        assert!(!rows[0].cells.is_empty());
324    }
325
326    #[test]
327    fn select_next_prev_move_selected_id() {
328        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
329        tab.state = Fetch::Loaded(vec![
330            ListRow {
331                id: Some("id-0".into()),
332                cells: vec!["zero".into()],
333            },
334            ListRow {
335                id: Some("id-1".into()),
336                cells: vec!["one".into()],
337            },
338            ListRow {
339                id: Some("id-2".into()),
340                cells: vec!["two".into()],
341            },
342        ]);
343        assert_eq!(tab.selected_id(), Some("id-0"));
344        tab.select_next();
345        assert_eq!(tab.selected_id(), Some("id-1"));
346        tab.select_next();
347        assert_eq!(tab.selected_id(), Some("id-2"));
348        tab.select_prev();
349        assert_eq!(tab.selected_id(), Some("id-1"));
350    }
351}