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.
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:
# 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:
- if:
when: "${prs}"
then:
forEach:
in: "${prs}"
template: ...
else:
text: { content: "No open PRs", color: secondary }
if — branch on condition
- 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:
- 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:
- 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:
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"
- text: { content: "Errors: ${error_count}", color: red }
showIf: "${error_count} > 0"
"Different icon per state"
- 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"
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"
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.