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")
endrow
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")
endcontainer
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")
endscrollable
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
endscrollable 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:
| Value | Behaviour |
|---|---|
:fill | Take all available space |
:shrink | Take only as much as the content needs |
{:fill_portion, n} | Take a proportional share of available space |
| number | Exact 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")
endThe 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
endThe 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")
endSpacing 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
endPadding 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")
endAlignment
align_x: and align_y: control how children are positioned within a
container:
align_x values | align_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")
endThe shorthand center: true sets both axes at once:
container width: :fill, height: :fill, center: true do
text("centered", "Centred both ways")
endOther 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
endKey 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")
endThis 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
rowwith a fixed-widthcolumnand a:fillcontainer. - Try different
{:fill_portion, n}ratios. Give one pane2and another1to see the 2:1 split. - Nest a
scrollableinside a fixed-height container. Add enough items to trigger scrolling. - Experiment with
align_x: :centerandalign_y: :bottomon a container. - Try
rowwithwrap: trueand 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