Skip to main content

tui/widgets/
splits.rs

1//! Multi-row split editor widget for transaction-create forms.
2//!
3//! ## Key scheme (while Splits field is focused)
4//!
5//! | Key        | Effect                                                       |
6//! |------------|-------------------------------------------------------------|
7//! | Tab        | Next cell (col→next row's first col); at the last cell of   |
8//! |            | the last row it YIELDS to the outer form (→ Date)           |
9//! | BackTab    | Previous cell; at (row 0, col 0) it YIELDS to the outer     |
10//! |            | form (→ Note)                                               |
11//! | Up         | Prev Select option (col is Select) / prev row               |
12//! | Down       | Next Select option (col is Select) / next row               |
13//! | Enter      | Submit the transaction (from anywhere in the form)          |
14//! | Ctrl+N / + | Add a new row below (inherits the fetched option lists)     |
15//! | - / Ctrl+D | Remove the focused row (minimum 1 kept)                     |
16//!
17//! The outer form cycles Date→Note→Splits→Date; the boundary yields above let
18//! the user leave the Splits editor by Tab/BackTab without submitting.
19
20use crate::widgets::{AmountWidget, EditMode, SelectOption, SelectWidget, WidgetKind};
21
22const COL_COUNT: usize = 6;
23
24pub const COL_FROM: usize = 0;
25pub const COL_TO: usize = 1;
26pub const COL_FROM_COMM: usize = 2;
27pub const COL_TO_COMM: usize = 3;
28pub const COL_VALUE: usize = 4;
29pub const COL_TO_AMOUNT: usize = 5;
30
31/// Pre-fill data for one row of the transaction-edit form.
32pub struct SplitRowPrefill<'a> {
33    pub from: &'a str,
34    pub to: &'a str,
35    pub from_commodity: &'a str,
36    pub to_commodity: &'a str,
37    pub value: &'a str,
38    pub to_amount: Option<&'a str>,
39}
40
41/// One from→to split row with four account/commodity selects and two amount editors.
42#[derive(Debug)]
43pub struct SplitRow {
44    pub from: SelectWidget,
45    pub to: SelectWidget,
46    pub from_commodity: SelectWidget,
47    pub to_commodity: SelectWidget,
48    pub value: AmountWidget,
49    pub to_amount: AmountWidget,
50}
51
52impl SplitRow {
53    /// Build a row whose Selects are pre-seeded from the cached option lists,
54    /// so a row added after the options arrive is immediately completable.
55    fn new(mode: EditMode, accounts: &[SelectOption], commodities: &[SelectOption]) -> Self {
56        let mut row = Self {
57            from: SelectWidget::new(),
58            to: SelectWidget::new(),
59            from_commodity: SelectWidget::new(),
60            to_commodity: SelectWidget::new(),
61            value: AmountWidget::new(mode),
62            to_amount: AmountWidget::new(mode),
63        };
64        row.from.set_options(accounts.to_vec());
65        row.to.set_options(accounts.to_vec());
66        row.from_commodity.set_options(commodities.to_vec());
67        row.to_commodity.set_options(commodities.to_vec());
68        row
69    }
70
71    /// WidgetKind for the given column index.
72    #[must_use]
73    pub fn col_kind(col: usize) -> WidgetKind {
74        match col {
75            COL_FROM | COL_TO | COL_FROM_COMM | COL_TO_COMM => WidgetKind::Select,
76            _ => WidgetKind::Amount,
77        }
78    }
79
80    pub(super) fn select_mut(&mut self, col: usize) -> Option<&mut SelectWidget> {
81        match col {
82            COL_FROM => Some(&mut self.from),
83            COL_TO => Some(&mut self.to),
84            COL_FROM_COMM => Some(&mut self.from_commodity),
85            COL_TO_COMM => Some(&mut self.to_commodity),
86            _ => None,
87        }
88    }
89
90    pub(super) fn amount_mut(&mut self, col: usize) -> Option<&mut AmountWidget> {
91        match col {
92            COL_VALUE => Some(&mut self.value),
93            COL_TO_AMOUNT => Some(&mut self.to_amount),
94            _ => None,
95        }
96    }
97}
98
99/// Mutable reference to the focused sub-widget for intent routing in `event.rs`.
100pub enum FocusedSubWidget<'a> {
101    Select(&'a mut SelectWidget),
102    Amount(&'a mut AmountWidget),
103}
104
105/// Multi-row split editor.
106///
107/// `row_focus` is always `< rows.len()` (minimum 1 row).
108/// `col_focus` is always `< COL_COUNT`.
109#[derive(Debug)]
110pub struct SplitsWidget {
111    rows: Vec<SplitRow>,
112    pub row_focus: usize,
113    pub col_focus: usize,
114    mode: EditMode,
115    /// Cached fetched option lists, applied to every current row and to any
116    /// row added later so a late `add_row` is not stuck with empty Selects.
117    account_options: Vec<SelectOption>,
118    commodity_options: Vec<SelectOption>,
119}
120
121impl SplitsWidget {
122    /// Create a new widget with one empty row.
123    #[must_use]
124    pub fn new(mode: EditMode) -> Self {
125        Self {
126            rows: vec![SplitRow::new(mode, &[], &[])],
127            row_focus: 0,
128            col_focus: 0,
129            mode,
130            account_options: Vec::new(),
131            commodity_options: Vec::new(),
132        }
133    }
134
135    /// Read-only view of the rows (for validation and display).
136    #[must_use]
137    pub fn rows(&self) -> &[SplitRow] {
138        &self.rows
139    }
140
141    /// WidgetKind of the focused column.
142    #[must_use]
143    pub fn focused_subwidget_kind(&self) -> WidgetKind {
144        SplitRow::col_kind(self.col_focus)
145    }
146
147    /// Mutable reference to the focused sub-widget for intent routing.
148    pub fn focused_subwidget_mut(&mut self) -> Option<FocusedSubWidget<'_>> {
149        let row = self.rows.get_mut(self.row_focus)?;
150        match self.col_focus {
151            COL_FROM | COL_TO | COL_FROM_COMM | COL_TO_COMM => {
152                row.select_mut(self.col_focus).map(FocusedSubWidget::Select)
153            }
154            _ => row.amount_mut(self.col_focus).map(FocusedSubWidget::Amount),
155        }
156    }
157
158    /// Advance one cell: next column, or the next row's first column at a row
159    /// boundary. A no-op at the last cell of the last row (the caller yields to
160    /// the outer form there — see [`at_last_cell`](Self::at_last_cell)).
161    pub fn advance_cell(&mut self) {
162        if self.col_focus + 1 < COL_COUNT {
163            self.col_focus += 1;
164        } else if self.row_focus + 1 < self.rows.len() {
165            self.row_focus += 1;
166            self.col_focus = 0;
167        }
168    }
169
170    /// Retreat one cell: previous column, or the previous row's last column at
171    /// a row boundary. A no-op at the first cell (the caller yields there).
172    pub fn retreat_cell(&mut self) {
173        if self.col_focus > 0 {
174            self.col_focus -= 1;
175        } else if self.row_focus > 0 {
176            self.row_focus -= 1;
177            self.col_focus = COL_COUNT - 1;
178        }
179    }
180
181    /// `true` when focus is on the very first cell (row 0, column 0); BackTab
182    /// here should yield to the outer form rather than move within the editor.
183    #[must_use]
184    pub fn at_first_cell(&self) -> bool {
185        self.row_focus == 0 && self.col_focus == 0
186    }
187
188    /// `true` when focus is on the very last cell (last row, last column); Tab
189    /// here should yield to the outer form rather than move within the editor.
190    #[must_use]
191    pub fn at_last_cell(&self) -> bool {
192        self.row_focus + 1 == self.rows.len() && self.col_focus + 1 == COL_COUNT
193    }
194
195    /// Move to the next row, wrapping around.
196    pub fn next_row(&mut self) {
197        if !self.rows.is_empty() {
198            self.row_focus = (self.row_focus + 1) % self.rows.len();
199        }
200    }
201
202    /// Move to the previous row, wrapping around.
203    pub fn prev_row(&mut self) {
204        let len = self.rows.len();
205        if len > 0 {
206            self.row_focus = (self.row_focus + len - 1) % len;
207        }
208    }
209
210    /// Append a new row after `row_focus`, pre-seeded from the cached option
211    /// lists, and focus it.
212    pub fn add_row(&mut self) {
213        let insert_at = self.row_focus + 1;
214        let row = SplitRow::new(self.mode, &self.account_options, &self.commodity_options);
215        self.rows.insert(insert_at, row);
216        self.row_focus = insert_at;
217    }
218
219    /// Remove the focused row; keeps at least one row.
220    pub fn remove_row(&mut self) {
221        if self.rows.len() <= 1 {
222            return;
223        }
224        self.rows.remove(self.row_focus);
225        if self.row_focus >= self.rows.len() {
226            self.row_focus = self.rows.len() - 1;
227        }
228    }
229
230    /// Cache the account options and apply them to every row's from/to selects,
231    /// preserving any pre-selected uuid so edit-form seeds survive the options fetch.
232    pub fn set_account_options(&mut self, options: Vec<SelectOption>) {
233        self.account_options = options;
234        for row in &mut self.rows {
235            row.from
236                .set_options_preserving_id(self.account_options.clone());
237            row.to
238                .set_options_preserving_id(self.account_options.clone());
239        }
240    }
241
242    /// Cache the commodity options and apply them to every row's commodity selects,
243    /// preserving any pre-selected uuid so edit-form seeds survive the options fetch.
244    pub fn set_commodity_options(&mut self, options: Vec<SelectOption>) {
245        self.commodity_options = options;
246        for row in &mut self.rows {
247            row.from_commodity
248                .set_options_preserving_id(self.commodity_options.clone());
249            row.to_commodity
250                .set_options_preserving_id(self.commodity_options.clone());
251        }
252    }
253
254    /// Replace all rows with pre-seeded data from an existing transaction.
255    ///
256    /// Each select is initialised with a single-option list holding the stored
257    /// uuid so `value()` returns it immediately; the later `set_account_options`
258    /// / `set_commodity_options` call will replace the list while preserving the
259    /// selection via `set_options_preserving_id`.
260    ///
261    /// Guarantees at least one row even when `rows` is empty.
262    pub fn apply_prefill(&mut self, rows: &[SplitRowPrefill<'_>]) {
263        self.rows.clear();
264        self.row_focus = 0;
265        self.col_focus = 0;
266        for data in rows {
267            let make1 = |id: &str| {
268                vec![SelectOption {
269                    id: id.to_string(),
270                    label: id.to_string(),
271                }]
272            };
273            let mut row = SplitRow {
274                from: SelectWidget::new(),
275                to: SelectWidget::new(),
276                from_commodity: SelectWidget::new(),
277                to_commodity: SelectWidget::new(),
278                value: AmountWidget::with_value(self.mode, data.value),
279                to_amount: AmountWidget::with_value(self.mode, data.to_amount.unwrap_or("")),
280            };
281            row.from.set_options(make1(data.from));
282            row.to.set_options(make1(data.to));
283            row.from_commodity.set_options(make1(data.from_commodity));
284            row.to_commodity.set_options(make1(data.to_commodity));
285            self.rows.push(row);
286        }
287        if self.rows.is_empty() {
288            self.rows.push(SplitRow::new(
289                self.mode,
290                &self.account_options,
291                &self.commodity_options,
292            ));
293        }
294    }
295
296    /// Display summary for the generic [`Widget::display`](crate::widgets::Widget) contract.
297    #[must_use]
298    pub fn display(&self) -> String {
299        let n = self.rows.len();
300        format!(
301            "{n} split(s) [row {}, col {}]",
302            self.row_focus, self.col_focus
303        )
304    }
305
306    /// One detailed line per row for the modal, with the focused row marked by
307    /// a leading `▸` and the focused cell wrapped in `[...]`. The cross-commodity
308    /// conversion (`→ to_amount`) is shown only when the commodities differ.
309    #[must_use]
310    pub fn render_lines(&self) -> Vec<String> {
311        self.rows
312            .iter()
313            .enumerate()
314            .map(|(i, row)| self.render_row(i, row))
315            .collect()
316    }
317
318    fn render_row(&self, idx: usize, row: &SplitRow) -> String {
319        let rmark = if idx == self.row_focus { "▸" } else { " " };
320        let from = self.cell(idx, COL_FROM, select_label(&row.from));
321        let to = self.cell(idx, COL_TO, select_label(&row.to));
322        let fc = self.cell(idx, COL_FROM_COMM, select_label(&row.from_commodity));
323        let tc = self.cell(idx, COL_TO_COMM, select_label(&row.to_commodity));
324        let value = self.cell(idx, COL_VALUE, amount_text(&row.value));
325        let conv = if row.from_commodity.value() != row.to_commodity.value() {
326            format!(
327                " → {}",
328                self.cell(idx, COL_TO_AMOUNT, amount_text(&row.to_amount))
329            )
330        } else {
331            String::new()
332        };
333        format!(
334            "{rmark}{}. {from} → {to}  [{fc}/{tc}]  val={value}{conv}",
335            idx + 1
336        )
337    }
338
339    fn cell(&self, idx: usize, col: usize, content: String) -> String {
340        if idx == self.row_focus && col == self.col_focus {
341            format!("[{content}]")
342        } else {
343            content
344        }
345    }
346}
347
348fn select_label(sw: &SelectWidget) -> String {
349    let label = sw.display();
350    if label.is_empty() {
351        "?".to_string()
352    } else {
353        label.to_string()
354    }
355}
356
357fn amount_text(aw: &AmountWidget) -> String {
358    let v = aw.value();
359    if v.is_empty() {
360        "0".to_string()
361    } else {
362        v.to_string()
363    }
364}
365
366#[cfg(test)]
367mod tests;