Skip to main content

tui/
form.rs

1//! Generic input form used by all overlay modals that collect user input.
2//!
3//! Replaces the per-modal `ConfigSetModal` / `ReportParamsModal` duplicates.
4//! [`Form`] is a list of [`Field`]s with a focused index and a [`FormKind`]
5//! that identifies the submit target. [`validate`] is a pure function that
6//! extracts and validates the form's buffers into a [`FormSubmit`] payload.
7
8mod validate;
9
10use cli_core::forms::{EditableTransaction, LogicalSplitInput};
11
12use crate::tabs::reports::ReportKind;
13use crate::widgets::{
14    AmountWidget, DateWidget, EditMode, Editor, SelectOption, SelectWidget, SplitRowPrefill,
15    SplitsWidget, Widget, WidgetKind,
16};
17
18#[cfg(test)]
19mod tests;
20
21pub use validate::validate;
22
23/// A single labelled input field.
24#[derive(Debug)]
25pub struct Field {
26    pub label: &'static str,
27    pub widget: Widget,
28}
29
30/// What to do when the form is submitted.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum FormKind {
33    ConfigSet,
34    ReportParams {
35        kind: ReportKind,
36    },
37    CommodityCreate,
38    CommodityConvert,
39    AccountCreate,
40    TransactionCreate,
41    /// Edit an existing transaction; the transaction id is in `Form::entity_id`.
42    TransactionEdit,
43    /// Set (or rename via name="name") a tag on an account.
44    AccountTag,
45    /// Set a tag on a transaction.
46    TransactionTag,
47}
48
49/// A generic multi-field input form.
50#[derive(Debug)]
51pub struct Form {
52    pub fields: Vec<Field>,
53    /// Index of the currently focused field. Always `< fields.len()`.
54    pub focus: usize,
55    pub kind: FormKind,
56    /// Entity id for tag forms; `None` for all other kinds.
57    pub entity_id: Option<String>,
58}
59
60/// The validated, ready-to-dispatch payload produced by [`validate`].
61#[derive(Debug)]
62pub enum FormSubmit {
63    ConfigSet {
64        key: String,
65        value: String,
66    },
67    Report {
68        kind: ReportKind,
69        from: String,
70        to: String,
71        chart: String,
72    },
73    CommodityCreate {
74        symbol: String,
75        name: String,
76    },
77    CommodityConvert {
78        /// Rational in `"N"` or `"N/D"` form, ready for the native.
79        amount_num_denom: String,
80        /// Raw typed amount string for display.
81        amount_str: String,
82        from: String,
83        from_label: String,
84        to: String,
85        to_label: String,
86    },
87    AccountCreate {
88        name: String,
89        parent: Option<String>,
90    },
91    TransactionCreate {
92        note: String,
93        date: String,
94        splits: Vec<LogicalSplitInput>,
95    },
96    TransactionEdit {
97        id: String,
98        note: String,
99        date: String,
100        splits: Vec<LogicalSplitInput>,
101    },
102    AccountTag {
103        account_id: String,
104        name: String,
105        value: String,
106    },
107    TransactionTag {
108        transaction_id: String,
109        name: String,
110        value: String,
111    },
112}
113
114impl Form {
115    /// Two-field form for the `config set` command.
116    #[must_use]
117    pub fn config_set(name: Editor, value: Editor) -> Self {
118        Self {
119            fields: vec![
120                Field {
121                    label: "name",
122                    widget: Widget::Text(name),
123                },
124                Field {
125                    label: "value",
126                    widget: Widget::Text(value),
127                },
128            ],
129            focus: 0,
130            kind: FormKind::ConfigSet,
131            entity_id: None,
132        }
133    }
134
135    /// Three-field form for the `reports activity/breakdown` commands.
136    #[must_use]
137    pub fn report_params(from: Editor, to: Editor, chart: Editor, kind: ReportKind) -> Self {
138        Self {
139            fields: vec![
140                Field {
141                    label: "from",
142                    widget: Widget::Text(from),
143                },
144                Field {
145                    label: "to",
146                    widget: Widget::Text(to),
147                },
148                Field {
149                    label: "chart",
150                    widget: Widget::Text(chart),
151                },
152            ],
153            focus: 0,
154            kind: FormKind::ReportParams { kind },
155            entity_id: None,
156        }
157    }
158
159    /// Two-field form for commodity create (symbol required, name required).
160    #[must_use]
161    pub fn commodity_create(mode: EditMode) -> Self {
162        Self {
163            fields: vec![
164                Field {
165                    label: "Symbol",
166                    widget: Widget::Text(Editor::new(mode)),
167                },
168                Field {
169                    label: "Name",
170                    widget: Widget::Text(Editor::new(mode)),
171                },
172            ],
173            focus: 0,
174            kind: FormKind::CommodityCreate,
175            entity_id: None,
176        }
177    }
178
179    /// Three-field form for commodity conversion: amount, from-commodity, to-commodity.
180    ///
181    /// The two Select fields are empty on construction; call
182    /// `fetch_commodity_convert_form_options` to populate them asynchronously.
183    #[must_use]
184    pub fn commodity_convert(mode: EditMode) -> Self {
185        Self {
186            fields: vec![
187                Field {
188                    label: "Amount",
189                    widget: Widget::Amount(AmountWidget::new(mode)),
190                },
191                Field {
192                    label: "From",
193                    widget: Widget::Select(SelectWidget::new()),
194                },
195                Field {
196                    label: "To",
197                    widget: Widget::Select(SelectWidget::new()),
198                },
199            ],
200            focus: 0,
201            kind: FormKind::CommodityConvert,
202            entity_id: None,
203        }
204    }
205
206    /// Two-field form for account create (name required, parent Select optional).
207    ///
208    /// `parent_options` seeds the parent Select; include a leading `(none)` entry
209    /// with empty id so "no parent" is the default.
210    #[must_use]
211    pub fn account_create(mode: EditMode, parent_options: Vec<SelectOption>) -> Self {
212        let mut sw = SelectWidget::new();
213        sw.set_options(parent_options);
214        Self {
215            fields: vec![
216                Field {
217                    label: "Name",
218                    widget: Widget::Text(Editor::new(mode)),
219                },
220                Field {
221                    label: "Parent",
222                    widget: Widget::Select(sw),
223                },
224            ],
225            focus: 0,
226            kind: FormKind::AccountCreate,
227            entity_id: None,
228        }
229    }
230
231    /// Three-field form for transaction create: date, note, splits.
232    #[must_use]
233    pub fn transaction_create(mode: EditMode) -> Self {
234        Self {
235            fields: vec![
236                Field {
237                    label: "Date",
238                    widget: Widget::Date(DateWidget::new(mode)),
239                },
240                Field {
241                    label: "Note",
242                    widget: Widget::Text(Editor::new(mode)),
243                },
244                Field {
245                    label: "Splits",
246                    widget: Widget::Splits(SplitsWidget::new(mode)),
247                },
248            ],
249            focus: 0,
250            kind: FormKind::TransactionCreate,
251            entity_id: None,
252        }
253    }
254
255    /// Three-field form for editing an existing transaction, pre-filled from `et`.
256    ///
257    /// Each split row's Selects start with a single-entry list holding the stored
258    /// uuid as both id and label; after the async `fetch_transaction_form_options`
259    /// reply arrives, `set_account_options` / `set_commodity_options` replace the
260    /// lists while `set_options_preserving_id` keeps the stored selection.
261    #[must_use]
262    pub fn transaction_edit(mode: EditMode, et: &EditableTransaction) -> Self {
263        let prefill_rows: Vec<SplitRowPrefill<'_>> = et
264            .rows
265            .iter()
266            .map(|r| SplitRowPrefill {
267                from: &r.from_account,
268                to: &r.to_account,
269                from_commodity: &r.from_commodity,
270                to_commodity: &r.to_commodity,
271                value: &r.value,
272                to_amount: r.to_amount.as_deref(),
273            })
274            .collect();
275        let mut sw = SplitsWidget::new(mode);
276        sw.apply_prefill(&prefill_rows);
277        Self {
278            fields: vec![
279                Field {
280                    label: "Date",
281                    widget: Widget::Date(DateWidget::with_value(mode, et.date.as_str())),
282                },
283                Field {
284                    label: "Note",
285                    widget: Widget::Text(Editor::with_buffer(mode, et.note.as_str())),
286                },
287                Field {
288                    label: "Splits",
289                    widget: Widget::Splits(sw),
290                },
291            ],
292            focus: 0,
293            kind: FormKind::TransactionEdit,
294            entity_id: Some(et.id.clone()),
295        }
296    }
297
298    /// Two-field form for `set-account-tag`: tag name (required) and value.
299    ///
300    /// The `account_id` is stored in `entity_id` and used at validation time.
301    #[must_use]
302    pub fn account_tag(mode: EditMode, account_id: String) -> Self {
303        Self {
304            fields: vec![
305                Field {
306                    label: "Tag name",
307                    widget: Widget::Text(Editor::new(mode)),
308                },
309                Field {
310                    label: "Value",
311                    widget: Widget::Text(Editor::new(mode)),
312                },
313            ],
314            focus: 0,
315            kind: FormKind::AccountTag,
316            entity_id: Some(account_id),
317        }
318    }
319
320    /// Two-field form for `set-transaction-tag`: tag name (required) and value.
321    ///
322    /// The `transaction_id` is stored in `entity_id` and used at validation time.
323    #[must_use]
324    pub fn transaction_tag(mode: EditMode, transaction_id: String) -> Self {
325        Self {
326            fields: vec![
327                Field {
328                    label: "Tag name",
329                    widget: Widget::Text(Editor::new(mode)),
330                },
331                Field {
332                    label: "Value",
333                    widget: Widget::Text(Editor::new(mode)),
334                },
335            ],
336            focus: 0,
337            kind: FormKind::TransactionTag,
338            entity_id: Some(transaction_id),
339        }
340    }
341
342    /// Advance or retreat focus by one step, wrapping around.
343    pub fn cycle(&mut self, forward: bool) {
344        if self.fields.is_empty() {
345            return;
346        }
347        let len = self.fields.len();
348        self.focus = if forward {
349            (self.focus + 1) % len
350        } else {
351            (self.focus + len - 1) % len
352        };
353    }
354
355    /// Mutable reference to the currently focused field, if any.
356    pub fn focused_field_mut(&mut self) -> Option<&mut Field> {
357        self.fields.get_mut(self.focus)
358    }
359
360    /// Mutable reference to the inner [`Editor`] of the focused field.
361    ///
362    /// Returns `None` when the form is empty or the focused widget is not
363    /// `Text`, `Amount`, or `Date` (i.e. a `Select` or `Splits`).
364    pub fn focused_editor_mut(&mut self) -> Option<&mut Editor> {
365        self.focused_field_mut()
366            .and_then(|f| f.widget.as_text_mut())
367    }
368
369    /// Widget kind of the focused field, or `None` when the form is empty.
370    #[must_use]
371    pub fn focused_widget_kind(&self) -> Option<WidgetKind> {
372        self.fields.get(self.focus).map(|f| f.widget.kind())
373    }
374
375    /// When the focused field is a Splits widget, returns the kind of its
376    /// focused sub-widget (for nested widget-first key routing).
377    #[must_use]
378    pub fn focused_splits_sub_kind(&self) -> Option<WidgetKind> {
379        match self.fields.get(self.focus)?.widget {
380            Widget::Splits(ref sw) => Some(sw.focused_subwidget_kind()),
381            _ => None,
382        }
383    }
384
385    /// When the focused field is a Splits widget, returns `(at_first_cell,
386    /// at_last_cell)` so the key layer can yield Tab/BackTab to the outer
387    /// field cycle at the editor's boundaries.
388    #[must_use]
389    pub fn focused_splits_boundary(&self) -> Option<(bool, bool)> {
390        match self.fields.get(self.focus)?.widget {
391            Widget::Splits(ref sw) => Some((sw.at_first_cell(), sw.at_last_cell())),
392            _ => None,
393        }
394    }
395
396    /// Buffer text of field `i`, or `""` when `i` is out of bounds.
397    pub(super) fn field_buffer(&self, i: usize) -> &str {
398        self.fields.get(i).map_or("", |f| f.widget.value())
399    }
400}