Breeze.View behaviour (Breeze v0.5.0)

Copy Markdown View Source

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:

Usage

The module can be used by including use Breeze.View:

defmodule Demo do
  use Breeze.View
end

use 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>"
end

There are a handful of supported attributes:

  • id - the id of the element. This is required for focusables and implicits
  • focusable - 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. The focus style 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 active
  • focus-scope - defines a focus region. Set focus-scope="trap" to keep tab traversal inside it
  • class - token-based styling for the box. This is covered in the Style section.
  • style - inline style maps or %BackBreeze.Style{} values for the box
  • implicit - 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}
end

For 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)}
end

A 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 as border, w-15, text-3
  • style - 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, and size-n; w-* and h-* also accept auto, full, and screen, while size-* accepts auto and full
  • padding - p-n, px-n, py-n, pt-n, pr-n, pb-n, and pl-n
  • typography - font-bold, font-normal, italic, not-italic, and text-left|center|right
  • layout - block, inline, hidden, grid, grid-cols-n, grid-rows-n, gap-n, gap-x-n, and gap-y-n
  • positioning - absolute, fixed, inset-n, inset-x-n, inset-y-n, top-n, right-n, bottom-n, left-n, and z-n
  • borders - border, border-t, border-r, border-b, border-l, and rounded, plus terminal-specific border-square, border-none, and border-invisible
  • overflow - overflow-auto, overflow-hidden, and overflow-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:

ModifierMinimum 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 color
  • bg - set the background to the theme's default background color
  • text-x - set the foreground color
  • bg-x - set the background color
  • border-x - set the border color
  • scrollbar-x - set the scrollbar color
  • placeholder-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>
  """
end

The 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
end
def init(_children, _root_attrs, last_state) do
  {:ok, last_state, rerender_every: 500}
end

If 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 target
  • focus_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

assigns()

@type assigns() :: Breeze.Component.assigns()

Assigns passed to a view or function component.

event()

@type event() :: input_event() | named_event_payload()

A decoded terminal input or named event payload.

event_name()

@type event_name() :: :input | String.t()

The reserved terminal-input name or an application-defined named event.

input_event()

@type input_event() :: %{optional(String.t()) => term()}

A string-keyed terminal input payload.

named_event_payload()

@type named_event_payload() :: term()

An unrestricted payload for an application-defined named event.

rendered()

@type rendered() :: Breeze.Component.rendered()

Compiled template output returned by the ~H sigil.

reply()

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

reply_option()

@type reply_option() :: {:invalidate, boolean()}

An option returned alongside an event or message reply.

Callbacks

handle_event(event_name, event, t)

(optional)
@callback handle_event(event_name(), event(), Breeze.Term.t()) :: reply()

Handles :input with a string-keyed payload or a named event with any payload.

handle_info(term, t)

(optional)
@callback handle_info(term(), Breeze.Term.t()) :: reply()

Handles a message sent to the view process.

mount(keyword, t)

(optional)
@callback mount(
  keyword(),
  Breeze.Term.t()
) :: {:ok, Breeze.Term.t()}

Initializes a view with its startup options and term state.

render(assigns)

(optional)
@callback render(assigns()) :: rendered()

Renders a view or component from its assigns.

Functions

active_keybindings(map)

Return the currently active keybinding hints for the term.

assign(term_or_assigns, values)

@spec assign(map(), Enumerable.t()) :: map()

Merges values into term or component assigns via Breeze.Component.assign/2.

clear_flash(term_or_assigns, kind_or_id \\ nil)

@spec clear_flash(map(), atom() | String.t() | nil) :: map()

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.

cycle_theme(term)

@spec cycle_theme(map()) :: map()

Cycles a term through Breeze's standard theme set.

cycle_theme(term, opts)

@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}]

cycle_theme(event, term, opts)

@spec cycle_theme(term(), map(), keyword()) :: {:noreply, map()}

Handles a global keybinding event using a configurable theme set.

focus(term, value)

@spec focus(Breeze.Term.t(), String.t() | nil) :: Breeze.Term.t()

Sets the focused element ID, or clears focus when value is nil.

put_flash(term_or_assigns, kind, message, opts \\ [])

@spec put_flash(map(), atom() | String.t(), term(), keyword() | map()) :: map()

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.

put_focus_keybindings(term, focus_id, bindings)

Set keybindings that are only active when the given local focus target is focused.

put_implicit(term, id, mod, state)

@spec put_implicit(map(), String.t(), module(), map()) :: map()

Set the implicit state for the given element ID.

put_local_keybindings(term, bindings)

Set keybindings that are active anywhere inside the current view subtree.

put_theme(term, theme)

Set the active Breeze theme for the current term.

render_slot(slot)

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

render_slot(slot, assigns)

@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(term, id)

@spec reset(map(), String.t()) :: map()

Reset the implicit state for the given element ID, causing it to reinitialise on the next render.

switch_theme(term, theme, opts \\ [])

@spec switch_theme(map(), atom() | {term(), term()}, keyword()) :: map()

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_implicit(term, id, fun)

@spec update_implicit(map(), String.t(), ({module(), map()} -> map())) :: map()

Update the implicit state for the given element ID in place.