The pad from chapter 6 works, but the layout could use some attention. The sidebar, editor, and preview panes are functional but not well-proportioned, and the spacing is inconsistent. In this chapter we will fix that by learning Plushie's layout system.

We will cover the layout containers you use every day, how sizing works, and how spacing and alignment give your UI structure. The full container catalog is in the Built-in Widgets reference -- here we focus on the ones that matter most.

Layout containers

Plushie provides several layout containers. These are the workhorses:

column

Stacks children vertically, top to bottom. Accepts spacing: (gap between children), padding: (space inside the container), width:, height:, and align_x:.

column spacing: 12, padding: 16 do
  text("one", "First")
  text("two", "Second")
  text("three", "Third")
end

row

Stacks children horizontally, left to right. Same props as column, plus align_y:. Also supports wrap: true to flow children to the next line when they overflow.

row spacing: 8 do
  button("a", "Left")
  button("b", "Right")
end

container

A single-child wrapper. Use it for styling (background, border, shadow), for scoping (gives children a named ID scope), or for alignment and padding.

container "card", padding: 16, background: "#f5f5f5" do
  text("content", "Inside the card")
end

scrollable

Adds scroll bars when content overflows. Direction can be :vertical (default), :horizontal, or :both. Set a fixed height to constrain the scrollable area.

scrollable "list", height: 300, direction: :vertical do
  column spacing: 4 do
    for item <- items do
      text(item.id, item.name)
    end
  end
end

scrollable supports auto_scroll: true for chat-like behaviour where new content scrolls into view automatically.

Sizing: fill, shrink, and fixed

Every widget has width: and height: props. They accept four kinds of values:

ValueBehaviour
:fillTake all available space
:shrinkTake only as much as the content needs
{:fill_portion, n}Take a proportional share of available space
numberExact pixel size

Most widgets default to :shrink. Layout containers grow to fit their children.

fill vs shrink

In a row, a :fill child takes all remaining space after :shrink children are measured:

row width: :fill do
  text_input("search", model.query, width: :fill, placeholder: "Search...")
  button("go", "Go")
end

The button shrinks to fit its label. The text input fills the rest.

fill_portion

When multiple children use :fill, they share space equally. Use {:fill_portion, n} for proportional splits:

row width: :fill do
  container "sidebar", width: {:fill_portion, 1} do
    text("nav", "Sidebar")
  end
  container "main", width: {:fill_portion, 3} do
    text("content", "Main content")
  end
end

The sidebar gets 1/4 of the width, the main area gets 3/4. The numbers are relative. {:fill_portion, 1} and {:fill_portion, 3} is the same ratio as {:fill_portion, 2} and {:fill_portion, 6}.

Fixed size

A plain number means exact pixels:

container "icon", width: 48, height: 48 do
  text("x", "X")
end

Spacing and padding

Spacing is the gap between sibling children inside a container:

column spacing: 12 do
  text("a", "First")    # 12px gap below
  text("b", "Second")   # 12px gap below
  text("c", "Third")    # no gap after last child
end

Padding is the space between a container's edges and its content. It accepts several forms:

# Uniform: 16px on all sides
column padding: 16 do ... end

# Vertical/horizontal: 8px top/bottom, 16px left/right
column padding: {8, 16} do ... end

# Per-side via do-block:
column do
  padding do
    top 16
    bottom 8
    left 12
    right 12
  end
  text("hello", "Hello")
end

Alignment

align_x: and align_y: control how children are positioned within a container:

align_x valuesalign_y values
:left (default), :center, :right:top (default), :center, :bottom
container width: :fill, height: 200, align_x: :center, align_y: :center do
  text("centered", "I am centred")
end

The shorthand center: true sets both axes at once:

container width: :fill, height: :fill, center: true do
  text("centered", "Centred both ways")
end

Other layout tools

These containers cover specialised needs. We will not use them in the pad right now, but they are good to know about:

  • stack - layers children on top of each other (z-axis). Useful for overlays, badges, and loading spinners.
  • grid - CSS-like grid layout. Supports fixed column count (columns: 3) or fluid mode (fluid: 200) that auto-wraps.
  • pin - positions a child at exact (x, y) pixel coordinates.
  • floating - applies translate and scale transforms to a child.
  • responsive - adapts layout based on available size.
  • space - explicit empty space with configurable width and height.

See the Built-in Widgets reference for full details on each.

Applying it: the polished pad layout

With these layout tools, refine the pad into a clean three-pane layout:

def view(model) do
  window "main", title: "Plushie Pad" do
    column width: :fill, height: :fill, spacing: 0 do
      # Main area: sidebar + editor + preview
      row width: :fill, height: :fill, spacing: 0 do
        file_list(model)

        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: 12 do
          # ...preview content...
        end
      end

      # Toolbar: compact, horizontal
      row padding: {4, 8}, spacing: 8 do
        button("save", "Save")
        checkbox("auto-save", model.auto_save)
        text("auto-label", "Auto-save")
      end

      # Event log: fixed height at the bottom
      scrollable "log", height: 100 do
        column spacing: 2, padding: {2, 8} do
          for {entry, i} <- Enum.with_index(model.event_log) do
            text("log-#{i}", entry, size: 11, font: :monospace)
          end
        end
      end
    end
  end
end

Key changes: spacing: 0 on the outer containers eliminates unwanted gaps. The toolbar uses {4, 8} padding (vertical, horizontal) for a compact look. The event log has a smaller fixed height and tighter text. Each section manages its own internal spacing.

The update logic is unchanged from chapter 6. Only the view and helper functions changed.

Verify it

Test that the three-pane layout renders with the expected structure:

test "three-pane layout with sidebar, editor, and preview" do
  assert_exists("#file-scroll")
  assert_exists("#editor")

  # Typing in the editor still works after layout changes
  type_text("#editor", ~s[text("test", "hello")])
  click("#save")
  assert_text("#preview/test", "hello")
end

This verifies the layout didn't break the editing flow. The editor, save button, and preview pane all still work together.

Try it

Write a layout experiment in your pad:

  • Build a sidebar + content layout using row with a fixed-width column and a :fill container.
  • Try different {:fill_portion, n} ratios. Give one pane 2 and another 1 to see the 2:1 split.
  • Nest a scrollable inside a fixed-height container. Add enough items to trigger scrolling.
  • Experiment with align_x: :center and align_y: :bottom on a container.
  • Try row with wrap: true and enough buttons to overflow the width.

In the next chapter, we will style the pad with themes, colours, and per-widget styling to make it look polished.


Next: Styling