Your First App

In the previous chapter we built a counter and learned the init/update/view cycle. Now we 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. The preview does not do anything yet; we wire that up in the next chapter. For now the focus is on how views are composed.

Two widget APIs

Gleam has no macro DSL, so Plushie exposes two widget APIs: ui.* convenience functions with typed opt lists, and per-widget builder modules with chainable setters. Both produce the same nodes and can be mixed inside the same view.

The opt-list form is compact and reads well for small widgets:

import plushie/prop/length.{Fill}
import plushie/ui
import plushie/widget/column

ui.column("root", [column.Spacing(8.0), column.Width(Fill)], [
  ui.text_("greeting", "Hello, Plushie!"),
])

The builder form is chainable and useful for helpers that tweak a widget gradually or set many options:

import plushie/prop/length.{Fill}
import plushie/widget/column
import plushie/widget/text

column.new("root")
|> column.spacing(8.0)
|> column.width(Fill)
|> column.push(text.new("greeting", "Hello, Plushie!") |> text.build())
|> column.build()

Both call sites return a Node. Pick whichever reads best at each call site; this guide mixes both.

Plushie Pad and Erlang experiments

Plushie Pad compiles code typed into the editor at runtime. BEAM ships with an Erlang compiler, but there’s no Gleam compiler we can call from a running program, so experiments are written in Erlang. If you haven’t used Erlang before, copy the snippets as-is and they’ll work. When you’re ready to understand what’s happening under the hood, the Erlang interop reference covers the Gleam-to-Erlang mapping.

The pad stores experiments as .erl files under priv/experiments/. A first experiment looks like this:

-module(hello).
-export([view/0]).

view() ->
    pad_helpers:column(<<"root">>,
        [{padding, pad_helpers:padding_all(16.0)}, {spacing, 8.0}],
        [
            pad_helpers:text_size(<<"greeting">>, <<"Hello, Plushie!">>, 24.0),
            pad_helpers:button(<<"btn">>, <<"Click Me">>)
        ]).

The helpers in pad_helpers are thin wrappers around the generated Erlang atoms for plushie@ui calls. Nothing in this chapter depends on it; we ship it so the pad has something to render once compilation lands in the next chapter.

The complete pad

Here is the module for this chapter. Save it as src/plushie_pad/app.gleam and we walk through the key parts below.

import gleam/option.{type Option, None, Some}
import plushie/app.{type App}
import plushie/command.{type Command}
import plushie/event.{
  type Event, EventTarget, Input, Widget,
}
import plushie/node.{type Node}
import plushie/prop/font.{Monospace}
import plushie/prop/length.{Fill, FillPortion}
import plushie/prop/padding
import plushie/ui
import plushie/widget/column
import plushie/widget/container
import plushie/widget/row
import plushie/widget/text_editor
import plushie/widget/window

pub type Model {
  Model(source: String, preview: Option(Node), error: Option(String))
}

pub fn app() -> App(Model, Event) {
  app.simple(init, update, view)
}

fn init() -> #(Model, Command(Event)) {
  let model =
    Model(
      source: "% Write some Plushie code here\n",
      preview: None,
      error: None,
    )
  #(model, command.none())
}

fn update(model: Model, evt: Event) -> #(Model, Command(Event)) {
  case evt {
    Widget(Input(target: EventTarget(id: "editor", ..), value: s)) -> #(
      Model(..model, source: s),
      command.none(),
    )
    _ -> #(model, command.none())
  }
}

fn view(model: Model) -> List(Node) {
  [
    ui.window("main", [window.Title("Plushie Pad")], [
      ui.column("root", [column.Width(Fill), column.Height(Fill)], [
        ui.row("split", [row.Width(Fill), row.Height(Fill)], [
          editor_pane(model),
          preview_pane(model),
        ]),
        ui.row("toolbar", [row.Padding(padding.all(8.0))], [
          ui.button_("save", "Save"),
        ]),
      ]),
    ]),
  ]
}

fn editor_pane(model: Model) -> Node {
  ui.text_editor("editor", model.source, [
    text_editor.Width(FillPortion(1)),
    text_editor.Height(Fill),
    text_editor.HighlightSyntax("erlang"),
    text_editor.Font(Monospace),
  ])
}

fn preview_pane(model: Model) -> Node {
  let content = case model.preview {
    Some(tree) -> tree
    None -> ui.text_("placeholder", "Press Save to compile and preview")
  }
  ui.container(
    "preview",
    [
      container.Width(FillPortion(1)),
      container.Height(Fill),
      container.Padding(padding.all(16.0)),
    ],
    [content],
  )
}

The project’s src/plushie_pad.gleam entry point hands this off to the runtime:

import gleam/io
import plushie
import plushie_pad/app as pad_app

pub fn main() {
  case plushie.start(pad_app.app(), plushie.default_start_opts()) {
    Ok(rt) -> plushie.wait(rt)
    Error(err) ->
      io.println_error(
        "plushie_pad failed to start: " <> plushie.start_error_to_string(err),
      )
  }
}

Run it:

gleam run -m plushie_pad

The editor appears on the left with Erlang syntax highlighting, and the placeholder text fills the preview on the right. The save button is there but does not do anything yet. We wire it up in the next chapter.

Walking through the code

The model

app.simple takes a zero-argument init. The returned record is the initial model. Option(Node) lets us distinguish “no preview yet” from “we have a tree to render” without inventing a sentinel.

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 Widget(Input(..)) events with the full content as the value. text_editor.HighlightSyntax("erlang") names a syntect language key ("ex", "rust", "js", and so on).

Some widgets hold renderer-side state (cursor position, scroll offset, text selection): text_editor, text_input, combo_box, scrollable, pane_grid. They 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 any stable ID works.

The view

view returns a List(Node) of top-level windows. We return a one-element list containing a window that wraps a column. The column splits into a main content row and a toolbar row.

Events

The editor emits Widget(Input(..)) events on every keystroke. We pattern-match on the target ID and pull the new value off the event record. The catch-all arm ignores everything else, including save button clicks, which we handle in the next chapter.

Verify it

Add a test for the pad layout in test/pad_test.gleam:

import gleeunit
import plushie/testing
import plushie_pad/app as pad_app

pub fn main() {
  gleeunit.main()
}

pub fn pad_has_editor_and_preview_panes_test() {
  let ctx = testing.start(pad_app.app())

  testing.assert_exists(ctx, "#editor")
  testing.assert_exists(ctx, "#preview")
  testing.assert_text(
    ctx,
    "#preview/placeholder",
    "Press Save to compile and preview",
  )

  testing.stop(ctx)
}

This verifies the split-pane layout is rendering correctly.

Try it

With the pad running:

The pad is a shell right now, a text editor next to an empty preview. The next chapter wires up hot reload and code compilation so experiments render as you save them.


Next: The Development Loop

Search Document