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_server_error_becomes_error_state() {
176        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
177        tab.on_reply(&wire_error(1, "db", "connection failed"));
178        assert!(matches!(tab.state, Fetch::Error(ref s) if s.contains("db")));
179    }
180
181    #[test]
182    fn on_reply_string_that_is_not_a_list_is_single_row() {
183        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
184        tab.on_reply(&wire_string(1, "just-a-uuid"));
185        assert!(!matches!(tab.state, Fetch::Idle));
186    }
187
188    #[test]
189    fn select_next_advances_when_loaded() {
190        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
191        tab.state = Fetch::Loaded(vec![
192            list_row(vec!["a"]),
193            list_row(vec!["b"]),
194            list_row(vec!["c"]),
195        ]);
196        assert_eq!(tab.selected, 0);
197        tab.select_next();
198        assert_eq!(tab.selected, 1);
199        tab.select_next();
200        assert_eq!(tab.selected, 2);
201        tab.select_next();
202        assert_eq!(tab.selected, 2, "must not advance past last row");
203    }
204
205    #[test]
206    fn select_prev_saturates_at_zero() {
207        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
208        tab.selected = 0;
209        tab.select_prev();
210        assert_eq!(tab.selected, 0);
211    }
212
213    #[test]
214    fn reset_returns_to_idle() {
215        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
216        tab.state = Fetch::Loaded(vec![list_row(vec!["x"])]);
217        tab.selected = 3;
218        tab.reset();
219        assert_eq!(tab.state, Fetch::Idle);
220        assert_eq!(tab.selected, 0);
221        assert_eq!(tab.scroll, 0);
222    }
223
224    #[test]
225    fn set_loading_id_updates_state() {
226        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
227        tab.set_loading_id(42);
228        assert_eq!(tab.state, Fetch::Loading { id: 42 });
229    }
230
231    // --- L4: structured ListRow tests ---
232
233    // Real list-accounts replies are typed records with a leading `:account`
234    // tag (exactly as `render_entity` emits), so `:id` sits after the tag.
235    const TYPED_ACCOUNTS: &str = r#"((:account :id "uuid-1" :name "Checking" :parent "") (:account :id "uuid-2" :name "Savings" :parent ""))"#;
236
237    /// Wire reply carrying the typed-record list as the `:value` string payload.
238    fn wire_accounts_list() -> String {
239        format!(
240            r#"(:id 1 :value "{}")"#,
241            TYPED_ACCOUNTS.replace('"', "\\\"")
242        )
243    }
244
245    #[test]
246    fn on_reply_list_accounts_rows_carry_ids() {
247        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
248        tab.on_reply(&wire_accounts_list());
249        let Fetch::Loaded(ref rows) = tab.state else {
250            panic!("expected Loaded state");
251        };
252        assert_eq!(rows.len(), 2);
253        assert_eq!(rows[0].id.as_deref(), Some("uuid-1"));
254        assert_eq!(rows[1].id.as_deref(), Some("uuid-2"));
255    }
256
257    #[test]
258    fn on_reply_list_accounts_projects_schema_cells() {
259        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
260        tab.on_reply(&wire_accounts_list());
261        let Fetch::Loaded(ref rows) = tab.state else {
262            panic!("expected Loaded state");
263        };
264        assert_eq!(rows.len(), 2);
265        // Name filled, Type/Parent blank (absent from these records)
266        assert_eq!(rows[0].cells, vec!["Checking", "", ""]);
267        assert_eq!(rows[1].cells, vec!["Savings", "", ""]);
268    }
269
270    #[test]
271    fn selected_id_returns_focused_row_id() {
272        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
273        tab.state = Fetch::Loaded(vec![
274            ListRow {
275                id: Some("id-A".into()),
276                cells: vec!["A".into()],
277            },
278            ListRow {
279                id: Some("id-B".into()),
280                cells: vec!["B".into()],
281            },
282        ]);
283        tab.selected = 0;
284        assert_eq!(tab.selected_id(), Some("id-A"));
285        tab.selected = 1;
286        assert_eq!(tab.selected_id(), Some("id-B"));
287    }
288
289    #[test]
290    fn selected_id_none_when_not_loaded() {
291        let tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
292        assert_eq!(tab.selected_id(), None);
293    }
294
295    #[test]
296    fn selected_id_none_when_row_has_no_id() {
297        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
298        tab.state = Fetch::Loaded(vec![ListRow {
299            id: None,
300            cells: vec!["x".into()],
301        }]);
302        assert_eq!(tab.selected_id(), None);
303    }
304
305    #[test]
306    fn element_without_id_field_has_none_id_cells_still_rendered() {
307        let list_str = r#"((:account :name "NoId" :parent ""))"#;
308        let wire = format!(r#"(:id 1 :value "{}")"#, list_str.replace('"', "\\\""));
309        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
310        tab.on_reply(&wire);
311        let Fetch::Loaded(ref rows) = tab.state else {
312            panic!("expected Loaded state");
313        };
314        assert_eq!(rows.len(), 1);
315        assert_eq!(rows[0].id, None);
316        assert!(!rows[0].cells.is_empty());
317    }
318
319    #[test]
320    fn select_next_prev_move_selected_id() {
321        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
322        tab.state = Fetch::Loaded(vec![
323            ListRow {
324                id: Some("id-0".into()),
325                cells: vec!["zero".into()],
326            },
327            ListRow {
328                id: Some("id-1".into()),
329                cells: vec!["one".into()],
330            },
331            ListRow {
332                id: Some("id-2".into()),
333                cells: vec!["two".into()],
334            },
335        ]);
336        assert_eq!(tab.selected_id(), Some("id-0"));
337        tab.select_next();
338        assert_eq!(tab.selected_id(), Some("id-1"));
339        tab.select_next();
340        assert_eq!(tab.selected_id(), Some("id-2"));
341        tab.select_prev();
342        assert_eq!(tab.selected_id(), Some("id-1"));
343    }
344}