Modals

NAME

App::Moneymoor::Screen::Main::Modals - every dialog in the app: the shell's own three, the budget tab's seven, and the accounts tab's seven.

SYNOPSIS


use App::Moneymoor::Screen::Main::Modals;

App::Moneymoor::Screen::Main::Modals::open-settings($main);      # ctrl+o
App::Moneymoor::Screen::Main::Modals::open-diagnostics($main);   # ctrl+g

# The budget period: ctrl+p from Settings, and once at creation.
App::Moneymoor::Screen::Main::Modals::open-period-picker($main);
App::Moneymoor::Screen::Main::Modals::open-period-picker($main, :first-run);

# The budget tab's, all keyed off whatever row its table has selected:
App::Moneymoor::Screen::Main::Modals::open-assign($main);          # a
App::Moneymoor::Screen::Main::Modals::open-move-money($main);      # m
App::Moneymoor::Screen::Main::Modals::open-fund-all($main);        # f
App::Moneymoor::Screen::Main::Modals::open-explain($main);         # x
App::Moneymoor::Screen::Main::Modals::open-category-editor($main); # n
App::Moneymoor::Screen::Main::Modals::open-group-editor($main);    # g
App::Moneymoor::Screen::Main::Modals::hide-category($main);        # h

# The accounts tab's, keyed off its register cursor or its sidebar:
App::Moneymoor::Screen::Main::Modals::open-transaction-editor($main);   # n / e
App::Moneymoor::Screen::Main::Modals::open-transfer($main);             # t
App::Moneymoor::Screen::Main::Modals::cycle-cleared($main);             # c
App::Moneymoor::Screen::Main::Modals::delete-transaction($main);        # d
App::Moneymoor::Screen::Main::Modals::open-account-editor($main);       # n
App::Moneymoor::Screen::Main::Modals::toggle-account-closed($main);     # c

# Reconcile mode (Β§4.5): ctrl+r starts it, Enter finishes it.
App::Moneymoor::Screen::Main::Modals::open-reconcile($main);            # ^r
App::Moneymoor::Screen::Main::Modals::finish-reconcile($main);          # enter

# Give a Selkie-provided dialog Moneymoor's backdrop:
$app.show-modal(scrimmed($help-overlay.build));

# Pure, and so directly testable:
period-option-index($ws.scheme);            # 0 / 1 / 2, the picker's list
parse-period-choice(1, day => '14');        # Period(monthly/14)
parse-assign-input('+5');       # { mode => 'adjust', amount => 500 }
parse-assign-input('400');      # { mode => 'set',    amount => 40000 }
parse-assign-input('=450');     # { mode => 'fund-to', amount => 45000 }
parse-assign-input('=');        # { mode => 'fund-target' }
parse-target('400');            # 40000 β€” the editor's Target field
parse-target('');               # 0     β€” blank clears the target
parse-repeat('3');              # 3     β€” the editor's Repeat field
parse-repeat('');               # 0     β€” blank is a one-off goal
signed-amount(1250, 'Outflow'); # -1250
number-example('.');            # '1,234.56'  (the Settings label)
number-example(',');            # '1.234,56'
split-remainder(1250, (500, 250));  # 500 still to allocate
payee-suggest-tail(('Tesco', 'Tesco Express'), 'tes');   # 'co'
balance-adjustment(account-id => 1, amount => 1240,
                   date => '2026-03-31', rta-category-id => 3);
# { txn => Transaction(+Β£12.40, reconciled), splits => [Split(RTA, 1240)] }

DESCRIPTION

Every function takes the Main screen as its first argument and reaches into it through the public accessors (.app, .config, .store, .theme, .view, .viewed-period, .with-modal, .apply-theme-live). The screen keeps one-line delegates (Main.open-settings, Main.open-diagnostics) so keybind call sites don't have to know where a dialog's body lives.

Both dialogs open through Main.with-modal, which owns the lifecycle contract: the modal closes before the body runs, and Esc closes. Diagnostics is read-only and so passes a supply that never emits β€” the point of a single wrapper is that the Esc binding and the close ordering are written once, not that every dialog has a submit path.

Settings

Four Selects β€” palette, glyph tier, currency symbol, number format β€” with a live colour-swatch row under the first that re-paints as the highlighted palette changes, so the user sees what they are choosing before committing to it. Ctrl+S writes all four keys to App::Moneymoor::Config and closes.

The two money settings are then pushed into App::Moneymoor::Util::Money::set-money-locale, and the redraw is the one Main.apply-theme-live was already doing: it rebuilds the content area, and every figure in the tree is formatted on the way through, so a currency or decimal-mark change needs no plumbing of its own to reach the budget grid, the register, the sidebar or the reports. No restart for any of the four.

Under those four is the period section, which is a line of text and a key rather than a fifth picker: the other four are display preferences in config.json, and the period is a property of the budget file whose change rewrites every assignment row. Ctrl+P closes Settings and opens the picker β€” see THE PERIOD PICKER β€” so re-bucketing somebody's money can never be a side effect of changing a currency symbol.

The number-format Select lists examples (1,234.56 / 1.234,56), not the bare mark, because the setting moves two characters at once β€” the decimal mark and, by implication, the thousands separator β€” and one example says that where a , in a dropdown does not. number-example derives the label from the mark, so the two lists stay index-aligned and the submit can map the selection back by index.

THE PERIOD PICKER

open-period-picker is the one dialog that decides how the whole budget is bucketed, and it is reached two ways: Ctrl+P from Settings, and once with :first-run immediately after a budget is created.

The first run asks after creation, not during it. The create-a-budget form is 24 rows tall, which is the whole of the terminal it has to fit; three more fields and a picker do not go in it. So the question is asked over the new, empty budget β€” where changing the scheme moves no money whatsoever β€” and Esc means "the calendar month, then", which is what the file already says. Nothing is written for that answer: an absent period_scheme is the calendar month, and recording the default as a choice would leave a file diff and a "changed" toast for a change nobody made. Choosing it from Settings later does write the key, because by then it is a change from something.

Three options, two schemes. "Calendar month (the 1st)" and "Day of the month" are the same monthly scheme; the first is < anchor-day => 1 >. The split is the difference between asking somebody what their budget looks like and asking them what the engine should store. parse-period-choice normalises a typed day of 1 back onto the calendar month for free, because they are the same scheme. The first option names the day it pins because "Calendar month" alone reads as "a month-long period, starting when?", and a user with a Day field in front of them will answer that themselves.

Only the fields the selected option reads are on the dialog. None for the calendar month, Day for the day-of-the-month option, Weeks and First payday for the weekly one. parse-period-choice ignores the fields its mode does not use, and a field that renders, takes focus and accepts keystrokes is an input β€” so leaving the unused ones on screen made a correct save look like a broken one ("Calendar month", 14 typed into a visible Day box, Ctrl+S, "Budget period unchanged"). The rows live in a VBox of their own between the Select and the hint, rebuilt on each change β€” Container.clear destroys what it removes, so this is the Login screen's mode switch rather than the transaction editor's swap β€” and what was typed is remembered across the rebuild, so flipping options and back costs nobody a date. The dialog keeps one height throughout: the flex error line absorbs the rows an option does not use.

The hint under the fields spends three of those rows, at every option. It is a RichText that wraps, because all three sentences are longer than the 44 columns this dialog has and a one-row Text cut every one of them off mid-clause β€” including the one saying what the weekly option counts its periods from, which is the only place that is said. Three rows is the longest of them measured through RichText.wrap-spans, not guessed, and t/90 re-measures it so a re-wording that runs long fails a test instead of silently dropping its last line.

Changing it on a budget with history is a confirm with numbers in it. Workspace.re-bucket-preview is a read-only dry run, and its counts go into the message: how many assignments move, how many periods they are in now, how many they will be in, and β€” when the answer is not zero β€” how many will land on top of another and be added together. That last one is the only irreversible part of the operation (changing back restores the totals, not the split), so it gets its own sentence. The same scheme again is a toast naming the scheme the budget is staying on and no dialog; a budget with no assignments at all skips the confirm, for the same reason fund-all with nothing to fund does.

Applying it re-seeds app/period. After a change the store is holding a key from the old scheme, which is very likely not a start under the new one, and app/period-set's guard would drop it. The apply path therefore dispatches the new scheme's current-period, and the recompute that follows carries the change to the banner, the grid, the pill and the reports through the digest every subscription already watches.

The budget dialogs

Seven, and they share three rules.

Every write goes through ws/mutate. Not one of these functions calls a gateway directly: they build the call as a closure and dispatch it, so the Failure check, the toast, the .so and the recompute are written once (App::Moneymoor::StoreHandlers). That is also why a refused delete shows the engine's own "hide it instead" wording rather than something this file invented.

Validation happens before the submit supply emits. with-modal closes the dialog and then runs the body, which is right for a dialog that has succeeded and wrong for one the user has fat-fingered. So the input's own on-submit is tapped first, and the supply with-modal sees only ever carries a value that has already parsed. A malformed amount paints the error line inside the still-open dialog.

The row decides what is possible. a, m, x, h and d need an envelope, so on a group header a, m, x and h toast and return; e and d ask the row what it is and open the matching editor or confirm. Nothing silently acts on a neighbouring row. f is the exception that proves it: fund-all is about the whole period, not about the row, so it works from a header and from the empty state just as well.

Saving: Enter and Ctrl+S

Every field in every dialog here is single-line, so Enter in a text field saves the form, through the same closure Ctrl+S runs β€” the same validation, the same refusals, the same error line. Ctrl+S stays, because it works from a picker as well as from a field.

That is wired per field (enter-saves), not as an enter keybind on the modal, and the reason is Selkie::App's dispatch order: a key goes to the focused widget first and walks parent-wards, with the modal's own keybinds consulted last. TextInput consumes Enter β€” emitting on-submit is what it does with it β€” so a modal-level bind would never see Enter from a field at all. It also means the widgets that already own Enter keep it: a Select opens or commits its dropdown, a RadioGroup picks, a Checkbox toggles, and the transaction editor's splits ListView edits the split under its cursor. None of those save, which is the point β€” Enter on a picker means "choose this", not "I am done".

The one dialog without an Enter-save is Settings: it has no text field at all, only pickers, and Enter in a picker is how you pick.

Assign β€” the +/-/= rule

A leading sign means adjust by, a leading = means fund to, anything else means set to:

Every "Typed" cell below is quoted because two of them begin with an =, and a Pod table cell is plain text β€” no formatting codes in it, and a line starting with = is a directive:

Typed Effect
'400' set-assigned Β£400.00
'Β£1,234.56' set-assigned Β£1,234.56
'+5' adjust by +Β£5.00
'-2.50' adjust by -Β£2.50
'(5.00)' set-assigned -Β£5.00
'=450' make AVAILABLE Β£450.00 this period
'=' make available equal this envelope's target

(5.00) is the escape hatch, and it is why the sign rule can be this simple: negative assignments are legal (pulling money back out of a category that had none this period is an ordinary correction), so there has to be some way to type one, and -5 is already spoken for. Accountant parentheses are the notation every CSV export already uses for it, and parse-pence already accepts them.

=450 is about available, not about assigned, and that is the whole reason it exists. Assigned is what you put in; available is what is left after carry-in, spending and the derivation's own card moves β€” and "I want Β£450 in this envelope" is what a person actually means. So =450 is implemented as adjust(450 - available), never as a set: setting assigned to 450 on an envelope that already carried Β£37.50 in would leave Β£487.50 available, which is not what the user typed.

Three consequences fall out of that:

  • A negative fund-to is refused. =-5 and =(5) both reach parse-pence as minus five pounds, and "fund to minus five" is not a thing anyone means. Taking money out is -5, which the sign rule above already reads.

  • A zero delta writes nothing. =450 on an envelope already sitting at Β£450 closes with a toast saying so and dispatches no mutation at all. The upsert would be a no-op in the database and anything but a no-op in the app: it bumps ws/rev, re-derives the whole budget and repaints every subscription to announce that nothing changed.

  • A bare = needs a target. parse-assign-input is pure and has no envelope to ask, so it answers fund-target and the dialog resolves it. No target, and the dialog stays open with "No target set β€” edit the envelope to add one" on its error line, which is the same validate-before-emit contract every other refusal here obeys.

A bare = is not a fund-to, though it used to be. It is "fund this envelope's plan for this period", and only under a refill target is that plan's gap target βˆ’ available: a set-aside measures this period's assignments and a goal measures its milestone, so the old identity is simply false for two kinds out of three. The dialog therefore resolves = through Service::Target.target-ask β€” the one function every target feature keys on β€” and emits the ask itself as an adjustment. A zero ask takes the same no-write path a zero delta does, for the same reason.

Fund all β€” f

The same idea over the whole period, in one write: open-fund-all lists every standard, visible envelope whose plan asks for something this period, with the +Β£ each would take, a total, and what Ready to Assign will be afterwards. Enter or Ctrl+S confirms.

Four rules, all of them stated in fund-all-candidates and open-fund-all's own Pod: standard envelopes only, a target above zero, not hidden, and underfunded β€” fund-all only ever adds, and will not pull an over-target envelope back down. "Underfunded" is target-ask and nothing else, so f funds a refill to its level, a set-aside's contribution and a goal's next milestone in the same sweep; each line says the figure it lands on, which for a goal is that milestone and not the goal's total. Nothing to fund is a toast rather than a dialog whose only answer is "do nothing".

Confirming dispatches one ws/mutate closure that loops the adjustments, so twenty envelopes are one derivation and not twenty, and a refusal partway through stops the run and toasts the engine's own words.

Ready to Assign is allowed to go negative, loudly: the modal says what RTA will be, in red and in words, when the answer is below zero β€” and then lets the user do it anyway. Assigning money that has not arrived is a real step on the way to a plan, and the app's job is to be unmistakable about it, not to forbid it.

The accounts dialogs

Seven, and they obey the same three rules as the budget's β€” every write through ws/mutate, validation before the submit supply emits, the row under the cursor decides what is possible. Four of them are worth reading about.

The transaction editor, and the shape of a transaction

A transaction is an amount on an account, and β€” on an on-budget account β€” a set of splits that sum to it. The form types the amount as a positive figure plus an Outflow/Inflow choice, because that is how a register reads it and how a bank statement prints it; signed-amount is the one place the sign is applied.

Splits are an entries list, in Mindmoor's pattern: a ListView of formatted rows over an array of entry hashes, with a nested dialog for adding and editing one. It is always mounted, and its content β€” not its presence β€” changes:

  • empty, which is the ordinary case: the row reads "one category, press a to split", and the Category picker above it is live. Saving builds exactly one split, which is what the engine stores for a single-category transaction anyway.

  • non-empty: the Category picker is replaced (in content, not in layout) by "(split across N categories)" and ignored, and the remainder line under the list says how much of the amount is still unallocated.

The alternative β€” a Split… toggle that grows the dialog β€” means adding and removing widgets from a live VBox, which costs the editor's transient state (Container.clear destroys its children) and risks a zero-allocation child painting outside its parent. Swapping content is the same UX with none of that.

Saving is refused while the remainder is non-zero: the remainder line turns amber, the status line says Save is disabled, and Ctrl+S does nothing. There is no Save button to grey out β€” this is a keyboard dialog β€” so the greying is the amber and the refusal.

Split amounts are typed as positive figures too, and take the transaction's direction. Accountant parentheses are the escape hatch for the odd line that runs the other way (a refund inside a purchase), exactly as they are in the Assign dialog.

What the editor will not let you change

  • The account, once the transaction exists. The engine refuses to move a transfer leg outright, and moving an ordinary transaction between accounts silently rewrites two balances β€” offering it in the same form as a memo edit invites it by accident. Delete and re-enter.

  • A tracking account's category. The derivation excludes tracking splits, and Gateway::Transaction refuses them, so the picker says so instead of pretending.

  • A transfer's endpoints. Same reason; the payee and category fields are replaced by the transfer's own facts.

A reconciled transaction gets a confirm first. Reconciled means "this matched a statement", and the whole point of the state is that it does not change by accident.

The transfer dialog carries a category

Β§4.4 draws it as from / to / amount / date / memo, which is right for a transfer inside the budget. A transfer that crosses the budget boundary β€” cash out to a tracking account, or in from one β€” is money entering or leaving the envelopes, and create-transfer refuses it without a split on the on-budget leg. So the picker is there, with a line saying when it applies, and it is ignored for the transfers where the engine would refuse it.

Reconcile is a mode, and it finishes in one write

Ctrl+R asks for the statement balance and writes accounts/reconcile; from then on the register's frame carries the diff and Enter means "finish" instead of "edit". The dialogs here are the two ends of that: open-reconcile takes the figure, and finish-reconcile either promotes or offers the transaction that would let it.

Two decisions worth stating:

  • Promotion is one ws/mutate closure. Every cleared transaction on the account is set to reconciled inside a single action, so the budget is derived once. Per-transaction dispatches would derive it once per row, each derivation discarding the last.

  • An out-of-balance finish is a confirm, not a refusal. The difference is offered as a Balance Adjustment transaction β€” signed from the account's point of view, filed to Ready to Assign on a budget account and to nothing at all on a tracking one β€” and declining leaves the mode running with every cleared mark intact. A statement that does not match usually means a receipt that has not been entered, and the app should not make the user choose between finishing wrongly and starting over.

Deleting an account is the one true cascade

Every other delete in the app is either refused (a category with history) or lossless (a group). Gateway::Account.delete is a hard delete of the account, its transactions, their splits and the peer leg of every transfer that touched it. The confirm says all of that in words, and offers closing the account as the thing the user probably meant.

Diagnostics

A read-only dump of what the derivation thinks about the current budget: view.warnings, view.invariant-errors, the viewed period's flags, and the canonical-digest.

The first two should be empty on any budget written through the gateways β€” they exist to catch facts the derivation could not make sense of. Non-empty means a bug, and the dialog says so in as many words and in the palette's red, because a warning list nobody knows is abnormal is a warning list nobody reports.

The digest is there for exactly that report. Note that canonical-digest is not a fingerprint despite the name: it is the full canonical serialisation of the derivation β€” one line per period, per category-period, per move and per account, every figure in it. That is the right thing for the engine (it is what makes the property tests able to say "these two derivations are identical"), but it is the wrong thing to paint into a dialog: hundreds of lines, and every balance the user owns. So the dialog shows a 64-bit FNV-1a fingerprint of it plus the line count β€” enough to say "my budget is in state X" in an issue, and safe to paste in public.

EXPORTS

Private β€” every sub is our sub, called by fully-qualified name. The module name is the namespace boundary.

SEE ALSO

  • change-scheme re-buckets and persists, in one transaction. It dies rather than failing if its own totals assertion trips β€” which rolls the transaction back β€” so the call is wrapped and the message goes to a toast. The engine's wording already ends with "refused, and nothing was written", which is the half the user needs.

  • app/period is re-seeded with the new scheme's current period. It is holding a key from the old scheme, which is very likely not a period start under the new one, and app/period-set's guard is start-ness against workspace.scheme β€” already the new one by now β€” so anything else would be dropped on the floor and leave the app looking at a bucket the engine no longer has.

  • the toast names the scheme in words, because "changed" on its own leaves the user to go and look.

  • the same scheme β€” a toast and nothing else. The engine would happily re-bucket a budget onto the scheme it is already on (it is a legal no-op that still rewrites every row), and a confirm asking to move 400 assignments nowhere is a dialog whose only honest answer is "why". The toast names the scheme the budget is staying on, for the same reason the applied one names the scheme it moved to: "unchanged" on its own leaves a user who expected a change with nothing to check their expectation against.

  • no assignments to move β€” apply it directly. A confirm listing zero rows is the fund-all "nothing to do" dialog again.

  • anything else β€” the confirm, with the actual numbers in it.

Carry overspending

The Kind picker, and the rows that follow it

Saving without clearing what it did not touch

  • the update carries the row's existing target tuple, every one of the five columns, unchanged. Renaming an envelope writes its target back exactly as it found it, and the editor cannot clear a plan it was not asked to change. The carry flag is not in that group: this dialog renders it, so it carries what the checkbox says rather than what the row said.

  • the target itself goes through Service::Workspace.set-target and only when it actually changed. That is the front door that stamps target_start, so a goal re-ramps from today when its amount or its goal period moves and keeps its stamp when only the repeat does. Nothing else in the app may write that column, and this dialog does not.

  • categorised β€” the form shows a live category picker and saves splits. False on a tracking account (the engine refuses splits there) and on a transfer leg that does not cross the budget boundary (the engine refuses those too β€” no envelope moves when money stays inside the budget).

  • tracking / transfer β€” which of the two reasons applies, so the picker can say which.

  • endpoints-locked β€” the account is static text. True for every edit: the engine refuses to move a transfer leg outright, and moving an ordinary transaction between accounts rewrites two balances, which is not something to offer in the same form as a memo edit.

  • warn-first β€” a reconciled transaction; confirm before opening.

  • The sign is the diff's. A statement showing more than the register has cleared means money arrived that was never entered, so the adjustment is an inflow. The diff is already signed from the account's point of view; nothing negates it.

  • On-budget accounts get one split, to Ready to Assign. Money appearing in a budget account is money to be assigned β€” the same place a payday deposit lands. An outward adjustment is a negative split against the same envelope, which takes it back out of Ready to Assign.

  • Tracking accounts get no splits at all. The engine refuses to categorise a tracking transaction, and the derivation excludes them from the budget by design.

App::Moneymoor v0.4.2

YNAB-style envelope budgeting: a derivation engine

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

DBIish:ver<0.6.7>:auth<zef:raku-community-modules>Notcurses::Native:ver<0.6.5+>:auth<zef:apogee>Selkie:ver<0.16.0+>:auth<zef:apogee>JSON::Fast:ver<0.19>:auth<cpan:TIMOTIMO>MacOS::NativeLib:ver<0.0.6>:auth<zef:lizmat>

Test Dependencies

Provides

  • App::Moneymoor
  • App::Moneymoor::Config
  • App::Moneymoor::DB
  • App::Moneymoor::Gateway::Account
  • App::Moneymoor::Gateway::Assignment
  • App::Moneymoor::Gateway::Category
  • App::Moneymoor::Gateway::Payee
  • App::Moneymoor::Gateway::Transaction
  • App::Moneymoor::Handlers::Boot
  • App::Moneymoor::Model::Account
  • App::Moneymoor::Model::Assignment
  • App::Moneymoor::Model::Category
  • App::Moneymoor::Model::CategoryGroup
  • App::Moneymoor::Model::Payee
  • App::Moneymoor::Model::Split
  • App::Moneymoor::Model::Transaction
  • App::Moneymoor::Screen::Accounts
  • App::Moneymoor::Screen::Budget
  • App::Moneymoor::Screen::Login
  • App::Moneymoor::Screen::Main
  • App::Moneymoor::Screen::Main::Keybinds
  • App::Moneymoor::Screen::Main::Modals
  • App::Moneymoor::Screen::Main::Subscriptions
  • App::Moneymoor::Screen::Reports
  • App::Moneymoor::Service::Budget
  • App::Moneymoor::Service::Icons
  • App::Moneymoor::Service::Target
  • App::Moneymoor::Service::Workspace
  • App::Moneymoor::StoreHandlers
  • App::Moneymoor::Theme
  • App::Moneymoor::Theme::Catppuccin
  • App::Moneymoor::Theme::Dracula
  • App::Moneymoor::Theme::Everforest
  • App::Moneymoor::Theme::Gruvbox
  • App::Moneymoor::Theme::Kanagawa
  • App::Moneymoor::Theme::Monokai
  • App::Moneymoor::Theme::Nord
  • App::Moneymoor::Theme::OneDark
  • App::Moneymoor::Theme::RosePine
  • App::Moneymoor::Theme::Solarized
  • App::Moneymoor::Theme::TokyoNight
  • App::Moneymoor::Themes
  • App::Moneymoor::UI
  • App::Moneymoor::Util::Money
  • App::Moneymoor::Util::Period
  • App::Moneymoor::View::BudgetRow
  • App::Moneymoor::View::EmptyState
  • App::Moneymoor::View::HintBar
  • App::Moneymoor::View::InspectorPane
  • App::Moneymoor::View::ModalChrome
  • App::Moneymoor::View::RegisterRow
  • App::Moneymoor::View::ReportRow
  • App::Moneymoor::Widget::BannerBar
  • App::Moneymoor::Widget::BootProgressModal

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite β€” the markup and publishing tools behind this site.