Breeze.View behaviour (Breeze v0.4.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 because the same module is also used to define component-only modules, but a root view must implement render/1.

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. Passing a binary remains backwards compatible.
  • 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:

def handle_event(_, %{"key" => "ArrowUp"}, term) do
  {:noreply, assign(term, counter: term.assigns.counter + 1)}
end

def handle_event(_, %{"key" => "ArrowDown"}, term) do
  {:noreply, assign(term, counter: term.assigns.counter - 1)}
end

def handle_event(_, %{"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".

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, width-15, text-3
  • style - a %BackBreeze.Style{} struct or a map for inline values

Passing a binary to style remains supported for backwards compatibility.

A box can be styled similar to CSS using the class attribute:

<box class="bold text-3 border width-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 styles are supported:

  • border - add a line border to the box
  • border-square - add a square block border to the box
  • bold - make the text bold
  • italic - make the text italic
  • inverse - reverse the foreground-background
  • reverse - reverse the foreground-background
  • inline - display the elements inline (join horizontally)
  • grid - lay out child elements in a grid
  • grid-cols-n - set the number of grid columns
  • grid-rows-n - set the number of grid rows
  • gap-x-n - set the horizontal gap between grid cells
  • gap-y-n - set the vertical gap between grid cells
  • width-x - set the width of the element
  • height-x - set the height of the element
  • overflow-hidden - clip child content to the viewport
  • offset-top-x - vertically scroll content by x rows
  • offset-left-x - horizontally scroll content by x columns
  • absolute - position the elements absolute relative to the parent

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-x-n and gap-y-n add horizontal and vertical spacing in terminal cells.

<box class="grid grid-cols-2 grid-rows-2 gap-x-1 gap-y-1 width-full">
  <box>One</box>
  <box>Two</box>
  <box>Three</box>
  <box>Four</box>
</box>

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 implicit module is first called with an init/2 callback (or optional init/3). It receives all child element attributes and the previous state. The init/3 form also receives root attributes for the implicit container.

init can either return the implicit state directly, 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
    %{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 or implicit event payload.

A view event name.

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.

Runtime slot entry generated by Breeze.Template

Callbacks

Handles a terminal or implicit event.

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.

Merge values into term.assigns, or into a plain assigns map inside a component function.

Declares an attribute for the next function component.

Declares an attribute for the next function component.

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.

Render a slot value using empty assigns.

Render a slot value with assigns passed to the slot render function.

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

Compiles a Breeze template from an ~H sigil.

Declares a slot for the next function component.

Declares a slot for the next function component.

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() :: map()

Assigns passed to a view or function component.

event()

@type event() :: map()

A decoded terminal or implicit event payload.

event_name()

@type event_name() :: term()

A view event name.

rendered()

@type rendered() :: term()

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.

slot_entry()

@type slot_entry() :: %{
  optional(atom()) => term(),
  __breeze_slot__: (map() -> iodata())
}

Runtime slot entry generated by Breeze.Template

Callbacks

handle_event(event_name, event, t)

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

Handles a terminal or implicit event.

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, values)

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

Merge values into term.assigns, or into a plain assigns map inside a component function.

attr(name, type)

(macro)

Declares an attribute for the next function component.

attr(name, type, opts)

(macro)

Declares an attribute for the next function component.

Options include :required, :default, and :doc.

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 | slot_entry() | [slot_entry()]) :: binary()

Render a slot value using empty assigns.

Accepts nil, a single slot entry, or a list of slot entries.

render_slot(slots, assigns)

@spec render_slot(nil | slot_entry() | [slot_entry()], map() | keyword() | nil) ::
  binary()

Render a slot value with assigns passed to the slot render function.

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.

sigil_H(arg, modifiers)

(macro)

Compiles a Breeze template from an ~H sigil.

slot(name)

(macro)

Declares a slot for the next function component.

slot(name, opts)

(macro)

Declares a slot for the next function component.

Use a do block to declare attributes accepted by each slot entry. Options include :required and :doc.

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, :nebula, :catppuccin, :dracula, :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.