Learn · 04 of 09

Lists and selection.

Select-from-a-list-and-do-something-with-it is the core interaction loop in terminal software — see Lazygit, K9s, Yazi, fzf, every editor's buffer picker. TerminalUI ships three collection views with first-class selection bindings:

  • List — flat collection, single or multi-selection.
  • OutlineGroup — hierarchical rows with disclosure.
  • Table — multi-column, sortable, multi-selection.

All three take a selection binding. The shape of the binding decides the selection mode:

  • Binding<ID?> — single selection.
  • Binding<Set<ID>> — multi-selection.

List + detail panel.

The classic two-pane shape. Arrow keys move the selection, the detail rerenders, and the same data is also rendered as a hierarchical OutlineGroup. The updatesIfNonEmpty helper is required: OutlineGroup uses nil children to detect leaf rows and stop expansion.

DeployBrowser.swift swift
import TerminalUI
import TerminalUICLI
import Observation

@main
struct DeployApp: App {
  var body: some Scene {
    WindowGroup("Deploys") {
      DeployBrowser()
    }
  }
}

struct Deploy: Identifiable, Hashable {
  let id: String
  let environment: String
  let status: String
  let owner: String
  let updates: [Deploy]   // for tree expansion below
}

@Observable
final class DeployStore {
  var deploys: [Deploy] = sampleDeploys()
}

struct DeployBrowser: View {
  @State private var store = DeployStore()
  @State private var selection: Deploy.ID?

  var body: some View {
    HStack(spacing: 1) {
      // 1. List with single selection. The binding is to an optional ID;
      //    arrow keys move it, Enter activates the row's primary action.
      List(store.deploys, selection: $selection) { deploy in
        LabeledContent(deploy.environment, value: deploy.status)
      }
      .frame(width: 32)

      Divider()

      // 2. Detail panel reads from the selection. The selection drives
      //    invalidation; this view rerenders when selection changes.
      DeployDetail(deploy: store.deploys.first { $0.id == selection })
    }
  }
}

struct DeployDetail: View {
  let deploy: Deploy?

  var body: some View {
    if let deploy {
      VStack(alignment: .leading, spacing: 0) {
        Text(deploy.id).bold()
        LabeledContent("Status", value: deploy.status)
        LabeledContent("Owner", value: deploy.owner)
        Divider()
        // 3. Hierarchical: same data shape, expressed as an outline.
        OutlineGroup(deploy.updates, children: \.updatesIfNonEmpty) { node in
          Text(node.id)
        }
      }
    } else {
      Text("Select a deploy.").foregroundStyle(.secondary)
    }
  }
}

extension Deploy {
  // OutlineGroup needs nil for leaves to stop expansion.
  var updatesIfNonEmpty: [Deploy]? {
    updates.isEmpty ? nil : updates
  }
}

func sampleDeploys() -> [Deploy] { ... }

Table with multi-select.

Swap List for Table when you have multiple columns to display. The selection binding shape is what switches single ↔ multi.

DeployTable.swift swift
import TerminalUI

struct DeployTable: View {
  let deploys: [Deploy]
  @State private var selection: Set<Deploy.ID> = []

  var body: some View {
    // Multi-selection with Set<ID>.
    Table(deploys, selection: $selection) {
      TableColumn("Env", value: \.environment)
      TableColumn("Status", value: \.status)
      TableColumn("Owner", value: \.owner)
    }
  }
}

Anatomy

Rows must be Identifiable

Selection is keyed by ID. Rows are Identifiable either explicitly (the model conforms) or implicitly via a key path (List(deploys, id: \.id)). Stable IDs are the same requirement that @State persistence depends on elsewhere in the framework — see State keying.

Selection mode is the binding shape

Single: Binding<ID?>. Multi: Binding<Set<ID>>. There is no separate "select mode" property — the type system decides.

Hierarchical with OutlineGroup

OutlineGroup takes a children key path that returns [Element]?. Leaves return nil; non-leaves return their children. Returning an empty array makes the row expandable but empty — almost certainly not what you want. The updatesIfNonEmpty pattern in the example above is the idiomatic shape.

Laziness

List placement is lazy by default — only visible rows are placed and drawn. LazyVStack / LazyHStack let you opt into the same behaviour for ordinary stacks. The single-ForEach case has a full-lazy fast path; mixed children fall back to viewport-lazy placement.

Mouse and pointer

On terminals that report mouse, list rows are clickable for selection. Tutorial 07 covers the pointer model in depth — for now, treat clicks as automatic when the host supports them.

Where to go next