Learn · 03 of 09

Focus and keybindings.

Terminal apps are keyboard-first. TerminalUI ships two cooperating systems for keyboard interaction:

  • Focus@FocusState, .focusable(...), and .focused(...) determine which view receives typing, arrows, Tab, Enter, Escape.
  • Commands.keyCommand(...) and .paletteCommand(...) attach modifier-key shortcuts and palette entries to scopes, not to the global app.

The two combine through one rule: a scope is active iff its anchor is on the current focus chain. Tree presence is a prerequisite; focus-chain membership is the activation condition.

Search + list with scoped commands.

A Panel wraps the search field and message list as a single ActionScope. Two scope-level keybindings move focus between fields, and a palette command exposes "Mark as read" to any palette surface that wants to render it.

InboxApp.swift swift
import TerminalUI
import TerminalUICLI

@main
struct InboxApp: App {
  var body: some Scene {
    // 1. Scene-level commands are active whenever the scene is on screen.
    WindowGroup("Inbox") {
      InboxView()
    }
    .keyCommand("New message", key: .character("n"), modifiers: .command) {
      print("Compose…")
    }
  }
}

struct InboxView: View {
  @State private var query = ""
  @State private var selection: String?
  @FocusState private var focusedField: Field?

  enum Field: Hashable { case search, list }

  var body: some View {
    Panel(id: "inbox") {                          // 2. Panel = an ActionScope.
      VStack(alignment: .leading, spacing: 1) {
        TextField("search", text: $query)
          .focused($focusedField, equals: .search)
        List(filteredMessages, selection: $selection) { id in
          Text(id)
        }
        .focused($focusedField, equals: .list)
      }
    }
    // 3. Panel-level commands. Active when focus is anywhere in this Panel.
    .keyCommand("Focus search", key: .character("/"), modifiers: .command) {
      focusedField = .search
    }
    .keyCommand("Focus list", key: .escape, modifiers: .command) {
      focusedField = .list
    }
    .paletteCommand(name: "Mark as read") {
      // ...
    }
    .toolbar(style: DefaultBottomToolbarStyle())  // 4. Hoisted items land here.
  }

  var filteredMessages: [String] { ["alice", "bob", "carol"] }
}

Anatomy

1 — @FocusState

A property wrapper that exposes the current focus as a typed value. The enum case (Field.search, Field.list) is the focus identity; .focused($focusedField, equals: .search) registers a view as the receiver of that focus value.

Setting focusedField = .search moves focus programmatically. The runtime drives the focus chain; @FocusState just observes and writes it.

2 — Panel = an ActionScope

Panel is the consumer-facing primitive that says "I want an ActionScope here." It conforms to ActionScope, has no built-in chrome (style with standard modifiers), and acts as the focus-chain anchor for any commands attached to it.

Other built-in ActionScopes: Scene (always on the chain while the scene is active), and the presentation modifiers .alert, .confirmationDialog, .sheet — each conforms to ActionScope so commands attach naturally to the modal.

3 — .keyCommand

Attaches a (key, modifiers) → action binding to the receiving scope. Modifiers must be non-empty — single-key shortcuts are framework-reserved (typing, arrows, Tab, Enter, Escape are routed to focused widgets). Dispatch is shallowest-wins along the focus chain: the runtime walks root-to-leaf, the first matching scope takes the event, and a disabled match consumes the event without running the action.

That precedence is the load-bearing rule. It means a deep dependency cannot silently override a shortcut claimed by a shell-level scope.

4 — .paletteCommand + .toolbar

.paletteCommand(name:…) attaches an action-bearing entry to the same scope, with no key shortcut. It's pure metadata + action, and it shows up in EnvironmentValues.activePaletteCommands for consumer-authored palette surfaces. The framework doesn't ship a palette UI — wrap your own.

.toolbar(style:) declares the receiving scope as a toolbar surface. Toolbar items hoisted from descendants via .toolbarItem(...) bubble up the tree and land in the nearest enclosing scope that called .toolbar(style:). Commands flow top-down (declared at scope roots); items flow bottom-up.

The focus-chain dispatch flow

For a key press with at least one modifier:

  1. The runtime walks the focus chain root-to-leaf.
  2. At each scope, it looks for a matching (key, modifiers).
  3. The first match wins. If enabled, fires the action; if disabled, consumes the event silently.
  4. If no scope claims the binding, the event is ignored.

Where to go next