Widgets

Control flow

forEach for arrays, if for branches, showIf for visibility.

Three control-flow primitives let your widget react to the data it receives. All three are first-class node kinds (or modifiers) — you don't drop into a templating language.

forEach — iterate an array

forEach renders its template once per item in a binding-resolved array.

yaml
forEach:
  in: "${prs}"            # binding to an array
  stack: vstack           # vstack (default) | hstack | zstack
  spacing: 8
  limit: 10               # cap, useful when source can grow
  alignment: leading
  template:
    hstack:
      spacing: 6
      children:
        - text: { content: "#${item.number}" }
        - text: { content: "${item.title}", lineLimit: 1 }

Inside the template:

  • ${item} — the current element
  • ${index} — the 0-based ordinal

If the array contains objects, walk into them: ${item.name}, ${item.url}, ${item.draft}.

Numeric arrays

forEach works with arrays of numbers too — ${item} is the number itself:

yaml
# script outputs: {"history": [10, 20, 30, 40]}
forEach:
  in: "${history}"
  template:
    text: { content: "${index}: ${item:%.0f}" }

Empty arrays

If in: resolves to an empty array (or a missing path), forEach renders nothing — no spacing, no separator. Use a sibling if to render an "empty" state:

yaml
- if:
    when: "${prs}"
    then:
      forEach:
        in: "${prs}"
        template: ...
    else:
      text: { content: "No open PRs", color: secondary }

if — branch on condition

yaml
- if:
    when: "${pct} >= 80"
    then:
      text: { content: "Hot", color: red }
    else:
      text: { content: "Cool", color: green }

The when: expression supports:

  • ${path} — truthy if the value is non-zero / non-empty / true
  • ${path} OP literal — comparison
  • ${path} OP ${other} — comparison between two bindings
  • ${path} == "string" — string compare with quoted literal

Operators: <, <=, >, >=, ==, !=.

Numeric comparison is preferred. If either side isn't numeric, == and != fall back to string comparison.

else: is optional. Without it, the if renders nothing on the false branch.

Nested ifs (compound conditions)

Compound expressions (&&, ||) aren't supported in when: — keep the surface tiny. Express them by nesting:

yaml
- if:
    when: "${charging} == true"
    then:
      if:
        when: "${pct} >= 80"
        then: { text: { content: "Almost full" } }
        else: { text: { content: "Charging…" } }
    else:
      if:
        when: "${pct} < 20"
        then: { text: { content: "Low!", color: red } }

showIf — node-level visibility

Drop a showIf: next to any node to gate it on a condition:

yaml
- text:
    content: "Low battery"
    color: red
  showIf: "${pct} < 20"

When the predicate is false, the node renders as nothing — no slot, no spacing reserved. Same expression grammar as if.when.

showIf is a modifier, not a node kind. You can attach it to any node, including forEach:

yaml
forEach:
  in: "${tools}"
  template:
    text: { content: "${item.name}" }
  showIf: "${tools}"

(In practice the forEach would render zero items on an empty array anyway, so showIf here just suppresses any whitespace from the surrounding stack.)

Patterns

"Show only when there's something to show"

yaml
- text: { content: "Errors: ${error_count}", color: red }
  showIf: "${error_count} > 0"

"Different icon per state"

yaml
- if:
    when: "${state} == \"open\""
    then:
      symbol: { name: circle.fill, color: green }
    else:
      if:
        when: "${state} == \"closed\""
        then:
          symbol: { name: checkmark.circle, color: purple }
        else:
          symbol: { name: questionmark.circle, color: secondary }

"Zebra stripes in a list"

yaml
forEach:
  in: "${rows}"
  template:
    hstack:
      children: [...]
      background: "#FFFFFF11"
      cornerRadius: 4
      showIf: "${index} != 0"  # skip the first row

(Zebra stripes via index parity: parity isn't directly supported, but you could pre-compute a stripe field server-side.)

"Hide the cell entirely when no data"

yaml
id: gh-prs
command: gh pr list --json ... 2>/dev/null
view:
  hstack:
    ...
  showIf: "${count} > 0"

When count is 0 the entire cell renders empty (just the hover area). The cell still occupies space in the bar — showIf on view suppresses content but not the slot. To remove the cell entirely, set enabled: false in the entry.

Performance

forEach walks the items array on every render and rebuilds the inner views. SwiftUI's diff is good but not free — the recommendation in references/list-patterns.md is "constant view count per element". The widget renderer satisfies this by emitting one view per item with stable index identity.

For large arrays (>50 items), set limit: to cap visible rows. The popover wraps in a ScrollView at 500pt max height anyway — you won't see more than a screenful regardless.