The Development Loop

Copy Markdown View Source

The pad has a layout but the preview does not work yet. In this chapter we bring it to life with two complementary techniques: hot reload for editing the pad's own source code, and runtime compilation for compiling widget code typed into the pad's editor.

Along the way we will learn how to inspect a running app from iex, a useful debugging skill.

Hot reload

In chapter 2 you used --watch to enable hot reload on a per-run basis. Let us make it the default for development. Create a config/ directory with the following files:

# config/config.exs
import Config

import_config "#{config_env()}.exs"
# config/dev.exs
import Config

config :plushie, code_reloader: true

You also need stub files for the other environments so the import does not fail. Create config/test.exs and config/prod.exs each containing just import Config.

With this in place, mix plushie.gui enables hot reload automatically in dev without the --watch flag. Plushie watches your lib/ directory. Edit any .ex file, save it, and the running app recompiles in place -- your model state is preserved.

This is how you develop the pad itself. Change the view, save, see the result. You have been using this already if you tried the "Try it" exercises in previous chapters.

Hot reload works because the runtime re-calls view/1 with the current model after recompilation. The new view function produces a new tree, the runtime diffs it against the old one, and only the changes are sent to the renderer.

Making the preview work

The pad's editor holds Plushie widget code. We want to compile that code and render the result in the preview pane. This requires three steps:

  1. Parse - check that the code is valid Elixir syntax
  2. Compile - compile it into a module with macro expansion
  3. Render - call the module's view/0 function and embed the result

Here is the helper that does all three:

defp compile_preview(source) do
  case Code.string_to_quoted(source) do
    {:error, {meta, message, token}} ->
      line = Keyword.get(meta, :line, "?")
      {:error, "Line #{line}: #{message}#{token}"}

    {:ok, _ast} ->
      try do
        Code.put_compiler_option(:ignore_module_conflict, true)
        [{module, _}] = Code.compile_string(source)

        if function_exported?(module, :view, 0) do
          {:ok, module.view()}
        else
          {:error, "Module must export a view/0 function"}
        end
      rescue
        e -> {:error, Exception.message(e)}
      after
        Code.put_compiler_option(:ignore_module_conflict, false)
      end
  end
end

Code.string_to_quoted/1 parses the source without evaluating it. If the syntax is invalid, we get an error with a line number.

Code.compile_string/1 compiles the source into a module. This runs the full Elixir compilation pipeline including macro expansion, so import Plushie.UI and the DSL macros work exactly as they would in a normal .ex file. We set ignore_module_conflict to suppress the warning that appears when saving the same experiment twice (redefining the module).

The compiled module's view/0 function returns a widget tree. Because widget structs compose naturally, we can place this tree directly in the pad's view.

Errors at any stage are caught and displayed as text in the preview pane. The pad never crashes from bad experiment code.

Wiring up the save button

Update init/1 to compile a starter experiment on startup, and add a save handler to update/2:

@starter_code """
defmodule Pad.Experiments.Hello do
  import Plushie.UI

  def view do
    column padding: 16, spacing: 8 do
      text("greeting", "Hello, Plushie!", size: 24)
      button("btn", "Click Me")
    end
  end
end
"""

def init(_opts) do
  model = %{
    source: @starter_code,
    preview: nil,
    error: nil
  }

  case compile_preview(model.source) do
    {:ok, tree} -> %{model | preview: tree}
    {:error, msg} -> %{model | error: msg}
  end
end

def update(model, %WidgetEvent{type: :click, id: "save"}) do
  case compile_preview(model.source) do
    {:ok, tree} -> %{model | preview: tree, error: nil}
    {:error, msg} -> %{model | error: msg, preview: nil}
  end
end

Now when you type experiment code in the editor and click Save, the preview updates with the rendered widgets. Syntax errors, compile errors, and runtime errors all show as red text in the preview pane.

The experiment format

Experiments are modules with a view/0 function:

defmodule Pad.Experiments.Hello do
  import Plushie.UI

  def view do
    column padding: 16, spacing: 8 do
      text("greeting", "Hello, Plushie!", size: 24)
      button("btn", "Click Me")
    end
  end
end

This is the same DSL code you write in an app's view/1. Experiments just don't have a model, so the function takes no arguments.

Inspecting a running app from iex

Sometimes you want to look at the model or tree of a running app. Start an iex session and launch the app directly:

iex -S mix
iex> {:ok, pid} = Plushie.start_link(PlushiePad, binary: Plushie.Binary.path!())

Now the app is running and you have the iex prompt. Query the runtime:

iex> Plushie.Runtime.get_model(Plushie.Runtime)
%{source: "...", preview: ..., error: nil}

iex> Plushie.Runtime.get_tree(Plushie.Runtime)
%{id: "main", type: "window", children: [...]}

get_model/1 returns the current model. get_tree/1 returns the normalized UI tree. These are the same values your view/1 and update/2 work with -- seeing them directly helps when debugging layout issues or unexpected state.

The default runtime name is Plushie.Runtime. If you started Plushie with a custom name: option, the runtime name follows the convention {name}.Runtime.

The complete pad

Here is the full module with compilation wired up:

defmodule PlushiePad do
  use Plushie.App

  import Plushie.UI

  alias Plushie.Event.WidgetEvent

  @starter_code """
  defmodule Pad.Experiments.Hello do
    import Plushie.UI

    def view do
      column padding: 16, spacing: 8 do
        text("greeting", "Hello, Plushie!", size: 24)
        button("btn", "Click Me")
      end
    end
  end
  """

  def init(_opts) do
    model = %{
      source: @starter_code,
      preview: nil,
      error: nil
    }

    case compile_preview(model.source) do
      {:ok, tree} -> %{model | preview: tree}
      {:error, msg} -> %{model | error: msg}
    end
  end

  def update(model, %WidgetEvent{type: :input, id: "editor", value: source}) do
    %{model | source: source}
  end

  def update(model, %WidgetEvent{type: :click, id: "save"}) do
    case compile_preview(model.source) do
      {:ok, tree} -> %{model | preview: tree, error: nil}
      {:error, msg} -> %{model | error: msg, preview: nil}
    end
  end

  def update(model, _event), do: model

  def view(model) do
    window "main", title: "Plushie Pad" do
      column width: :fill, height: :fill do
        row width: :fill, height: :fill do
          text_editor "editor", model.source do
            width {:fill_portion, 1}
            height :fill
            highlight_syntax "ex"
            font :monospace
          end

          container "preview", width: {:fill_portion, 1}, height: :fill, padding: 16 do
            if model.error do
              text("error", model.error, color: :red)
            else
              if model.preview do
                model.preview
              else
                text("placeholder", "Press Save to compile and preview")
              end
            end
          end
        end

        row padding: 8 do
          button("save", "Save")
        end
      end
    end
  end

  defp compile_preview(source) do
    case Code.string_to_quoted(source) do
      {:error, {meta, message, token}} ->
        line = Keyword.get(meta, :line, "?")
        {:error, "Line #{line}: #{message}#{token}"}

      {:ok, _ast} ->
        try do
          Code.put_compiler_option(:ignore_module_conflict, true)
          [{module, _}] = Code.compile_string(source)

          if function_exported?(module, :view, 0) do
            {:ok, module.view()}
          else
            {:error, "Module must export a view/0 function"}
          end
        rescue
          e -> {:error, Exception.message(e)}
        after
          Code.put_compiler_option(:ignore_module_conflict, false)
        end
    end
  end
end

Run it:

mix plushie.gui PlushiePad

The starter experiment compiles on init, so you should see "Hello, Plushie!" and a "Click Me" button in the preview pane immediately. Edit the code in the editor, click Save, and the preview updates.

Verify it

Update the test to verify compilation works:

test "starter code compiles and renders on init" do
  assert_text("#preview/greeting", "Hello, Plushie!")
  assert_exists("#preview/btn")
end

The preview widgets have scoped IDs (preview/greeting, preview/btn) because they are inside container "preview". This is how scoped IDs work. We will use them more in chapter 6.

Try it

  • Change the starter experiment: add a checkbox, a slider, or a text_input to the column. Save and see them render.
  • Deliberately break the syntax: delete a closing end. Save and see the error message in the preview. Fix it and save again.
  • Try writing a completely different experiment: a row of coloured buttons, or a progress bar with a label.
  • Start iex (iex -S mix), launch the pad with Plushie.start_link(PlushiePad, binary: Plushie.Binary.path!()), and inspect the model with Plushie.Runtime.get_model(Plushie.Runtime).

In the next chapter, we will add an event log to the pad so you can see exactly what events widgets produce when you interact with them.


Next: Events