Lines
93.88 %
Functions
50 %
Branches
100 %
//! Multi-row split editor widget for transaction-create forms.
//!
//! ## Key scheme (while Splits field is focused)
//! | Key | Effect |
//! |------------|-------------------------------------------------------------|
//! | Tab | Next cell (col→next row's first col); at the last cell of |
//! | | the last row it YIELDS to the outer form (→ Date) |
//! | BackTab | Previous cell; at (row 0, col 0) it YIELDS to the outer |
//! | | form (→ Note) |
//! | Up | Prev Select option (col is Select) / prev row |
//! | Down | Next Select option (col is Select) / next row |
//! | Enter | Submit the transaction (from anywhere in the form) |
//! | Ctrl+N / + | Add a new row below (inherits the fetched option lists) |
//! | - / Ctrl+D | Remove the focused row (minimum 1 kept) |
//! The outer form cycles Date→Note→Splits→Date; the boundary yields above let
//! the user leave the Splits editor by Tab/BackTab without submitting.
use crate::widgets::{AmountWidget, EditMode, SelectOption, SelectWidget, WidgetKind};
const COL_COUNT: usize = 6;
pub const COL_FROM: usize = 0;
pub const COL_TO: usize = 1;
pub const COL_FROM_COMM: usize = 2;
pub const COL_TO_COMM: usize = 3;
pub const COL_VALUE: usize = 4;
pub const COL_TO_AMOUNT: usize = 5;
/// Pre-fill data for one row of the transaction-edit form.
pub struct SplitRowPrefill<'a> {
pub from: &'a str,
pub to: &'a str,
pub from_commodity: &'a str,
pub to_commodity: &'a str,
pub value: &'a str,
pub to_amount: Option<&'a str>,
}
/// One from→to split row with four account/commodity selects and two amount editors.
#[derive(Debug)]
pub struct SplitRow {
pub from: SelectWidget,
pub to: SelectWidget,
pub from_commodity: SelectWidget,
pub to_commodity: SelectWidget,
pub value: AmountWidget,
pub to_amount: AmountWidget,
impl SplitRow {
/// Build a row whose Selects are pre-seeded from the cached option lists,
/// so a row added after the options arrive is immediately completable.
fn new(mode: EditMode, accounts: &[SelectOption], commodities: &[SelectOption]) -> Self {
let mut row = Self {
from: SelectWidget::new(),
to: SelectWidget::new(),
from_commodity: SelectWidget::new(),
to_commodity: SelectWidget::new(),
value: AmountWidget::new(mode),
to_amount: AmountWidget::new(mode),
};
row.from.set_options(accounts.to_vec());
row.to.set_options(accounts.to_vec());
row.from_commodity.set_options(commodities.to_vec());
row.to_commodity.set_options(commodities.to_vec());
row
/// WidgetKind for the given column index.
#[must_use]
pub fn col_kind(col: usize) -> WidgetKind {
match col {
COL_FROM | COL_TO | COL_FROM_COMM | COL_TO_COMM => WidgetKind::Select,
_ => WidgetKind::Amount,
pub(super) fn select_mut(&mut self, col: usize) -> Option<&mut SelectWidget> {
COL_FROM => Some(&mut self.from),
COL_TO => Some(&mut self.to),
COL_FROM_COMM => Some(&mut self.from_commodity),
COL_TO_COMM => Some(&mut self.to_commodity),
_ => None,
pub(super) fn amount_mut(&mut self, col: usize) -> Option<&mut AmountWidget> {
COL_VALUE => Some(&mut self.value),
COL_TO_AMOUNT => Some(&mut self.to_amount),
/// Mutable reference to the focused sub-widget for intent routing in `event.rs`.
pub enum FocusedSubWidget<'a> {
Select(&'a mut SelectWidget),
Amount(&'a mut AmountWidget),
/// Multi-row split editor.
///
/// `row_focus` is always `< rows.len()` (minimum 1 row).
/// `col_focus` is always `< COL_COUNT`.
pub struct SplitsWidget {
rows: Vec<SplitRow>,
pub row_focus: usize,
pub col_focus: usize,
mode: EditMode,
/// Cached fetched option lists, applied to every current row and to any
/// row added later so a late `add_row` is not stuck with empty Selects.
account_options: Vec<SelectOption>,
commodity_options: Vec<SelectOption>,
impl SplitsWidget {
/// Create a new widget with one empty row.
pub fn new(mode: EditMode) -> Self {
Self {
rows: vec![SplitRow::new(mode, &[], &[])],
row_focus: 0,
col_focus: 0,
mode,
account_options: Vec::new(),
commodity_options: Vec::new(),
/// Read-only view of the rows (for validation and display).
pub fn rows(&self) -> &[SplitRow] {
&self.rows
/// WidgetKind of the focused column.
pub fn focused_subwidget_kind(&self) -> WidgetKind {
SplitRow::col_kind(self.col_focus)
/// Mutable reference to the focused sub-widget for intent routing.
pub fn focused_subwidget_mut(&mut self) -> Option<FocusedSubWidget<'_>> {
let row = self.rows.get_mut(self.row_focus)?;
match self.col_focus {
COL_FROM | COL_TO | COL_FROM_COMM | COL_TO_COMM => {
row.select_mut(self.col_focus).map(FocusedSubWidget::Select)
_ => row.amount_mut(self.col_focus).map(FocusedSubWidget::Amount),
/// Advance one cell: next column, or the next row's first column at a row
/// boundary. A no-op at the last cell of the last row (the caller yields to
/// the outer form there — see [`at_last_cell`](Self::at_last_cell)).
pub fn advance_cell(&mut self) {
if self.col_focus + 1 < COL_COUNT {
self.col_focus += 1;
} else if self.row_focus + 1 < self.rows.len() {
self.row_focus += 1;
self.col_focus = 0;
/// Retreat one cell: previous column, or the previous row's last column at
/// a row boundary. A no-op at the first cell (the caller yields there).
pub fn retreat_cell(&mut self) {
if self.col_focus > 0 {
self.col_focus -= 1;
} else if self.row_focus > 0 {
self.row_focus -= 1;
self.col_focus = COL_COUNT - 1;
/// `true` when focus is on the very first cell (row 0, column 0); BackTab
/// here should yield to the outer form rather than move within the editor.
pub fn at_first_cell(&self) -> bool {
self.row_focus == 0 && self.col_focus == 0
/// `true` when focus is on the very last cell (last row, last column); Tab
pub fn at_last_cell(&self) -> bool {
self.row_focus + 1 == self.rows.len() && self.col_focus + 1 == COL_COUNT
/// Move to the next row, wrapping around.
pub fn next_row(&mut self) {
if !self.rows.is_empty() {
self.row_focus = (self.row_focus + 1) % self.rows.len();
/// Move to the previous row, wrapping around.
pub fn prev_row(&mut self) {
let len = self.rows.len();
if len > 0 {
self.row_focus = (self.row_focus + len - 1) % len;
/// Append a new row after `row_focus`, pre-seeded from the cached option
/// lists, and focus it.
pub fn add_row(&mut self) {
let insert_at = self.row_focus + 1;
let row = SplitRow::new(self.mode, &self.account_options, &self.commodity_options);
self.rows.insert(insert_at, row);
self.row_focus = insert_at;
/// Remove the focused row; keeps at least one row.
pub fn remove_row(&mut self) {
if self.rows.len() <= 1 {
return;
self.rows.remove(self.row_focus);
if self.row_focus >= self.rows.len() {
self.row_focus = self.rows.len() - 1;
/// Cache the account options and apply them to every row's from/to selects,
/// preserving any pre-selected uuid so edit-form seeds survive the options fetch.
pub fn set_account_options(&mut self, options: Vec<SelectOption>) {
self.account_options = options;
for row in &mut self.rows {
row.from
.set_options_preserving_id(self.account_options.clone());
row.to
/// Cache the commodity options and apply them to every row's commodity selects,
pub fn set_commodity_options(&mut self, options: Vec<SelectOption>) {
self.commodity_options = options;
row.from_commodity
.set_options_preserving_id(self.commodity_options.clone());
row.to_commodity
/// Replace all rows with pre-seeded data from an existing transaction.
/// Each select is initialised with a single-option list holding the stored
/// uuid so `value()` returns it immediately; the later `set_account_options`
/// / `set_commodity_options` call will replace the list while preserving the
/// selection via `set_options_preserving_id`.
/// Guarantees at least one row even when `rows` is empty.
pub fn apply_prefill(&mut self, rows: &[SplitRowPrefill<'_>]) {
self.rows.clear();
self.row_focus = 0;
for data in rows {
let make1 = |id: &str| {
vec![SelectOption {
id: id.to_string(),
label: id.to_string(),
}]
let mut row = SplitRow {
value: AmountWidget::with_value(self.mode, data.value),
to_amount: AmountWidget::with_value(self.mode, data.to_amount.unwrap_or("")),
row.from.set_options(make1(data.from));
row.to.set_options(make1(data.to));
row.from_commodity.set_options(make1(data.from_commodity));
row.to_commodity.set_options(make1(data.to_commodity));
self.rows.push(row);
if self.rows.is_empty() {
self.rows.push(SplitRow::new(
self.mode,
&self.account_options,
&self.commodity_options,
));
/// Display summary for the generic [`Widget::display`](crate::widgets::Widget) contract.
pub fn display(&self) -> String {
let n = self.rows.len();
format!(
"{n} split(s) [row {}, col {}]",
self.row_focus, self.col_focus
)
/// One detailed line per row for the modal, with the focused row marked by
/// a leading `▸` and the focused cell wrapped in `[...]`. The cross-commodity
/// conversion (`→ to_amount`) is shown only when the commodities differ.
pub fn render_lines(&self) -> Vec<String> {
self.rows
.iter()
.enumerate()
.map(|(i, row)| self.render_row(i, row))
.collect()
fn render_row(&self, idx: usize, row: &SplitRow) -> String {
let rmark = if idx == self.row_focus { "▸" } else { " " };
let from = self.cell(idx, COL_FROM, select_label(&row.from));
let to = self.cell(idx, COL_TO, select_label(&row.to));
let fc = self.cell(idx, COL_FROM_COMM, select_label(&row.from_commodity));
let tc = self.cell(idx, COL_TO_COMM, select_label(&row.to_commodity));
let value = self.cell(idx, COL_VALUE, amount_text(&row.value));
let conv = if row.from_commodity.value() != row.to_commodity.value() {
" → {}",
self.cell(idx, COL_TO_AMOUNT, amount_text(&row.to_amount))
} else {
String::new()
"{rmark}{}. {from} → {to} [{fc}/{tc}] val={value}{conv}",
idx + 1
fn cell(&self, idx: usize, col: usize, content: String) -> String {
if idx == self.row_focus && col == self.col_focus {
format!("[{content}]")
content
fn select_label(sw: &SelectWidget) -> String {
let label = sw.display();
if label.is_empty() {
"?".to_string()
label.to_string()
fn amount_text(aw: &AmountWidget) -> String {
let v = aw.value();
if v.is_empty() {
"0".to_string()
v.to_string()
#[cfg(test)]
mod tests;