Main
NAME
App::Moneymoor::Screen::Main - the application shell: banner, tab strip, content host, hint footer, and the caches every tab reads from.
SYNOPSIS
use App::Moneymoor::Screen::Main;
my $main = App::Moneymoor::Screen::Main.new(
app => $app,
db => $db,
workspace => $workspace,
config => $config,
theme => $theme,
);
$app.add-screen('main', $main.build);
$app.switch-screen('main');
$main.focus-content; # the budget table, on the tab that is mounted
# Everything downstream reads the cache, never the gateways:
$main.view.rta($main.viewed-period); # the cached BudgetView
$main.category($id).name; # id โ model, rebuilt per recompute
DESCRIPTION
VBox(flex)
โ BannerBar(fixed 1) ' App::Moneymoor ยท Budget ยท August 2026' + clock
โ TabBar(fixed 1) Budget / Accounts / Reports
โ VBox(flex) content host โ one child, swapped per tab
โ RichText(fixed 1) hint footer
A plain class, not a widget: it owns the tree rather than being part of
one. Its job is the four things a tab builder should not have to think
about โ the palette, the glyph tier, the cached BudgetView plus the
id โ model lookup hashes, and the store โ and it exposes all of them
through public accessors, which are the module boundary for the
Keybinds / Modals / Subscriptions siblings.
The cache, and why it is not in the store
Selkie::Store digests subscription values to decide whether anything
changed, and it keys objects by identity. A selector handing back a
BudgetView โ or a Hash of Model::Category objects โ would read as
"changed" on every tick and repaint the whole screen sixty times a
second. So the view and the four lookup hashes live here, and the store
carries budget/digest: a byte-stable, order-independent string the
derivation computes for exactly this purpose. Subscriptions watch the
digest; their callbacks pull from these accessors.
set-view is the sink the ws/mutate effect calls (wired in
!register-handlers), so the cache and the digest can never disagree
โ they are written by the same recompute.
Tab switching
The tab strip, 1 / 2 / 3 and Ctrl+1 / Ctrl+2 /
Ctrl+3 all dispatch
app/tab-selected. A store subscription on app/tab then drives
!refresh-content-layout, which clears the content host โ destroying
the departing subtree and, with it, its subscriptions โ builds the new
tab's panes, pushes the store down to them, and re-installs that tab's
subscriptions and keybinds.
Doing it through the store rather than straight from the tab callback
is what makes the two entry points converge: a keybind switch and a
click both end as one write to app/tab, and the strip re-syncs from
the same subscription.
The last step of a re-mount is focus-content: the widget that had
focus was destroyed with the departing subtree, and Selkie does not
move focus on its own, so without it the new tab would be on screen
with the keyboard still pointing at nothing and the footer showing the
generic hints. The one mount that does not focus is the first, from
inside build โ the screen has not been switched to yet, and
Selkie::App.switch-screen restores its own remembered focus after
the fact, so placing the initial focus is UI's job.
The tab builders
!build-budget-pane hands off to App::Moneymoor::Screen::Budget,
which owns the envelope table, the Ready-to-Assign pill and the detail
rail, and which the shell keeps a reference to in budget-tab so the
budget keybinds and modals can find the table after a rebuild.
!build-accounts-pane does the same for
App::Moneymoor::Screen::Accounts and accounts-tab: the sidebar,
the register and the nine dialogs that change what either says.
!build-reports-pane for App::Moneymoor::Screen::Reports and
reports-tab: the period's cash-flow strip and the chart of what it
went on.
All three go through the same seams โ the builder contract,
mount-pane, the per-tab subscription and keybind installs, the
per-pane hint-context map, the store cascade โ which is what let each
tab land inside them without the shell changing shape.
One resize callback, for the whole app
Selkie::App's resize callbacks accumulate and there is no way to
remove one, so a tab that registered its own would leak a closure โ
holding a destroyed widget tree โ on every visit. The shell therefore
owns the single callback and fans it out: the hint footer refits, and
the accounts tab is told the new width so it can decide whether the
register's running-balance column still fits.
ACCESSORS
app,db,workspace,config,theme,storeโ the injected collaborators.iconsโ the activeApp::Moneymoor::Iconstier, resolved lazily fromconfig.iconsand refreshed byapply-theme-live.root,top-bar,tabs,content-host,hint-footerโ the shell's own widgets.viewโ the cachedBudgetView. A type object until the first recompute lands.payee($id)/category($id)/group($id)/account($id)โ id โ model, from the cachesset-viewrebuilds. A type object for an unknown id.viewed-periodโapp/period, or the period containing today if the store has not been seeded yet.current-tab,tab-label($name),tab-names.pane-contextsโ the current tab's< widget => hint context> pairs, which is how the footer resolves focus to a context.initial-focus-widgetโ the current tab's primary focus target.focus-content()โ put focus there. Called after every content rebuild;UIcalls it once more afterswitch-screen.budget-tab/accounts-tab/reports-tabโ the tab controller while its tab is mounted; a type object otherwise.pane-border($title, $sizing)/mount-pane($parent, $border, $pane, $context)โ the pane-mounting API the tab builders work through.rebuild-content()โ re-mount the current tab's panes (a live theme swap; the inspector toggle).
SEE ALSO
App::Moneymoor::StoreHandlers โ the state shape and the one mutation effect.
App::Moneymoor::Screen::Main::Subscriptions โ banner, footer and per-tab store wiring.
App::Moneymoor::Screen::Main::Keybinds โ the global binds.
App::Moneymoor::Screen::Main::Modals โ settings, diagnostics and the budget-period picker.
The hint bar drops whole groups to fit, so a wider terminal has to get its groups back and a narrower one has to shed them.
The register's running-balance column only exists above a width threshold, and crossing it rebuilds the column set.
The reports chart draws one bar per row and cannot scroll, so a taller pane shows categories a shorter one had to cut.
1. Re-resolve
$!themeby name โ several places in this class hold it, and every subscription closure captured it.2.
$!app.set-themeโ cascades through every registered screen's widget tree and repaints the plane bases.3. Re-derive what the shell baked in at build time. The banner resolved its gradient stops itself; the footer's colours live in its spans. Neither is a
Selkie::Stylethe cascade can see.4. Re-read the glyph tier โ Settings writes both keys and closes through this one path.
5.
rebuild-contentโ re-registers every per-tab subscription closure against the new palette, and puts focus back on the pane that just got destroyed and rebuilt. ) method apply-theme-live() { return without $!config; my $new = App::Moneymoor::Themes::load($!config.theme); $!theme = $new; $!app.set-theme($new.to-selkie) if $!app.defined; $!icons = self!load-icons;