class

Phosphor::Screen(S, M)

Inherits Reference < Object

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 EventMsg 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

new(app : App(S, M))
Source

Instance methods

app

Reference to the running application — used in on_mount and sync for app.theme, app.add_port, and app.color_profile.

Source
handle(msg : M, state : S) : Tuple(S, Cmd | Nil)

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.

Source
on_mount(state : S) : S

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.

Source
on_pause

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).

Source
on_resume

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).

Source
on_unmount

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.

Source
render(state : S, frame : Frame, buf : Buffer) : Void

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.

Source
transition_widget(id : Symbol, transition : Transition(S, M), state : S, frame : Frame, buf : Buffer, &) : Void

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.

Source
view

The focus and layout manager for this screen.

Source

Macros

layout

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:

ShorthandEquivalent
length(n)Length.new(n)
fillFill.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.

Source