Learn · 06 of 09

Custom layouts.

TerminalUI's layout protocol follows the SwiftUI contract: the parent proposes a size, each child chooses its own size, then the parent places children in a final rectangle. The terminal does not get a shortcut around that rule.

Use a custom Layout when the built-in stacks, grids, lists, and tables do not express the relationship between siblings. Use SendableLayout when the layout value and cache are safe to run on the frame-tail worker.

Equal terminal columns.

This layout divides its final width equally across children. It measures children for their natural height, then places each one with the same finite width proposal.

StatusBoard.swift swift
import TerminalUI
import TerminalUICLI

@main
struct StatusBoardApp: App {
  var body: some Scene {
    WindowGroup("Status") {
      StatusBoard()
    }
  }
}

struct StatusBoard: View {
  var body: some View {
    EqualColumns(spacing: 2) {
      service("API", state: "healthy", tone: .success)
      service("Queue", state: "backing up", tone: .warning)
      service("Search", state: "healthy", tone: .success)
    }
    .frame(width: 64)
    .padding(.init(horizontal: 1, vertical: 0))
  }

  func service<S: ShapeStyle>(_ name: String, state: String, tone: S) -> some View {
    VStack(alignment: .leading, spacing: 0) {
      Text(name).bold()
      Text(state).foregroundStyle(tone)
      Divider()
      LabeledContent("owner", value: "ops")
    }
  }
}

struct EqualColumns: SendableLayout {
  var spacing = 1

  var measurementReuseSignature: String { "EqualColumns:\(spacing)" }
  var placementReuseSignature: String { measurementReuseSignature }

  func sizeThatFits(
    proposal: ProposedViewSize,
    subviews: LayoutSubviews,
    cache: inout ()
  ) -> LayoutSize {
    guard !subviews.isEmpty else { return .zero }
    let natural = subviews.map { $0.sizeThatFits(.unspecified) }
    let naturalWidth = natural.reduce(0) { $0 + $1.width }
      + spacing * max(0, subviews.count - 1)
    let width = proposed(proposal.width, fallback: naturalWidth)
    let height = natural.map(\.height).max() ?? 0
    return LayoutSize(width: width, height: height)
  }

  func placeSubviews(
    in bounds: LayoutRect,
    proposal _: ProposedViewSize,
    subviews: LayoutSubviews,
    cache: inout ()
  ) {
    guard !subviews.isEmpty else { return }
    let totalSpacing = spacing * max(0, subviews.count - 1)
    let columnWidth = max(0, (bounds.size.width - totalSpacing) / subviews.count)
    var x = bounds.origin.x

    for subview in subviews {
      subview.place(
        at: LayoutPoint(x: x, y: bounds.origin.y),
        proposal: ProposedViewSize(width: columnWidth, height: bounds.size.height)
      )
      x += columnWidth + spacing
    }
  }

  private func proposed(_ dimension: ProposedDimension, fallback: Int) -> Int {
    switch dimension {
    case .finite(let value): value
    case .unspecified, .infinity: fallback
    }
  }
}

The contract

sizeThatFits is measurement. Ask children what they want with subview.sizeThatFits(...), combine those answers, and return the size your layout needs. The return value is still just a request; the parent can later place you in a different rectangle.

placeSubviews is placement. Use the final bounds to place every child with subview.place(at:proposal:). Measurement and placement are intentionally separate, because scroll views, lists, retained layout, and async rendering all depend on that boundary.

When to conform to SendableLayout

Plain Layout is always correct. SendableLayout is an opt-in performance contract: the layout value, its cache, and everything captured by its callbacks must be safe away from the main actor. In exchange, the runtime can run measurement and placement on the frame-tail worker.

The reuse signatures are part of that contract. Include every field that changes measurement in measurementReuseSignature, and every field that changes placement in placementReuseSignature. For the example above, spacing changes both.

Layout values

Layouts can read child-provided metadata through LayoutValueKey and .layoutValue(key:value:). Reach for that when a child needs to say "I am the primary column" or "I want twice the weight" without coupling the layout to a concrete child view type.

Where to go next