Build a Simple Breeze App

Copy Markdown View Source

In this guide, we will build a small Breeze application in the same style you may recognize from Phoenix LiveView. We will initialize state in mount/2, render from assigns, react to events in handle_event/3, and test the view without starting an interactive terminal.

The example app is Task Pad, a tiny todo list. It is intentionally small, but it covers the pieces most Breeze applications need: assigns, ~H templates, inputs, lists, focus, keybindings, events, themes, and tests.

Create a Mix project

We start by generating a supervised Mix application:

mix new task_pad --sup
cd task_pad

Then we add Breeze to mix.exs:

defp deps do
  [
    {:breeze, "~> 0.4.0"}
  ]
end

0.4.0 is the version this guide targets.

Then we fetch dependencies:

mix deps.get

Add the data layer

We keep application rules out of the terminal view. We will create lib/task_pad/in_memory_tasks.ex to own the task list state and transformations:

defmodule TaskPad.InMemoryTasks do
  @filters ["all", "open", "done"]

  def new do
    %{
      tasks: [
        %{id: "1", title: "Read the Breeze README", done?: true},
        %{id: "2", title: "Build a small terminal app", done?: false}
      ],
      next_id: 3
    }
  end

  def add(state, title) do
    title = String.trim(title)

    if title == "" do
      state
    else
      task = %{id: Integer.to_string(state.next_id), title: title, done?: false}
      %{state | tasks: state.tasks ++ [task], next_id: state.next_id + 1}
    end
  end

  def toggle(state, nil), do: state

  def toggle(state, task_id) do
    tasks =
      Enum.map(state.tasks, fn
        %{id: ^task_id} = task -> %{task | done?: !task.done?}
        task -> task
      end)

    %{state | tasks: tasks}
  end

  def clear_done(state), do: %{state | tasks: Enum.reject(state.tasks, & &1.done?)}

  def visible(state, filter) do
    case filter do
      "open" -> Enum.reject(state.tasks, & &1.done?)
      "done" -> Enum.filter(state.tasks, & &1.done?)
      _ -> state.tasks
    end
  end

  def valid_filter?(filter), do: filter in @filters

  def selected_visible_id(nil, []), do: nil

  def selected_visible_id(selected_id, tasks) do
    if Enum.any?(tasks, &(&1.id == selected_id)) do
      selected_id
    else
      case tasks do
        [%{id: id} | _] -> id
        [] -> nil
      end
    end
  end

  def status_text(state) do
    done = Enum.count(state.tasks, & &1.done?)
    total = length(state.tasks)
    "#{done}/#{total} done"
  end
end

For a real app, this module is where we add persistence, validation, or calls to a database-backed context. The Breeze view stays focused on input, focus, and rendering.

Add the view

Next we create lib/task_pad/view.ex. Breeze's API is LiveView-inspired, but it does not depend on Phoenix LiveView.

defmodule TaskPad.View do
  use Breeze.View
  import Breeze.Blocks

  def mount(_opts, term) do
    {:ok,
     term
     |> focus("new-task")
     |> assign(
       tasks_state: TaskPad.InMemoryTasks.new(),
       new_task: "",
       filter: "all",
       selected_task_id: "1"
     )}
  end
end

term is the Breeze view state. We assign values to it, choose the focused element, and return {:ok, term}.

Render from assigns

render/1 returns a Breeze ~H template. The template syntax is intentionally close to HEEx: we use @assigns, function components, slots, :if, and :for. assign/2 also accepts the plain map passed to render/1; values added there exist only for that render and are not written back to the view process. That is the right place for projections such as the visible task list and empty-state message.

def render(assigns) do
  assigns =
    assign(assigns,
      visible_tasks: TaskPad.InMemoryTasks.visible(assigns.tasks_state, assigns.filter),
      empty_message: empty_message(assigns.filter)
    )

  ~H"""
  <box class="grid grid-cols-1 grid-rows-2 width-screen height-screen bg">
    <box class="height-4 padding-left-2 padding-right-2 padding-top-1">
      <box class="inline width-full">
        <box class="bold text-primary">Task Pad</box>
        <box class="text-muted">  A small Breeze app</box>
        <box class="width-full text-right text-muted">
          {TaskPad.InMemoryTasks.status_text(@tasks_state)}
        </box>
      </box>
      <.tabs
        id="filters"
        selected={@filter}
        br-change="filter_changed"
        panel={false}
        variant="underline"
        class="width-full padding-top-1"
      >
        <:tab value="all" label="All"/>
        <:tab value="open" label="Open"/>
        <:tab value="done" label="Done"/>
      </.tabs>
    </box>

    <box class="height-full padding-left-2 padding-right-2">
      <box class="height-3">
        <box class="text-muted">New task</box>
        <.input
          id="new-task"
          input-value={@new_task}
          input-placeholder="Type a task and press Enter"
          br-change="new_task_changed"
          class="width-full"
        >
          {@new_task}
        </.input>
      </box>

      <box class="height-full padding-top-1">
        <.list
          :if={@visible_tasks != []}
          id="tasks"
          br-change="task_selected"
          list-selected={selected_visible_id(@selected_task_id, @visible_tasks)}
          class="height-full width-full"
          item_class="width-full"
        >
          <:item :for={task <- @visible_tasks} value={task.id}>
            {task_label(task)}
          </:item>
        </.list>

        <box :if={@visible_tasks == []} class="border height-full width-full text-muted">
          {@empty_message}
        </box>
      </box>
    </box>
  </box>
  """
end

The built-in blocks handle the terminal-specific behavior. The input captures typing while focused. The tabs handle left and right navigation and emit a change event when the active filter changes. The list handles selection with arrow keys and emits a change event when the selected item changes.

Handle events

Components emit named events through br-change, and keyboard input arrives as a regular Breeze event.

def handle_event("new_task_changed", %{value: value}, term) do
  {:noreply, assign(term, new_task: value)}
end

def handle_event("task_selected", %{value: task_id}, term) do
  {:noreply, assign(term, selected_task_id: task_id)}
end

def handle_event("filter_changed", %{value: filter}, term) do
  {:noreply, set_filter(term, filter)}
end

def handle_event(_, %{"key" => "Enter"}, %{focused: "new-task"} = term) do
  {:noreply, add_task(term)}
end

def handle_event(_, %{"key" => key}, %{focused: "tasks"} = term)
    when key in ["Enter", " "] do
  {:noreply, toggle_selected_task(term)}
end

def handle_event(_, %{"key" => "a"}, term), do: {:noreply, focus(term, "new-task")}
def handle_event(_, %{"key" => "c"}, term), do: {:noreply, clear_done(term)}
def handle_event(_, _, term), do: {:noreply, term}

The helper functions are ordinary Elixir. We keep state changes small and explicit:

defp add_task(term) do
  title = String.trim(term.assigns.new_task)

  if title == "" do
    term
  else
    tasks_state = TaskPad.InMemoryTasks.add(term.assigns.tasks_state, title)
    selected_task_id = Integer.to_string(term.assigns.tasks_state.next_id)

    assign(term,
      tasks_state: tasks_state,
      new_task: "",
      selected_task_id: selected_task_id,
      filter: "all"
    )
    |> focus("tasks")
  end
end

defp toggle_selected_task(term) do
  selected_id = selected_visible_id(term.assigns.selected_task_id, visible_tasks(term))
  tasks_state = TaskPad.InMemoryTasks.toggle(term.assigns.tasks_state, selected_id)

  assign(term, tasks_state: tasks_state, selected_task_id: selected_id)
end

defp clear_done(term) do
  tasks_state = TaskPad.InMemoryTasks.clear_done(term.assigns.tasks_state)
  visible_tasks = TaskPad.InMemoryTasks.visible(tasks_state, term.assigns.filter)
  selected_id =
    TaskPad.InMemoryTasks.selected_visible_id(term.assigns.selected_task_id, visible_tasks)

  assign(term, tasks_state: tasks_state, selected_task_id: selected_id)
end

defp set_filter(term, filter) do
  if TaskPad.InMemoryTasks.valid_filter?(filter) do
    visible_tasks = TaskPad.InMemoryTasks.visible(term.assigns.tasks_state, filter)
    selected_id =
      TaskPad.InMemoryTasks.selected_visible_id(term.assigns.selected_task_id, visible_tasks)

    term
    |> assign(filter: filter, selected_task_id: selected_id)
  else
    term
  end
end

defp visible_tasks(term) do
  TaskPad.InMemoryTasks.visible(term.assigns.tasks_state, term.assigns.filter)
end

defp selected_visible_id(selected_id, tasks),
  do: TaskPad.InMemoryTasks.selected_visible_id(selected_id, tasks)

defp task_label(%{done?: true, title: title}), do: "[x] " <> title
defp task_label(%{done?: false, title: title}), do: "[ ] " <> title

defp empty_message("open"), do: "No open tasks"
defp empty_message("done"), do: "No completed tasks"
defp empty_message(_), do: "No tasks yet"

At this checkpoint, format and compile the application before adding more UI:

mix format
mix compile

Add keybinding hints

The app already handles keys in handle_event/3, but users need a visible reminder. We add local keybindings in mount/2:

term
|> focus("new-task")
|> put_local_keybindings([
  {"Enter", "Add/toggle"},
  {"a", "New task"},
  {"c", "Clear done"}
])
|> assign(...)

Local keybindings describe actions that belong to this view. Breeze merges them into @breeze.keybindings, which we can render with the built-in keybinding bar:

<box class="grid grid-cols-1 grid-rows-3 width-screen height-screen bg">
  ...

  <box class="height-1 width-full bg-panel overflow-hidden">
    <.keybinding_bar keybindings={@breeze.keybindings}/>
  </box>
</box>

The bar is only UI. The event handlers still decide what each key does.

Use a built-in theme

Breeze ships with built-in themes. We start with one named theme:

theme: Breeze.Theme.builtin(:gruvbox)

The template already uses semantic classes such as bg, bg-panel, text-primary, and text-muted. Those tokens resolve through the active theme, so changing the theme changes the app without rewriting the view.

Built-in theme names include :system16, :system, :nebula, :catppuccin, :dracula, :gruvbox, :nord, :solarized_light, and :solarized_dark. To define a project-specific palette later, we can use Breeze.Theme.

Start the app

We update lib/task_pad/application.ex to start Breeze under the application supervision tree:

defmodule TaskPad.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children =
      if start_server?() do
        [
          Supervisor.child_spec(
            {Breeze.Server,
             view: TaskPad.View,
             theme: Breeze.Theme.builtin(:gruvbox),
             mouse: true,
             global_keybindings: [
               {"F3", "Cycle theme", &Breeze.View.cycle_theme/2},
               {"F10", "Quit", fn _event, term -> {:stop, term} end}
             ]},
            restart: :temporary
          )
        ]
      else
        []
      end

    Supervisor.start_link(children, strategy: :one_for_one, name: TaskPad.Supervisor)
  end

  defp start_server? do
    Application.get_env(:task_pad, :start_server, default_start_server?())
  end

  defp default_start_server? do
    if Code.ensure_loaded?(Mix) do
      Mix.env() != :test
    else
      true
    end
  end
end

global_keybindings are configured on Breeze.Server, not in the view. Breeze checks them before normal focused view and component event routing, so they are a good fit for application-level controls such as quitting or cycling themes. We use F10 for quitting so the command never competes with printable input.

The start_server?/0 guard keeps the interactive server out of mix test, where we use Breeze.Test to exercise the view directly.

We run the application with:

mix run --no-halt

Test it

Breeze.Test renders a view at a fixed terminal size and dispatches input without launching an interactive terminal.

defmodule TaskPad.ViewTest do
  use ExUnit.Case, async: true

  test "adds and completes a task" do
    session =
      Breeze.Test.start!(TaskPad.View,
        size: {80, 18},
        theme: Breeze.Theme.builtin(:gruvbox)
      )

    on_exit(fn -> Breeze.Test.stop(session) end)

    assert Breeze.Test.render!(session) =~ "Task Pad"

    for key <- String.graphemes("Write tests") do
      assert {:noreply, "new-task", true} = Breeze.Test.input(session, key)
    end

    assert {:noreply, "tasks", true} = Breeze.Test.input(session, "Enter")
    assert Breeze.Test.render!(session) =~ "Write tests"

    assert {:noreply, "tasks", true} = Breeze.Test.input(session, "Enter")
    assert Breeze.Test.render!(session) =~ "[x] Write tests"
  end
end

That gives us the basic Breeze loop: state in assigns, UI from render/1, events through handle_event/3, built-in themes, and deterministic tests around the terminal output.