Lines
99.54 %
Functions
50 %
Branches
100 %
//! Generic input form used by all overlay modals that collect user input.
//!
//! Replaces the per-modal `ConfigSetModal` / `ReportParamsModal` duplicates.
//! [`Form`] is a list of [`Field`]s with a focused index and a [`FormKind`]
//! that identifies the submit target. [`validate`] is a pure function that
//! extracts and validates the form's buffers into a [`FormSubmit`] payload.
mod validate;
use cli_core::forms::{EditableTransaction, LogicalSplitInput};
use crate::tabs::reports::ReportKind;
use crate::widgets::{
AmountWidget, DateWidget, EditMode, Editor, SelectOption, SelectWidget, SplitRowPrefill,
SplitsWidget, Widget, WidgetKind,
};
#[cfg(test)]
mod tests;
pub use validate::validate;
/// A single labelled input field.
#[derive(Debug)]
pub struct Field {
pub label: &'static str,
pub widget: Widget,
}
/// What to do when the form is submitted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FormKind {
ConfigSet,
ReportParams {
kind: ReportKind,
},
CommodityCreate,
CommodityConvert,
AccountCreate,
TransactionCreate,
/// Edit an existing transaction; the transaction id is in `Form::entity_id`.
TransactionEdit,
/// Set (or rename via name="name") a tag on an account.
AccountTag,
/// Set a tag on a transaction.
TransactionTag,
/// A generic multi-field input form.
pub struct Form {
pub fields: Vec<Field>,
/// Index of the currently focused field. Always `< fields.len()`.
pub focus: usize,
pub kind: FormKind,
/// Entity id for tag forms; `None` for all other kinds.
pub entity_id: Option<String>,
/// The validated, ready-to-dispatch payload produced by [`validate`].
pub enum FormSubmit {
ConfigSet {
key: String,
value: String,
Report {
from: String,
to: String,
chart: String,
CommodityCreate {
symbol: String,
name: String,
CommodityConvert {
/// Rational in `"N"` or `"N/D"` form, ready for the native.
amount_num_denom: String,
/// Raw typed amount string for display.
amount_str: String,
from_label: String,
to_label: String,
AccountCreate {
parent: Option<String>,
TransactionCreate {
note: String,
date: String,
splits: Vec<LogicalSplitInput>,
TransactionEdit {
id: String,
AccountTag {
account_id: String,
TransactionTag {
transaction_id: String,
impl Form {
/// Two-field form for the `config set` command.
#[must_use]
pub fn config_set(name: Editor, value: Editor) -> Self {
Self {
fields: vec![
Field {
label: "name",
widget: Widget::Text(name),
label: "value",
widget: Widget::Text(value),
],
focus: 0,
kind: FormKind::ConfigSet,
entity_id: None,
/// Three-field form for the `reports activity/breakdown` commands.
pub fn report_params(from: Editor, to: Editor, chart: Editor, kind: ReportKind) -> Self {
label: "from",
widget: Widget::Text(from),
label: "to",
widget: Widget::Text(to),
label: "chart",
widget: Widget::Text(chart),
kind: FormKind::ReportParams { kind },
/// Two-field form for commodity create (symbol required, name required).
pub fn commodity_create(mode: EditMode) -> Self {
label: "Symbol",
widget: Widget::Text(Editor::new(mode)),
label: "Name",
kind: FormKind::CommodityCreate,
/// Three-field form for commodity conversion: amount, from-commodity, to-commodity.
///
/// The two Select fields are empty on construction; call
/// `fetch_commodity_convert_form_options` to populate them asynchronously.
pub fn commodity_convert(mode: EditMode) -> Self {
label: "Amount",
widget: Widget::Amount(AmountWidget::new(mode)),
label: "From",
widget: Widget::Select(SelectWidget::new()),
label: "To",
kind: FormKind::CommodityConvert,
/// Two-field form for account create (name required, parent Select optional).
/// `parent_options` seeds the parent Select; include a leading `(none)` entry
/// with empty id so "no parent" is the default.
pub fn account_create(mode: EditMode, parent_options: Vec<SelectOption>) -> Self {
let mut sw = SelectWidget::new();
sw.set_options(parent_options);
label: "Parent",
widget: Widget::Select(sw),
kind: FormKind::AccountCreate,
/// Three-field form for transaction create: date, note, splits.
pub fn transaction_create(mode: EditMode) -> Self {
label: "Date",
widget: Widget::Date(DateWidget::new(mode)),
label: "Note",
label: "Splits",
widget: Widget::Splits(SplitsWidget::new(mode)),
kind: FormKind::TransactionCreate,
/// Three-field form for editing an existing transaction, pre-filled from `et`.
/// Each split row's Selects start with a single-entry list holding the stored
/// uuid as both id and label; after the async `fetch_transaction_form_options`
/// reply arrives, `set_account_options` / `set_commodity_options` replace the
/// lists while `set_options_preserving_id` keeps the stored selection.
pub fn transaction_edit(mode: EditMode, et: &EditableTransaction) -> Self {
let prefill_rows: Vec<SplitRowPrefill<'_>> = et
.rows
.iter()
.map(|r| SplitRowPrefill {
from: &r.from_account,
to: &r.to_account,
from_commodity: &r.from_commodity,
to_commodity: &r.to_commodity,
value: &r.value,
to_amount: r.to_amount.as_deref(),
})
.collect();
let mut sw = SplitsWidget::new(mode);
sw.apply_prefill(&prefill_rows);
widget: Widget::Date(DateWidget::with_value(mode, et.date.as_str())),
widget: Widget::Text(Editor::with_buffer(mode, et.note.as_str())),
widget: Widget::Splits(sw),
kind: FormKind::TransactionEdit,
entity_id: Some(et.id.clone()),
/// Two-field form for `set-account-tag`: tag name (required) and value.
/// The `account_id` is stored in `entity_id` and used at validation time.
pub fn account_tag(mode: EditMode, account_id: String) -> Self {
label: "Tag name",
label: "Value",
kind: FormKind::AccountTag,
entity_id: Some(account_id),
/// Two-field form for `set-transaction-tag`: tag name (required) and value.
/// The `transaction_id` is stored in `entity_id` and used at validation time.
pub fn transaction_tag(mode: EditMode, transaction_id: String) -> Self {
kind: FormKind::TransactionTag,
entity_id: Some(transaction_id),
/// Advance or retreat focus by one step, wrapping around.
pub fn cycle(&mut self, forward: bool) {
if self.fields.is_empty() {
return;
let len = self.fields.len();
self.focus = if forward {
(self.focus + 1) % len
} else {
(self.focus + len - 1) % len
/// Mutable reference to the currently focused field, if any.
pub fn focused_field_mut(&mut self) -> Option<&mut Field> {
self.fields.get_mut(self.focus)
/// Mutable reference to the inner [`Editor`] of the focused field.
/// Returns `None` when the form is empty or the focused widget is not
/// `Text`, `Amount`, or `Date` (i.e. a `Select` or `Splits`).
pub fn focused_editor_mut(&mut self) -> Option<&mut Editor> {
self.focused_field_mut()
.and_then(|f| f.widget.as_text_mut())
/// Widget kind of the focused field, or `None` when the form is empty.
pub fn focused_widget_kind(&self) -> Option<WidgetKind> {
self.fields.get(self.focus).map(|f| f.widget.kind())
/// When the focused field is a Splits widget, returns the kind of its
/// focused sub-widget (for nested widget-first key routing).
pub fn focused_splits_sub_kind(&self) -> Option<WidgetKind> {
match self.fields.get(self.focus)?.widget {
Widget::Splits(ref sw) => Some(sw.focused_subwidget_kind()),
_ => None,
/// When the focused field is a Splits widget, returns `(at_first_cell,
/// at_last_cell)` so the key layer can yield Tab/BackTab to the outer
/// field cycle at the editor's boundaries.
pub fn focused_splits_boundary(&self) -> Option<(bool, bool)> {
Widget::Splits(ref sw) => Some((sw.at_first_cell(), sw.at_last_cell())),
/// Buffer text of field `i`, or `""` when `i` is out of bounds.
pub(super) fn field_buffer(&self, i: usize) -> &str {
self.fields.get(i).map_or("", |f| f.widget.value())