In the previous chapter we built a counter and learned the init/update/view cycle. Now we will start building Plushie Pad, a live widget editor that grows with you throughout this guide.
In this chapter we set up the pad's layout: a code editor on the left, a preview area on the right, and a save button. We will make the preview actually work in the next chapter. For now the focus is on the DSL and how views are composed.
The DSL: three equivalent forms
Before we look at the code, a quick note on how the Plushie DSL works. There are three ways to set widget properties, and they all produce the same result.
Keyword arguments on the call line, inline declarations mixed with children in the do-block, and nested do-blocks for struct-typed properties like padding, all in one expression:
column spacing: 8 do
padding do
top 16
bottom 8
end
text("hello", "Hello")
endThis is equivalent to:
column spacing: 8, padding: %{top: 16, bottom: 8} do
text("hello", "Hello")
endYou can mix all three in the same expression. Block declarations override keyword arguments when they conflict. Use whichever form reads best for the situation. Throughout this guide we will use different forms depending on context.
Widgets also have a programmatic struct API that you can use instead of the macro-based DSL. The same column from above:
alias Plushie.Widget.{Column, Text}
Column.new("my_col", spacing: 8, padding: %{top: 16, bottom: 8})
|> Column.push(Text.new("hello", "Hello"))The struct API produces the same output as the DSL. It is useful for helper functions, dynamic generation, or anywhere you prefer working with data structures directly. We will use the DSL throughout this guide.
The complete pad
Here is the full module for this chapter. Save it as lib/plushie_pad.ex
and we will walk through the key parts below.
defmodule PlushiePad do
use Plushie.App
import Plushie.UI
alias Plushie.Event.WidgetEvent
def init(_opts) do
%{
source: "# Write some Plushie code here\n",
preview: nil,
error: nil
}
end
def update(model, %WidgetEvent{type: :input, id: "editor", value: source}) do
%{model | source: source}
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
endRun it:
mix plushie.gui PlushiePad --watch
You should see the editor on the left with syntax highlighting and a placeholder message on the right. The save button is there but does not do anything yet. We will fix that in the next chapter.
Walking through the code
The model
init/1 receives a keyword list passed via the app_opts: option when
starting the app (e.g. Plushie.start_link(PlushiePad, app_opts: [key: val])).
We do not need it yet, so we ignore it. The returned map becomes the initial
model. Here we track the editor source text and leave slots for a compiled
preview and an error message.
text_editor
text_editor is a multi-line editing widget with syntax highlighting
support. The content argument seeds the initial text, and subsequent
changes arrive as :input events with the full content as the value.
The highlight_syntax: "ex" option enables Elixir syntax highlighting.
Some widgets hold renderer-side state (cursor position, scroll offset,
text selection). text_editor, text_input, combo_box, scrollable,
and pane_grid all fall into this category. These widgets need an explicit
string ID so the renderer can match them to their state across renders. If
the ID changes, the state resets. Layout widgets like column and row
have no renderer-side state, so auto-generated IDs work fine for them.
The view
The view is a window containing a column that splits vertically into
a main content row and a toolbar row at the bottom.
{:fill_portion, 1}gives the editor and preview equal width. Change one to{:fill_portion, 2}and it takes twice the space. We cover sizing in depth in chapter 7.- The preview pane is wrapped in
container "preview". This named container will matter later when we need to distinguish events from preview widgets vs the pad's own widgets. model.previewwill hold a compiled widget tree once we wire up compilation. For now it isnil, so the placeholder text shows.- An
ifwithout anelsereturnsnil, which the layout filters out automatically. This is a useful pattern throughout Plushie views for conditionally showing widgets.
Events
The editor emits :input events on every keystroke. We pattern-match on the
widget ID and update the model. The catch-all clause ignores everything
else, including save button clicks. We will wire those up in the next
chapter.
Verify it
Add a test for the pad layout in test/pad_test.exs:
defmodule PlushiePad.PadTest do
use Plushie.Test.Case, app: PlushiePad
test "pad has editor and preview panes" do
assert_exists("#editor")
assert_exists("#preview")
assert_text("#preview/placeholder", "Press Save to compile and preview")
end
endThis verifies the split-pane layout is rendering correctly.
Try it
With the pad running and hot reload active:
- Type some Elixir code in the editor. The syntax highlighting updates as you type.
- Change
{:fill_portion, 1}to{:fill_portion, 2}on the editor pane. Save the file. The editor takes twice the width. - Add a second button to the toolbar row:
button("clear", "Clear"). Save and see it appear.
The pad is a shell right now, a text editor next to an empty preview. In the next chapter, we will bring it to life by wiring up hot reload and code compilation so you can write Plushie widgets and see them rendered instantly.
Next: The Development Loop