Phosphor::Screen(S, M)
Abstract base for application screens.
A Screen is the controller layer: it configures a View on mount,
handles Msg values returned by View#dispatch, and drives per-frame
rendering. It receives clean, named Msg values — never raw Events.
The View handles the noisy Event → Msg translation.
Generic over S, the application's own state type (an immutable
struct, by convention — see AppState for the shape examples/todo
uses). A concrete app picks one S and every Screen/Widget/App
it works with shares it — class DashboardScreen < Screen(AppState, Msg),
for instance. This is what lets different apps built on Phosphor use
state shaped for what they actually track, instead of all sharing one
framework-mandated struct.
Also generic over M, the application's own Msg type — #handle
receives an M, not the framework's built-in Phosphor::Msg
specifically. Existing apps pass Phosphor::Msg explicitly for M;
a new app can define its own message enum instead.
See DESIGN.md § "Screens" for the full rationale and a worked example.
Constructors
Instance methods
Reference to the running application — used in on_mount and sync
for app.theme, app.add_port, and app.color_profile.
Handles msg by returning updated state and an optional Cmd.
Must be a pure function: no I/O, no blocking, no side effects. Wrap
async work in a Cmd and return it alongside the new state. Return
{state, nil} when no async work is needed.
Called once when this screen becomes active.
Mount widgets into view, set focus, subscribe global events, and
register Port instances here. Return the (possibly modified) initial
state. Do not make layout decisions — layout is computed in render.
Called when another screen is pushed on top of this one via
Cmd::Push.
The screen's View and WidgetState are preserved — on_mount is
not called again when this screen later resumes. No-op by default;
override when a screen needs to react (e.g. pausing a Port's work
while it's not visible).
Called when this screen is popped off the stack via Cmd::Pop and
resumes being the active screen.
The View and WidgetState from before the pause are still intact.
No-op by default; override to reverse whatever on_pause did — e.g.
re-registering a Port that was stopped when this screen paused (see
.claude/rules/async.md — ports don't survive a pause, so restarting
one means calling app.add_port again here, the same way on_mount
did the first time).
Called when this screen is popped off the stack via Cmd::Pop and
will not become active again.
App#run calls App#stop_ports_for right after this, so there's no
need to stop ports here manually. No-op by default; override for any
other cleanup a screen needs before it's discarded.
Renders this screen into buf within frame.
Compute layout here by splitting frame into sub-frames, then call
view.render_widget(id, state, sub_frame, buf) for each slot. Layout
is recomputed every frame, so terminal resize is handled for free.
Animates the widget registered under id from its currently
rendered state to whatever the block leaves it in, using
transition, without affecting any other cell in buf — thin
forwarder to View#transition_widget; see its own doc comment for
the full contract.
Macros
Compile-time DSL for building a Layout tree without the imperative
Layout.vertical/horizontal(...).split(frame) boilerplate.
Expands to a plain def layout(frame : Frame) : Array(Frame) — the
block is never evaluated at runtime, only read once at compile time
to generate the equivalent imperative code. Calling the generated
method costs nothing beyond what hand-writing the Layout calls
yourself would.
layout do vertical do length(1) fill length(1) end end
Nest vertical/horizontal blocks to split a slot along the other
axis; the nested block's own frames are spliced into the result in
place of that one slot (a nested block always takes Fill from its
parent — there's no syntax for giving it a fixed outer size):
layout do vertical do length(1) horizontal do length(20) fill end length(1) end end
produces the same 4-element Array(Frame), in the same order, as:
rows = Layout.vertical([Length.new(1), Fill.new, Length.new(1)] of Constraint).split(frame) cols = Layout.horizontal([Length.new(20), Fill.new] of Constraint).split(rows[1]) [rows[0], cols[0], cols[1], rows[2]]
bordered wraps a single nested vertical/horizontal/bordered/
padded block, inset 1 cell on every edge (Frame#inset(1)) before
that block's own split runs — purely a geometry adjustment, leaving
room for a border a BorderMock (or similar) draws separately.
bordered never draws anything itself:
layout do vertical do length(1) bordered do horizontal do length(20) fill end end length(1) end end
gives the same Array(Frame) as the previous example, except the
middle two frames come from splitting rows[1].inset(1) instead of
rows[1] directly — the title/status rows above and below still use
the full outer frame, only the bordered slot's own contents shrink.
padded(n) is bordered's general form: the same single-nested-
block wrapper, inset by n cells (Frame#inset(n)) instead of a
hardcoded 1 — bordered is exactly padded(1) with the added
implication that something draws a border in that space. padded
makes no assumption about what fills its padding region, if
anything; n must be a positive integer literal:
layout do padded(2) do vertical do fill length(1) end end end
bordered and padded nest inside each other and inside
vertical/horizontal freely, in any combination.
Pass flex: to vertical/horizontal to control how leftover space
(only possible when there's no Fill/Ratio constraint to absorb
it) is distributed — :start (the default), :end, :center,
:space_between, or :space_around, mapping to the matching
Flex member and forwarded as Layout.vertical/horizontal's own
flex argument:
layout do horizontal flex: :center do length(40) end end
Inside a vertical/horizontal block, use the constraint shorthand
in place of the Constraint types directly:
| Shorthand | Equivalent |
|---|---|
length(n) | Length.new(n) |
fill | Fill.new |
pct(n) | Percentage.new(n) |
min(n) | Min.new(n) |
max(n) | Max.new(n) |
ratio(a, b) | Ratio.new(a, b) |
Any of the shorthand above, or vertical/horizontal/bordered/
padded itself, accepts an optional id: tag — purely
documentation for a slot's purpose, with no effect on the generated
Layout code:
layout do vertical do length(1) fill id: :main length(1) end end
id: values repeated anywhere in the same layout do...end block
produce a compile-time warning (not an error — see
#layout_dsl_check_ids's own doc comment for exactly what this
does and doesn't catch).
A vertical/horizontal block that's empty, a bordered/padded
block that doesn't wrap exactly one nested vertical/horizontal/
bordered/padded block, padded with n <= 0, an unrecognized
flex: value, content other than the shorthand above or a nested
block, or a vertical/horizontal block whose slots are all
Length (summing past 10000) or all Percentage (summing past
100%) — a mix of constraint kinds skips this check entirely, since
Layout#split already clamps overflow gracefully once anything
other than fixed sizes is involved — all fail to compile, as does
calling the shorthand directly inside layout do ... end without a
vertical/horizontal/bordered/padded wrapper.
Defined as a macro on Screen(S) itself, not at module level, so
every screen gets it automatically through ordinary class
inheritance — including class Phosphor::MyScreen < Phosphor:: Screen(S), the dotted-name style every screen in this codebase
uses. A module-level macro would need either genuine lexical
nesting inside module Phosphor or an explicit include Phosphor
to be visible from a dotted-name class body; neither is needed
here, since Crystal resolves a class-scoped macro through the
ancestor chain the same way it resolves an inherited method.
See TASKS.md § "Phase 26: Layout DSL" for the full rationale.