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.
=-5and=(5)both reachparse-penceas 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.
=450on 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 bumpsws/rev, re-derives the whole budget and repaints every subscription to announce that nothing changed.A bare
=needs a target.parse-assign-inputis pure and has no envelope to ask, so it answersfund-targetand 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::Transactionrefuses 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/mutateclosure. Everyclearedtransaction on the account is set toreconciledinside 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 Adjustmenttransaction β 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
App::Moneymoor::Screen::Main β
with-modaland the accessors.App::Moneymoor::View::ModalChrome β the shared style bundle and the swatch spans.
App::Moneymoor::Service::Budget β what
warnings/invariant-errors/canonical-digestmean.
change-schemere-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/periodis 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, andapp/period-set's guard is start-ness againstworkspace.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
updatecarries 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-targetand only when it actually changed. That is the front door that stampstarget_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.