Widgets ship rendering separate from data. The YAML tree describes how to render; the script's stdout JSON provides what to render. Bindings glue them together.
The basics
Inside any string-shaped field, ${path} substitutes the value at path in the script's parsed JSON.
text: { content: "${city}: ${temp_f}°F" }
# script outputs: {"city":"SF","temp_f":68}
# → "SF: 68°F"
Numeric and array fields accept either a literal or a binding:
gauge: { value: 42 } # literal
gauge: { value: "${pct}" } # binding (resolves to a number)
sparkline: { values: [1, 2, 3] } # literal
sparkline: { values: "${history.pct}" } # binding (resolves to [Double])
A binding that doesn't resolve falls back to the field's default — 0 for numbers, [] for arrays, the literal token ("${missing}") for strings. Widgets never crash on bad data.
Dotted paths
Paths can walk into nested objects and arrays:
text: { content: "${weather.current.temp}" }
text: { content: "${prs.0.title}" } # array index
text: { content: "${top.0.name}" } # array of objects
Resolution rules:
- Top-level: literal key lookup
- Inside an object: key lookup
- Inside an array: numeric index (
0,1,2, ...) - Missing segment:
null
Format specs
Append :format to the path inside the binding to apply a printf-style format:
text: { content: "${pct:%.1f}%" } # → "42.3%"
text: { content: "${load1:%.2f}" } # → "1.05"
text: { content: "${size:%d}" } # → "1024"
Specs are passed straight to String(format:). Numeric formats only — strings are returned as-is.
History rings
For sparklines and trend charts, you typically need the history of a field, not just the current value. Declare a ring at the entry level:
id: cpu-pulse
history:
pct: 60 # keep last 60 values of `pct`
load1: 30 # keep last 30 values of `load1`
command: |
PCT=$(top -l 1 -n 0 | awk '/CPU usage/ {gsub(/%/,"",$3); printf "%.0f", $3}')
LOAD=$(sysctl -n vm.loadavg | awk '{printf "%.2f", $2}')
printf '{"pct":%s,"load1":%s}\n' "$PCT" "$LOAD"
The runner appends each tick's value to the ring buffer and exposes the contents under ${history.<field>}:
sparkline: { values: "${history.pct}", min: 0, max: 100 }
History rings persist for the life of the runner (until ApexDock quits or the YAML reloads with a different history: block). They're not written to disk — restarts begin with empty rings that fill over time.
Truthy and falsy values
For if.when and showIf predicates, a bare ${path} is evaluated as truthy:
| Value | Truthy? |
|---|---|
true | ✓ |
| Number ≠ 0 | ✓ |
| Non-empty string | ✓ |
| Non-empty array | ✓ |
| Non-empty object | ✓ |
false, 0, "", null, [], {} | ✗ |
showIf: "${charging}" # truthy bool
showIf: "${prs}" # non-empty array
showIf: "${error_message}" # non-empty error string
Comparison expressions
if.when and showIf support six operators between two operands:
"${pct} < 20"
"${count} >= 1"
"${state} == \"open\""
"${draft} != true"
"${pct} > ${threshold}" # binding-vs-binding
Operators: <, <=, >, >=, ==, !=. Numeric comparison is tried first; if either side isn't a number, falls back to string compare for == / !=.
The parser ignores > / < inside ${...} so paths can't trip the operator search.
Compound expressions (&&, ||) are intentionally unsupported — keep the surface tiny so a typo never breaks the bar. Express compounds via nested if:
if:
when: "${charging} == true"
then:
if:
when: "${pct} < 80"
then: { text: { content: "Charging" } }
Special bindings inside forEach
Inside a forEach template, two extra bindings appear:
| Binding | Value |
|---|---|
${item} | The current array element |
${index} | The current ordinal (0-based) |
If the array contains objects, walk into them: ${item.name}, ${item.url}, ${item.cpu}.
forEach:
in: "${top}"
template:
hstack:
children:
- text: { content: "${index}: ${item.name}" }
- text: { content: "${item.cpu:%.1f}%" }
Bindings inside click actions
Both click: (cell-wide) and onTap: (per-node) resolve ${} against the current widget's data at click time, so list rows can open per-item URLs:
forEach:
in: "${prs}"
template:
text: { content: "#${item.number} ${item.title}" }
onTap:
url: "${item.url}"
The widget's cell-level click: (declared at the entry top level) resolves bindings against the most recent data tick. Per-node onTap: resolves against the data scope at render time of that node — including ${item} for forEach rows.
Resolution order
When rendering a node:
- Apply
showIffirst. If false, bail out — return EmptyView. - Render the kind (text / gauge / forEach / etc.).
- Apply modifiers in order: padding → background → frame → color → opacity → tooltip → onTap.
This ordering means a padding modifier wraps the result of the kind, while background paints behind the padded result. The onTap is the outermost — clicks reach a tap target whatever its child layout looks like.
Performance
Binding interpolation runs at every render. SwiftUI's diff means renders only happen when data actually changes (the store does an Equatable check on the widget before re-publishing). For most YAML widgets, that's the polling interval — not faster.
If you have a large forEach with hundreds of rows, the renderer still walks each row's bindings. Use limit: to cap visible items.