A Breeze View is a process that handles events, updates its states and renders to the terminal. Breeze Views are inspired by Phoenix LiveView, but not 100% compatible.
Prior art
Breeze's template/component ergonomics are inspired by Phoenix LiveView. Relevant upstream modules:
- https://github.com/phoenixframework/phoenix_live_view/blob/main/lib/phoenix_component.ex
- https://github.com/phoenixframework/phoenix_live_view/blob/main/lib/phoenix_live_view/tag_engine.ex
Usage
The module can be used by including use Breeze.View:
defmodule Demo do
use Breeze.View
enduse Breeze.View declares the Breeze.View behaviour. The callbacks are
optional, but a root view must implement render/1. Modules that only define
reusable components can use Breeze.Component instead.
Initial state
The initial state can be set in the mount callback:
def mount(_opts, term), do: {:ok, assign(term, counter: 0)}Rendering
Rendering is performed using Breeze's ~H template sigil.
def render(assigns) do
~H"<box>Counter: <%= @counter %></box>"
endThere are a handful of supported attributes:
id- the id of the element. This is required for focusables and implicitsfocusable- if the element should be added to the focus tree. These are added in the order they appear, and can be toggled using tab/shift-tab. Thefocusstyle state can be used to style these. E.g. class="border focus:border-3"default-focus- marks the preferred focus target when a view or focus scope becomes activefocus-scope- defines a focus region. Setfocus-scope="trap"to keep tab traversal inside itclass- token-based styling for the box. This is covered in the Style section.style- inline style maps or%BackBreeze.Style{}values for the boximplicit- this is a module that will be used for implicit state. This is covered in the Implicits section.
Handling events
Events that come from the terminal or an implicit are handled in the
optional handle_event/3 callback. Terminal input uses the reserved :input
event name and a string-keyed payload:
def handle_event(:input, %{"key" => "ArrowUp"}, term) do
{:noreply, assign(term, counter: term.assigns.counter + 1)}
end
def handle_event(:input, %{"key" => "ArrowDown"}, term) do
{:noreply, assign(term, counter: term.assigns.counter - 1)}
end
def handle_event(:input, %{"key" => "q"}, term) do
{:stop, term}
end
def handle_event(_, _, term) do
{:noreply, term}
endFor convenience, keys are converted to a more friendly representation for example, instead of sending "A" which is provided by the terminal, we convert it to "ArrowUp".
Modified keys add JS-style boolean fields to the same string-keyed map:
%{"key" => "Backspace", "ctrlKey" => true}Mouse input is nested under "mouse". Button and action names are strings,
coordinates are zero-based positions in the receiving view's coordinate
space, and active modifiers use the same JS-style fields as keyboard input:
%{
"mouse" => %{
"button" => "left",
"action" => "press",
"x" => 12,
"y" => 7,
"shiftKey" => true
},
"target" => "save",
"focused" => "url",
"row" => 1,
"col" => 4
}"target", "focused", "row", and "col" are added when the pointer
intersects a rendered target. "focused" contains the focus target from
before the event is dispatched. Row and column are zero-based positions inside
the target. Root-view coordinates are screen-relative; live-child coordinates
are translated into the child's coordinate space.
Repeated wheel events may contain a positive integer "repeat" inside the
"mouse" map.
Events emitted by Breeze implicits use the string handler name configured by
br-change or br-submit. Built-in implicits currently emit atom-keyed maps:
def handle_event("selection_changed", %{value: value, index: index}, term) do
{:noreply, assign(term, selected: value, selected_index: index)}
endA named event's payload is otherwise unrestricted. Custom implicits and callers dispatching events directly may use any term, including maps, structs, lists, or scalar values.
If handle_event/3 is not implemented, events that reach the view are
ignored. If it is implemented, normal Elixir function clause matching
applies.
Any other messages sent to the process are handled using the optional
handle_info/2 callback:
def handle_info(:some_message, term), do: {:noreply, term}If handle_info/2 is not implemented, those messages are ignored.
Style
Breeze supports two styling inputs:
class- string tokens such asborder,w-15,text-3style- a%BackBreeze.Style{}struct or a map for inline values
A box can be styled with Tailwind-compatible utility names using the class attribute. Numeric sizing and spacing values are literal terminal cells:
<box class="font-bold text-3 border w-15">Hello World</box>Inline maps can be used when you want direct BackBreeze values:
<box style={%{border: :rounded, border_color: 3, width: 15}}>Hello World</box>The following utility groups are supported:
- sizing -
w-n,h-n,max-h-n, andsize-n;w-*andh-*also acceptauto,full, andscreen, whilesize-*acceptsautoandfull - padding -
p-n,px-n,py-n,pt-n,pr-n,pb-n, andpl-n - typography -
font-bold,font-normal,italic,not-italic, andtext-left|center|right - layout -
block,inline,hidden,grid,grid-cols-n,grid-rows-n,gap-n,gap-x-n, andgap-y-n - positioning -
absolute,fixed,inset-n,inset-x-n,inset-y-n,top-n,right-n,bottom-n,left-n, andz-n - borders -
border,border-t,border-r,border-b,border-l, androunded, plus terminal-specificborder-square,border-none, andborder-invisible - overflow -
overflow-auto,overflow-hidden, andoverflow-scroll
Breeze also provides terminal-specific utilities for colors, scrollbars, content repetition, scroll offsets, and foreground/background inversion.
Grid layout
Grid children flow from left to right and then onto the next row. Use
grid-cols-n and, when a fixed row count is useful, grid-rows-n to define
the tracks. gap-n, gap-x-n, and gap-y-n add spacing in terminal cells.
<box class="grid grid-cols-2 grid-rows-2 gap-x-1 gap-y-1 w-full">
<box>One</box>
<box>Two</box>
<box>Three</box>
<box>Four</box>
</box>Responsive styles
Responsive modifiers apply styles at or above a minimum terminal width. Unprefixed styles provide the base layout, and prefixed styles override them as the terminal grows:
| Modifier | Minimum width |
|---|---|
sm: | 40 columns |
md: | 60 columns |
lg: | 80 columns |
xl: | 120 columns |
2xl: | 160 columns |
<box class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
<box>Always shown</box>
<box class="hidden md:block">Shown from 60 columns</box>
<box class="hidden lg:block">Shown from 80 columns</box>
</box>Responsive and state modifiers can be chained. For example,
md:focus:border-primary requires at least 60 columns and focus. Responsive
styles are reevaluated whenever the terminal is resized. Hidden elements do
not participate in layout or grid track counting until a display utility such
as md:block reveals them.
The current dimensions and active breakpoint are available to templates as
@breeze.terminal.width, @breeze.terminal.height, and
@breeze.breakpoint.
Colors
text- set the foreground to the theme's default text colorbg- set the background to the theme's default background colortext-x- set the foreground colorbg-x- set the background colorborder-x- set the border colorscrollbar-x- set the scrollbar colorplaceholder-text-x- set an input placeholder's foreground color
For the color classes above, x can be a numeric ANSI color index or a
theme variable such as primary, muted, or panel. See
Breeze.Theme for the complete variable reference and
custom theme configuration.
<box class="bg-panel border-primary text-muted">Status</box>Implicits
Implicits add stateful event handling outside the view. Implement the Breeze.Implicit behaviour to package that logic as a reusable renderer extension.
For example, consider a list component:
def render(assigns) do
~H"""
<.list id="my-list" br-change="my_custom_event">
<:item value="hello">Hello</:item>
<:item value="world">World</:item>
<:item value="foo">Foo</:item>
</.list>
"""
end
def handle_event("my_custom_event", %{value: value}, term), do: ...Ideally, we don't want to have to keep track of the selected value, handle key events, scroll position, viewport overflow, etc. within our view. We might only care about the selected value. In this case, we can define the list component to use an implicit state module.
attr :id, :string, required: true
attr :rest, :global
slot :item do
attr :value, :string, required: true
end
def list(assigns) do
~H"""
<box
id={@id}
focusable
class="border focus:border-primary"
implicit={MyAppList}
{@rest}
>
<box
:for={item <- @item}
value={item.value}
class="selected:bg-primary selected:text-background focus:selected:bg-accent focus:selected:text-background"
>
{render_slot(item)}
</box>
</box>
"""
endThe module in an implicit attribute must be a static module reference. To add
or remove an implicit dynamically, put :if on an element whose implicit
module remains literal:
<box :if={@enabled?} id="items" implicit={MyAppList}>...</box>Expressions such as implicit={@implicit_module} are rejected at compile
time, and implicit cannot be supplied through a spread attribute.
The implicit module is first called with an init/3 callback. It receives all child
element attributes, root attributes for the implicit container, and the previous state.
init returns {:ok, state} or {:ok, state, options}. The options form is used for
renderer-driven animation behavior such as periodic rerenders.
defmodule MyAppList do
@behaviour Breeze.Implicit
def init(children, root_attrs, last_state) do
{:ok,
%{values: Enum.map(children, &(&1.value)), selected: last_state[:selected], root: root_attrs}}
end
enddef init(_children, _root_attrs, last_state) do
{:ok, last_state, rerender_every: 500}
endIf rerender_every is set, Breeze will periodically call animate/5 when it is
implemented. The final argument includes timing context such as :now,
:frame, :last_render_at, :last_interaction_at, :pending?, and :focused?.
There is also a handle_event/3 callback. This is similar to the callback for a view, but
returns different values. Here we handle key events and return a :change event along
with the new state. The :change will be used by br-change to pass through to the handle_event
callback of the Breeze.View.
def handle_event(_, %{"key" => "ArrowDown"}, %{values: values} = state) do
index = Enum.find_index(values, &(&1 == state.selected))
value = if index, do: Enum.at(values, index + 1) || hd(values), else: hd(values)
{{:change, %{value: value}}, %{state | selected: value}}
end
def handle_event(_, %{"key" => "ArrowUp"}, %{values: values} = state) do
index = Enum.find_index(values, &(&1 == state.selected))
first = hd(Enum.reverse(values))
value = if index, do: Enum.at(values, index - 1) || first, else: first
{{:change, %{value: value}}, %{state | selected: value}}
end
def handle_event(_, _, state), do: {:noreply, state}There are two final handlers used during rendering.
animate/5 can transform the rendered BackBreeze.Box for lightweight
renderer-driven animation and other presentation changes. It can return either
the updated box directly or {:ok, box, overlays: overlays} to request
terminal overlays during async animation passes.
handle_modifiers/3 receives :root or :child as the first argument and can be
used to tell the renderer things about the state.
Return values can include style flags (for example selected: true) and structured
scroll modifiers (scroll_y, scroll_x, or scroll: {top, left}).
Root implicit modifiers can also influence focus handling:
default_focus: true- mark the root element as the preferred focus targetfocus_scope: :trap- constrain tab/shift-tab navigation to this implicit subtree
def handle_modifiers(:child, attributes, state) do
if state.selected == Keyword.get(attributes, :value) do
[selected: true]
else
[]
end
end
def handle_modifiers(:root, _attributes, state) do
[scroll_y: state.offset]
end
Summary
Types
Assigns passed to a view or function component.
A decoded terminal input or named event payload.
The reserved terminal-input name or an application-defined named event.
A string-keyed terminal input payload.
An unrestricted payload for an application-defined named event.
Compiled template output returned by the ~H sigil.
A valid return value from a view event or message callback.
An option returned alongside an event or message reply.
Callbacks
Handles :input with a string-keyed payload or a named event with any payload.
Handles a message sent to the view process.
Initializes a view with its startup options and term state.
Renders a view or component from its assigns.
Functions
Return the currently active keybinding hints for the term.
Merges values into term or component assigns via Breeze.Component.assign/2.
Clear flash messages from assigns.breeze.flash.
Cycles a term through Breeze's standard theme set.
Cycles a term through a configurable theme set, or handles a global keybinding event using the standard theme set.
Handles a global keybinding event using a configurable theme set.
Sets the focused element ID, or clears focus when value is nil.
Append a flash message to assigns.breeze.flash.
Set keybindings that are only active when the given local focus target is focused.
Set the implicit state for the given element ID.
Set keybindings that are active anywhere inside the current view subtree.
Set the active Breeze theme for the current term.
Renders a component slot. This delegates to Breeze.Component.render_slot/1.
Renders a component slot with assigns. This delegates to Breeze.Component.render_slot/2.
Reset the implicit state for the given element ID, causing it to reinitialise on the next render.
Set a named theme and update assigns.breeze.theme metadata.
Update the implicit state for the given element ID in place.
Types
@type assigns() :: Breeze.Component.assigns()
Assigns passed to a view or function component.
@type event() :: input_event() | named_event_payload()
A decoded terminal input or named event payload.
@type event_name() :: :input | String.t()
The reserved terminal-input name or an application-defined named event.
A string-keyed terminal input payload.
@type named_event_payload() :: term()
An unrestricted payload for an application-defined named event.
@type rendered() :: Breeze.Component.rendered()
Compiled template output returned by the ~H sigil.
@type reply() :: {:noreply, Breeze.Term.t()} | {:noreply, Breeze.Term.t(), [reply_option()]} | {:stop, Breeze.Term.t()} | {:stop, Breeze.Term.t(), [reply_option()]}
A valid return value from a view event or message callback.
@type reply_option() :: {:invalidate, boolean()}
An option returned alongside an event or message reply.
Callbacks
@callback handle_event(event_name(), event(), Breeze.Term.t()) :: reply()
Handles :input with a string-keyed payload or a named event with any payload.
@callback handle_info(term(), Breeze.Term.t()) :: reply()
Handles a message sent to the view process.
@callback mount( keyword(), Breeze.Term.t() ) :: {:ok, Breeze.Term.t()}
Initializes a view with its startup options and term state.
Renders a view or component from its assigns.
Functions
Return the currently active keybinding hints for the term.
@spec assign(map(), Enumerable.t()) :: map()
Merges values into term or component assigns via Breeze.Component.assign/2.
Clear flash messages from assigns.breeze.flash.
Without a second argument all flash messages are removed. With a second argument, messages matching that kind or id are removed.
Cycles a term through Breeze's standard theme set.
@spec cycle_theme( map(), keyword() ) :: map()
@spec cycle_theme(term(), map()) :: {:noreply, map()}
Cycles a term through a configurable theme set, or handles a global keybinding event using the standard theme set.
Pass :themes with a list of built-in names or {name, theme} tuples to
customize the cycle. The event-handler form can be used directly in a global
keybinding:
global_keybindings: [{"F3", "Cycle theme", &Breeze.View.cycle_theme/2}]
Handles a global keybinding event using a configurable theme set.
@spec focus(Breeze.Term.t(), String.t() | nil) :: Breeze.Term.t()
Sets the focused element ID, or clears focus when value is nil.
Append a flash message to assigns.breeze.flash.
The resulting flash assign is a stack-friendly list consumed by
Breeze.Blocks.flash_group/1.
term
|> put_flash(:info, "Saved", max: 3, duration: 5_000)
|> put_flash(:error, "Publish failed", id: "publish-error", highlight: "error")The left highlight strip can be customized with :highlight or :color.
It accepts Breeze semantic color names, ANSI color indexes, RGB tuples, and
"#rgb"/"#rrggbb" hex strings.
Set keybindings that are only active when the given local focus target is focused.
Set the implicit state for the given element ID.
Set keybindings that are active anywhere inside the current view subtree.
Set the active Breeze theme for the current term.
@spec render_slot( nil | Breeze.Component.slot_entry() | [Breeze.Component.slot_entry()] ) :: binary()
Renders a component slot. This delegates to Breeze.Component.render_slot/1.
@spec render_slot( nil | Breeze.Component.slot_entry() | [Breeze.Component.slot_entry()], map() | keyword() | nil ) :: binary()
Renders a component slot with assigns. This delegates to Breeze.Component.render_slot/2.
Reset the implicit state for the given element ID, causing it to reinitialise on the next render.
Set a named theme and update assigns.breeze.theme metadata.
The theme can be one of Breeze's built-in cycle names (:system16, :system,
:greenscreen, :nebula, :catppuccin, :dracula, :commander, :gruvbox,
:nord, :solarized_light, or :solarized_dark) or a {name, theme} tuple.
Update the implicit state for the given element ID in place.