Widgets

Bindings

How `${path}` resolves, format specs, history rings, dotted paths.

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.

yaml
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:

yaml
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:

yaml
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:

yaml
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:

yaml
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>}:

yaml
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:

ValueTruthy?
true
Number ≠ 0
Non-empty string
Non-empty array
Non-empty object
false, 0, "", null, [], {}
yaml
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:

yaml
"${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:

yaml
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:

BindingValue
${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}.

yaml
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:

yaml
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:

  1. Apply showIf first. If false, bail out — return EmptyView.
  2. Render the kind (text / gauge / forEach / etc.).
  3. 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.