Behaviour and data structure for screens in a TUI application.
A screen encapsulates a full view with its own mount/render/event lifecycle.
use Drafter.Screen injects the behaviour, import Drafter.App (so every
element helper — label/2, button/2, vertical/2 — is in scope), and
overridable no-op implementations of mount/1, render/1,
handle_event/2, handle_event/3, on_resume/2, unmount/1 and
keybindings/0, so a module only writes what it uses.
Screens are layered. The screen manager keeps a stack; a screen pushes a
child, replaces itself, opens a modal, or pops with a value by returning the
matching tuple from handle_event/2 or handle_event/3 — see result/0
for the shapes the runtime dispatches. When a child is popped the parent's
on_resume/2 receives the popped value.
defmodule ConfirmDialog do
use Drafter.Screen
@impl true
def mount(props), do: %{message: Map.get(props, :message, "Are you sure?")}
@impl true
def render(state) do
vertical([
label(state.message),
button("OK", on_click: :confirm),
button("Cancel", on_click: :cancel)
])
end
@impl true
def handle_event(:confirm, _data, _state), do: {:pop, :confirmed}
def handle_event(:cancel, _data, _state), do: {:pop, :cancelled}
def handle_event(_event, _data, state), do: {:noreply, state}
end
Drafter.ScreenManager.show_modal(ConfirmDialog, %{message: "Delete?"},
title: "Confirm",
width: 40,
height: 8
)Screen types and their options
The type is chosen with :type in the opts of new/3, and of
Drafter.App.push_screen/3 and Drafter.App.replace_screen/3, which pass opts straight
through. Default :default. Any other value raises FunctionClauseError.
Every key below is read once, when the screen is created; the map it produces
is the :options field of t/0. Sizes are in terminal cells.
:default — a full-screen view. Takes no options at all: :options is %{}
and any other key passed with it is discarded.
:modal — a centered overlay:
:width-dimension/0. Default:auto, which is 60 cells or the screen width minus 4, whichever is smaller.:height-dimension/0. Default:auto, which is 20 rows or the screen height minus 4, whichever is smaller.:position-position/0. Default:center.:overlay-boolean(), whether the screen behind is dimmed. Defaulttrue.:overlay_color-{r, g, b}with each component0..255. Default{0, 0, 0}.:overlay_opacity-float()from0.0(invisible) to1.0(opaque). Default0.5.:dismissable-boolean(). Defaulttrue. It decides who gets Escape, not what Escape does:truedelivers Escape to this screen'shandle_event/2, which must return{:pop, result}to close;falsepasses Escape down to the layer below untouched.:title-String.t()shown in the border, ornil. Defaultnil.:border-boolean(). Defaulttrue.
:popover — a small anchored overlay:
:width-dimension/0. Default:auto, which is 30 cells or the screen width minus 4, whichever is smaller.:height-dimension/0. Default:auto, which is 10 rows or the screen height minus 4, whichever is smaller.:position-position/0. Default{:at, 0, 0}. The resulting rect is clamped to stay on screen.:anchor- the widget id the popover is placed against, ornil. Defaultnil.:anchor_offset-{dx, dy}in cells from the anchor. Default{0, 1}.:overlay-boolean(). Defaultfalse.:dismissable-boolean(). Defaulttrue, with the same meaning as for:modal: it routes Escape to the screen rather than closing it.:border-boolean(). Defaulttrue.
:toast — a timed notification pushed onto the screen stack:
:width-pos_integer(). Default40. The height is always 3 rows.:position-toast_position/0. Default:bottom_right.:duration- milliseconds on screen. Default3000.:variant-:info,:success,:warning, or:error. Default:info.:dismissable-boolean(). Defaulttrue.
These are the options of a screen pushed with type: :toast.
Drafter.App.show_toast/2 is a different mechanism: it builds no
screen, keeps its own toast list, and reads only :variant (default :info),
:duration (default 3000) and :position (default :bottom_right) from the
options given to it.
:panel — an edge-docked side panel:
:width-dimension/0. Default30. Used for:leftand:right; a top or bottom panel is always the full screen width.:height-dimension/0. Default:full. Used for:topand:bottom; a left or right panel is always the full screen height.:position-:left,:right,:top, or:bottom. Default:right.:overlay-boolean(). Defaultfalse.:resizable-boolean(). Defaultfalse.:collapsible-boolean(). Defaulttrue.
Relationship to Drafter.App
A screen is not an app. Drafter.App owns the terminal session, the timers,
and the top-level event loop; a screen is one entry on the stack that session
renders.
A screen module can still be handed to Drafter.run/2 or
Drafter.Test.start_headless/3, because it exports the mount/1, render/1
and handle_event/2 the loop calls — but only if its render/1 returns a
non-empty tree. The loop falls back to render/2 when render/1 returns [],
and a screen defines no render/2, so an empty render raises
UndefinedFunctionError. Nothing else app-specific applies: on_ready/1,
on_timer/2 and __mouse_hover__/0 are absent and skipped, and the
screen-stack results ({:pop, _} and friends) mean nothing at the top level.
keybinding/3 is imported along with the rest of Drafter.App, and it does
compile inside a screen, but it does not work: use Drafter.Screen sets no
@before_compile hook and no @keybinding_hints attribute, so the macro emits
a warning about the undefined attribute, keybindings/0 keeps returning []
(Drafter.Widget.Footer therefore shows nothing for the screen), and because
the generated handle_event/2 is overridable the keybinding clause replaces
it outright — every event that does not match a keybinding/3 clause raises
FunctionClauseError. Write the key clauses by hand as handle_event({:key, :q}, state)
with a catch-all clause, and define keybindings/0 directly to feed the footer.
Summary
Types
A width or height option.
Which edge a :panel docks to. Any other value is treated as :right.
Where a :modal or :popover is placed.
A screen's rect, and the screen area it is measured against, in terminal cells.
What handle_event/2 and handle_event/3 may return.
The layout a screen is drawn with, chosen by the :type option of new/3.
One entry on the screen stack.
Where a :toast is placed. Any other value is treated as :bottom_right.
Callbacks
Handles an event that reached this screen, without a payload.
Handles an event together with its payload.
Builds the screen's initial state from the props it was pushed with.
Folds the value a popped child screen returned back into this screen's state.
Builds the screen's element tree from its state.
Releases the screen's resources as it leaves the stack. Returns :ok.
Functions
The rect a screen occupies inside screen_rect, from its type and options.
Runs one event through the screen module and folds the answer back into the struct.
Calls the screen module's mount/1 with the struct's props and stores the result.
Builds an unmounted screen struct.
Calls the screen module's render/1 with the stored state and returns its element tree.
Hands result to the screen module's on_resume/2 and stores the state it returns.
Calls the screen module's unmount/1 if it exports one. Returns :ok.
Types
@type dimension() :: :auto | :full | pos_integer() | {:percent, 0..100}
A width or height option.
:auto takes the per-type default clamped to the screen minus 4 cells, :full
takes the whole screen, an integer is that many cells clamped to the screen, and
{:percent, pct} is pct percent of the screen. Any other value behaves as
:auto.
@type panel_position() :: :left | :right | :top | :bottom
Which edge a :panel docks to. Any other value is treated as :right.
Where a :modal or :popover is placed.
{:at, x, y} is an absolute cell. Any other value, including :left and
:right, is treated as :center.
@type props() :: map()
A screen's rect, and the screen area it is measured against, in terminal cells.
@type result() :: {:ok, state()} | {:noreply, state()} | {:pop, term()} | {:push, module(), props(), keyword()} | {:replace, module(), props(), keyword()} | {:show_modal, module(), props(), keyword()}
What handle_event/2 and handle_event/3 may return.
These are the shapes the screen manager dispatches on. Anything else —
including the three-element {:push, module, props} and
{:replace, module, props}, {:show_toast, message, opts}, and
{:stop, reason} — is discarded: the event is passed through to the layer
below and the screen's state is left as it was.
{:ok, state}- keepstateand redraw.{:noreply, state}- keepstate. The event carries on to the layer below unless the screen type captures it.{:pop, result}- pop this screen;resultreaches the parent'son_resume/2.{:push, module, props, opts}- push a child screen;optsis the option list of the screen type given by its:typekey.{:replace, module, props, opts}- replace this screen with another.{:show_modal, module, props, opts}- pushmodulewith:typeforced to:modal.
@type screen_type() :: :default | :modal | :popover | :toast | :panel
The layout a screen is drawn with, chosen by the :type option of new/3.
@type state() :: term()
@type t() :: %Drafter.Screen{ id: reference(), module: module(), options: map(), parent_id: reference() | nil, props: map(), rect: map() | nil, state: term(), type: screen_type(), widget_hierarchy: term() }
One entry on the screen stack.
:id is assigned by new/3 and identifies the screen to
the screen manager. :state is nil until mount_screen/1 runs.
:options is the type's option map described in the module doc. :parent_id
and :rect are filled in by the screen manager, and :widget_hierarchy by the
renderer.
@type toast_position() ::
:top_left
| :top_center
| :top_right
| :bottom_left
| :bottom_center
| :bottom_right
Where a :toast is placed. Any other value is treated as :bottom_right.
Callbacks
Handles an event that reached this screen, without a payload.
event is a raw terminal event ({:key, key}, {:key, key, modifiers},
{:mouse, data}), the atom :passthrough_event when a widget below changed
but claimed nothing, or a widget callback name when the screen defines no
handle_event/3.
Returns a result/0. The generated default is {:noreply, state}.
Because use Drafter.Screen always generates handle_event/3, the dispatcher
always calls the three-argument form; a screen that defines only this one is
reached through the generated bridge, which forwards with the payload dropped.
Defining any clause here replaces the generated default entirely, so always
finish with a catch-all clause.
Handles an event together with its payload.
Called for every event the screen sees. data is the payload of a widget
callback — the row a DataTable selected, the text a TextInput holds — and is
nil for raw terminal events, which carry none.
Returns a result/0. The generated default forwards to handle_event/2,
discarding data.
Builds the screen's initial state from the props it was pushed with.
props is the map given to new/3, Drafter.App.push_screen/3, or
show_modal/3. The returned term becomes the :state field of t/0 and is
handed to every other callback. Called once, by mount_screen/1, before the
first render. The generated default returns %{}.
Folds the value a popped child screen returned back into this screen's state.
result is the term the child passed to {:pop, result}, and nil when the
child was popped by Drafter.App.pop_screen/1 with no argument. Returns the
new state; there is no tuple to return. The generated default returns state
unchanged.
@impl true
def on_resume(:confirmed, state), do: %{state | deleted: true}
def on_resume(_result, state), do: state
Builds the screen's element tree from its state.
Returns what the element helpers of Drafter.App return — a {:layout, ...}
or other element tuple, or a list of them. Called on every frame the screen is
visible, so it must be free of side effects. The generated default returns [].
@callback unmount(state()) :: :ok
Releases the screen's resources as it leaves the stack. Returns :ok.
Called by unmount_screen/1 when the screen is popped or replaced. The return
value is ignored. The generated default returns :ok.
Functions
The rect a screen occupies inside screen_rect, from its type and options.
screen_rect is the whole terminal area as rect/0. The result is a
rect/0 too. :default returns screen_rect itself. :modal is sized and
placed by its options and is not clamped, so an oversized :position can put it
partly off screen; :popover is clamped into screen_rect. :toast is always
3 rows tall and sits 2 cells in from the corner its :position names. A :left
or :right :panel is always the full screen height and a :top or :bottom
panel the full width, whatever :height and :width say.
iex> modal = Drafter.Screen.new(MyModal, %{}, type: :modal, width: 40, height: 10)
iex> Drafter.Screen.calculate_rect(modal, %{x: 0, y: 0, width: 80, height: 24})
%{x: 20, y: 7, width: 40, height: 10}
iex> toast = Drafter.Screen.new(Banner, %{}, type: :toast)
iex> Drafter.Screen.calculate_rect(toast, %{x: 0, y: 0, width: 80, height: 24})
%{x: 38, y: 19, width: 40, height: 3}
iex> panel = Drafter.Screen.new(Sidebar, %{}, type: :panel, width: 30)
iex> Drafter.Screen.calculate_rect(panel, %{x: 0, y: 0, width: 80, height: 24})
%{x: 50, y: 0, width: 30, height: 24}
iex> full = Drafter.Screen.new(MainScreen)
iex> Drafter.Screen.calculate_rect(full, %{x: 0, y: 0, width: 80, height: 24})
%{x: 0, y: 0, width: 80, height: 24}
Runs one event through the screen module and folds the answer back into the struct.
An {:app_callback, name, data} event is delivered as name with data; every
other event is delivered as itself with nil for the payload. Both go to
handle_event/3 whenever the module exports it, which every module built with
use Drafter.Screen does.
{:ok, state} and {:noreply, state} come back as {:ok, screen} and
{:noreply, screen} with the new state stored. Every other value — the
navigation results of result/0, and anything unrecognised — is returned
exactly as the callback produced it, with the screen struct unchanged.
Calls the screen module's mount/1 with the struct's props and stores the result.
Returns the screen with :state set. Raises FunctionClauseError on anything
that is not a t/0.
Builds an unmounted screen struct.
module is the screen module, props the map its mount/1 will receive
(default %{}), and opts a keyword list whose :type picks the screen type
(default :default) and whose remaining keys are that type's options, listed in
the module doc. Keys the type does not read are discarded, and a :type that is
not one of the five raises FunctionClauseError.
The returned struct has a fresh :id, :state nil — mount_screen/1 fills
it in — and :widget_hierarchy, :parent_id and :rect nil.
iex> screen = Drafter.Screen.new(MyModal, %{title: "Hi"}, type: :modal, width: 40)
iex> screen.type
:modal
iex> screen.options.width
40
iex> screen.options.overlay_opacity
0.5
iex> screen.state
nil
iex> Drafter.Screen.new(MainScreen).options
%{}
Calls the screen module's render/1 with the stored state and returns its element tree.
Hands result to the screen module's on_resume/2 and stores the state it returns.
result is the value the popped child screen passed to {:pop, result}.
Returns the screen unchanged when the module exports no on_resume/2.
@spec unmount_screen(t()) :: :ok
Calls the screen module's unmount/1 if it exports one. Returns :ok.
The callback's return value is ignored, and a module without one is left alone.