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

            
20
use crate::widgets::{AmountWidget, EditMode, SelectOption, SelectWidget, WidgetKind};
21

            
22
const COL_COUNT: usize = 6;
23

            
24
pub const COL_FROM: usize = 0;
25
pub const COL_TO: usize = 1;
26
pub const COL_FROM_COMM: usize = 2;
27
pub const COL_TO_COMM: usize = 3;
28
pub const COL_VALUE: usize = 4;
29
pub const COL_TO_AMOUNT: usize = 5;
30

            
31
/// Pre-fill data for one row of the transaction-edit form.
32
pub 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)]
43
pub 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

            
52
impl 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
55
    fn new(mode: EditMode, accounts: &[SelectOption], commodities: &[SelectOption]) -> Self {
56
55
        let mut row = Self {
57
55
            from: SelectWidget::new(),
58
55
            to: SelectWidget::new(),
59
55
            from_commodity: SelectWidget::new(),
60
55
            to_commodity: SelectWidget::new(),
61
55
            value: AmountWidget::new(mode),
62
55
            to_amount: AmountWidget::new(mode),
63
55
        };
64
55
        row.from.set_options(accounts.to_vec());
65
55
        row.to.set_options(accounts.to_vec());
66
55
        row.from_commodity.set_options(commodities.to_vec());
67
55
        row.to_commodity.set_options(commodities.to_vec());
68
55
        row
69
55
    }
70

            
71
    /// WidgetKind for the given column index.
72
    #[must_use]
73
22
    pub fn col_kind(col: usize) -> WidgetKind {
74
22
        match col {
75
12
            COL_FROM | COL_TO | COL_FROM_COMM | COL_TO_COMM => WidgetKind::Select,
76
10
            _ => WidgetKind::Amount,
77
        }
78
22
    }
79

            
80
3
    pub(super) fn select_mut(&mut self, col: usize) -> Option<&mut SelectWidget> {
81
3
        match col {
82
1
            COL_FROM => Some(&mut self.from),
83
            COL_TO => Some(&mut self.to),
84
            COL_FROM_COMM => Some(&mut self.from_commodity),
85
2
            COL_TO_COMM => Some(&mut self.to_commodity),
86
            _ => None,
87
        }
88
3
    }
89

            
90
5
    pub(super) fn amount_mut(&mut self, col: usize) -> Option<&mut AmountWidget> {
91
5
        match col {
92
4
            COL_VALUE => Some(&mut self.value),
93
1
            COL_TO_AMOUNT => Some(&mut self.to_amount),
94
            _ => None,
95
        }
96
5
    }
97
}
98

            
99
/// Mutable reference to the focused sub-widget for intent routing in `event.rs`.
100
pub 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)]
110
pub 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

            
121
impl SplitsWidget {
122
    /// Create a new widget with one empty row.
123
    #[must_use]
124
41
    pub fn new(mode: EditMode) -> Self {
125
41
        Self {
126
41
            rows: vec![SplitRow::new(mode, &[], &[])],
127
41
            row_focus: 0,
128
41
            col_focus: 0,
129
41
            mode,
130
41
            account_options: Vec::new(),
131
41
            commodity_options: Vec::new(),
132
41
        }
133
41
    }
134

            
135
    /// Read-only view of the rows (for validation and display).
136
    #[must_use]
137
19
    pub fn rows(&self) -> &[SplitRow] {
138
19
        &self.rows
139
19
    }
140

            
141
    /// WidgetKind of the focused column.
142
    #[must_use]
143
16
    pub fn focused_subwidget_kind(&self) -> WidgetKind {
144
16
        SplitRow::col_kind(self.col_focus)
145
16
    }
146

            
147
    /// Mutable reference to the focused sub-widget for intent routing.
148
8
    pub fn focused_subwidget_mut(&mut self) -> Option<FocusedSubWidget<'_>> {
149
8
        let row = self.rows.get_mut(self.row_focus)?;
150
8
        match self.col_focus {
151
            COL_FROM | COL_TO | COL_FROM_COMM | COL_TO_COMM => {
152
3
                row.select_mut(self.col_focus).map(FocusedSubWidget::Select)
153
            }
154
5
            _ => row.amount_mut(self.col_focus).map(FocusedSubWidget::Amount),
155
        }
156
8
    }
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
7
    pub fn advance_cell(&mut self) {
162
7
        if self.col_focus + 1 < COL_COUNT {
163
5
            self.col_focus += 1;
164
5
        } else if self.row_focus + 1 < self.rows.len() {
165
1
            self.row_focus += 1;
166
1
            self.col_focus = 0;
167
1
        }
168
7
    }
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
2
    pub fn retreat_cell(&mut self) {
173
2
        if self.col_focus > 0 {
174
            self.col_focus -= 1;
175
2
        } else if self.row_focus > 0 {
176
1
            self.row_focus -= 1;
177
1
            self.col_focus = COL_COUNT - 1;
178
1
        }
179
2
    }
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
15
    pub fn at_first_cell(&self) -> bool {
185
15
        self.row_focus == 0 && self.col_focus == 0
186
15
    }
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
15
    pub fn at_last_cell(&self) -> bool {
192
15
        self.row_focus + 1 == self.rows.len() && self.col_focus + 1 == COL_COUNT
193
15
    }
194

            
195
    /// Move to the next row, wrapping around.
196
3
    pub fn next_row(&mut self) {
197
3
        if !self.rows.is_empty() {
198
3
            self.row_focus = (self.row_focus + 1) % self.rows.len();
199
3
        }
200
3
    }
201

            
202
    /// Move to the previous row, wrapping around.
203
1
    pub fn prev_row(&mut self) {
204
1
        let len = self.rows.len();
205
1
        if len > 0 {
206
1
            self.row_focus = (self.row_focus + len - 1) % len;
207
1
        }
208
1
    }
209

            
210
    /// Append a new row after `row_focus`, pre-seeded from the cached option
211
    /// lists, and focus it.
212
14
    pub fn add_row(&mut self) {
213
14
        let insert_at = self.row_focus + 1;
214
14
        let row = SplitRow::new(self.mode, &self.account_options, &self.commodity_options);
215
14
        self.rows.insert(insert_at, row);
216
14
        self.row_focus = insert_at;
217
14
    }
218

            
219
    /// Remove the focused row; keeps at least one row.
220
3
    pub fn remove_row(&mut self) {
221
3
        if self.rows.len() <= 1 {
222
1
            return;
223
2
        }
224
2
        self.rows.remove(self.row_focus);
225
2
        if self.row_focus >= self.rows.len() {
226
2
            self.row_focus = self.rows.len() - 1;
227
2
        }
228
3
    }
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
10
    pub fn set_account_options(&mut self, options: Vec<SelectOption>) {
233
10
        self.account_options = options;
234
11
        for row in &mut self.rows {
235
11
            row.from
236
11
                .set_options_preserving_id(self.account_options.clone());
237
11
            row.to
238
11
                .set_options_preserving_id(self.account_options.clone());
239
11
        }
240
10
    }
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
7
    pub fn set_commodity_options(&mut self, options: Vec<SelectOption>) {
245
7
        self.commodity_options = options;
246
8
        for row in &mut self.rows {
247
8
            row.from_commodity
248
8
                .set_options_preserving_id(self.commodity_options.clone());
249
8
            row.to_commodity
250
8
                .set_options_preserving_id(self.commodity_options.clone());
251
8
        }
252
7
    }
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
3
    pub fn apply_prefill(&mut self, rows: &[SplitRowPrefill<'_>]) {
263
3
        self.rows.clear();
264
3
        self.row_focus = 0;
265
3
        self.col_focus = 0;
266
3
        for data in rows {
267
12
            let make1 = |id: &str| {
268
12
                vec![SelectOption {
269
12
                    id: id.to_string(),
270
12
                    label: id.to_string(),
271
12
                }]
272
12
            };
273
3
            let mut row = SplitRow {
274
3
                from: SelectWidget::new(),
275
3
                to: SelectWidget::new(),
276
3
                from_commodity: SelectWidget::new(),
277
3
                to_commodity: SelectWidget::new(),
278
3
                value: AmountWidget::with_value(self.mode, data.value),
279
3
                to_amount: AmountWidget::with_value(self.mode, data.to_amount.unwrap_or("")),
280
3
            };
281
3
            row.from.set_options(make1(data.from));
282
3
            row.to.set_options(make1(data.to));
283
3
            row.from_commodity.set_options(make1(data.from_commodity));
284
3
            row.to_commodity.set_options(make1(data.to_commodity));
285
3
            self.rows.push(row);
286
        }
287
3
        if self.rows.is_empty() {
288
            self.rows.push(SplitRow::new(
289
                self.mode,
290
                &self.account_options,
291
                &self.commodity_options,
292
            ));
293
3
        }
294
3
    }
295

            
296
    /// Display summary for the generic [`Widget::display`](crate::widgets::Widget) contract.
297
    #[must_use]
298
1
    pub fn display(&self) -> String {
299
1
        let n = self.rows.len();
300
1
        format!(
301
            "{n} split(s) [row {}, col {}]",
302
            self.row_focus, self.col_focus
303
        )
304
1
    }
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
2
    pub fn render_lines(&self) -> Vec<String> {
311
2
        self.rows
312
2
            .iter()
313
2
            .enumerate()
314
2
            .map(|(i, row)| self.render_row(i, row))
315
2
            .collect()
316
2
    }
317

            
318
2
    fn render_row(&self, idx: usize, row: &SplitRow) -> String {
319
2
        let rmark = if idx == self.row_focus { "▸" } else { " " };
320
2
        let from = self.cell(idx, COL_FROM, select_label(&row.from));
321
2
        let to = self.cell(idx, COL_TO, select_label(&row.to));
322
2
        let fc = self.cell(idx, COL_FROM_COMM, select_label(&row.from_commodity));
323
2
        let tc = self.cell(idx, COL_TO_COMM, select_label(&row.to_commodity));
324
2
        let value = self.cell(idx, COL_VALUE, amount_text(&row.value));
325
2
        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
2
            String::new()
332
        };
333
2
        format!(
334
            "{rmark}{}. {from} → {to}  [{fc}/{tc}]  val={value}{conv}",
335
2
            idx + 1
336
        )
337
2
    }
338

            
339
10
    fn cell(&self, idx: usize, col: usize, content: String) -> String {
340
10
        if idx == self.row_focus && col == self.col_focus {
341
2
            format!("[{content}]")
342
        } else {
343
8
            content
344
        }
345
10
    }
346
}
347

            
348
8
fn select_label(sw: &SelectWidget) -> String {
349
8
    let label = sw.display();
350
8
    if label.is_empty() {
351
4
        "?".to_string()
352
    } else {
353
4
        label.to_string()
354
    }
355
8
}
356

            
357
2
fn amount_text(aw: &AmountWidget) -> String {
358
2
    let v = aw.value();
359
2
    if v.is_empty() {
360
1
        "0".to_string()
361
    } else {
362
1
        v.to_string()
363
    }
364
2
}
365

            
366
#[cfg(test)]
367
mod tests;