Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
3f40b6632This file is pinned to the selected release. Download the original to open it in your design tool.
# Buttons: VirtualButton + ButtonBank
A button that cycles a filter mode involves edge polling, persistence,
HostLink description, LED feedback, and the DSP-side effect.
`VirtualButton` + `ButtonBank` declare all five in one place:
```cpp
static const char* kModes[3] = {"LP", "BP", "HP"};
static VirtualButton flt = VirtualButton(kButtonB3, "Filter")
.Ident("flt.mode") // stable host id (default "b<hw>")
.Selector(kModes) // 3 zones + labels from one array
.Colors(kModeColors) // per-zone LED feedback
.Bind(SetFilterMode); // fires on gesture and preset load
static Page page_a = Page(0).Knobs(k1, k2).Buttons(flt);
static ButtonBank buttons;
int main()
{
...
presets.Manage(buttons); // one byte per stateful button
loop.Use(pager).Use(page_a).Use(buttons).Use(host);
...
}
```
Everything else is derived. With no declared gesture the button
tap-cycles (two zones act as a toggle). The browser shows an editable
"Filter" enum inside its page, labeled "Cycle LP / BP / HP". Presets
carry the zone. `Bind` keeps `SetFilterMode` current on gestures and
preset loads. `flt.Zone()` returns the current zone for code that polls
in `OnFrame`.
## The model
`VirtualButton` is to `ButtonBank` what `VirtualKnob` is to `Pager`: the
bank owns the state. The declared object is description only; the bank
persists, dispatches, renders, and describes.
Knob state is keyed by *(page, pot)* because a physical pot's position
must be reconciled per page; that is what pot-catch does. A button has
no position, so its state is keyed by the **VirtualButton object**.
Pages only scope dispatch:
- The same physical button does different things on different pages by
declaring one VirtualButton per page (`page_a.Buttons(flt)`,
`page_b.Buttons(wave)`, both constructed on `kButtonB3`, each with
its own `.Ident()`).
- The same object on several pages is one shared control (one preset
byte, rendered on each page).
- Moving a button between pages at runtime redirects dispatch and
never touches its state.
`ButtonBank::Global()` registers a button active on every page.
## State, gestures, effects
| Declaration | Meaning |
|---|---|
| `VirtualButton(hw, name)` | physical button (e.g. `kButtonB1`; a bare literal `0` won't compile, use the constant) |
| `.Ident("field.id")` | stable host id; defaults to `"b<hw>"`; required when two buttons share a hw index |
| `.Selector(kLabels)` | N-zone state; zones + labels from one array |
| `.Selector(n)` / `.Toggle()` | unlabeled N zones / 2 zones |
| `.Default(z)` | factory zone (checked against N) |
| *(no Selector)* | momentary: zero preset bytes |
| `.Tap(Action::Cycle, "…")` | explicit tap; label optional (derived) |
| `.TapSet(z)` / `.HoldSet(ms, z)` | jump to a zone |
| `.Hold(ms, Action::…)` | hold mutation; suppresses the trailing tap |
| `.Tap(fn)` / `.Hold(ms, fn)` | momentary callbacks |
| `.Bind(&var)` / `.Bind(Setter)` | write zone into a variable / setter |
| `.OnChange(fn, ctx)` | general notification, after Bind targets |
| `.Anchor("field.id")` | attach to that field in the browser: renders with it wherever it appears, falls back to the page's button group where it doesn't; the id must name an emitted field or the descriptor build fails |
| `VirtualButton(ident, name)` | host-only state: persisted + editable, no gesture |
Gesture callbacks and mutations run in the control-loop poll (the
`OnPoll` context). Preset loads apply data in `Deserialize` but defer
the `Bind`/`OnChange` notifications to the next frame, coalescing
HostLink live pushes; notifications fire only when the zone actually
changed.
## The contract
- **The bank does not consume edges.** It reads only
`IButton::Pressed()` and derives its own edges, so app code reading
`RisingEdge()` directly (as in the `kick` example) sees every edge.
Sample-accurate triggers, such as kick's audio-callback drum trigger,
should stay on direct reads; bank callbacks run at the 1 ms poll.
- **Gestures resolve against the press-time page.** A page change mid
hold cannot retarget the gesture.
- **Gestures shadow page navigation.** A gesture fired on a button that
carries a release-driven navigation binding (the cycle button, or a
latch pair's button; see `KnobStorage::ConsumesRelease`) consumes that
release (`KnobStorage::ConsumeButton`), so a button declared on B1
shadows page-advance on pages that declare it, and only there.
- **Shift holds and taps share a button.** A `Pager::Shift` hold that
edited a parameter claims its release (`KnobStorage::HoldClaimed`)
and the trailing tap does not fire; a hold that edited nothing leaves
the tap free. See docs/pages-and-layers.md.
- **Don't stack holds on the ParamLock button.** Lock recording is
hold+knob; the two hold gestures cannot be arbitrated.
- **Settings gates gestures, not data.** While Settings is active
presses are inert (an in-flight press is discarded), but preset/host
pushes still reach `Bind` targets. One exception: the B2+B3 enter
chord only claims its buttons once it *completes* (after `hold_ms`),
so a tap declared on B2 or B3 fires if the chord is released early. A
press that spans the activation is discarded by the gate, but an
abandoned chord fires the tap. Harmless for cyclic state (one extra
press undoes it); put tap-critical actions on buttons outside the
chord.
- **LED precedence.** The bank paints after the pager's page tint, so
a declared mode color wins on a shared button.
- **Tables are borrowed.** `Selector`, `Labels`, and `Colors` keep a
pointer to the array you pass, so name it with static storage; a
braced list is rejected at compile time.
## Freeze and validity
The roster (which buttons persist, in what byte order) freezes at the
first `Presets`/descriptor walk, the same moment the rest of the blob
layout freezes. Declare every stateful button (via a page or
`Global()`) before `presets.BootLoad()`; page *membership* stays freely
mutable afterwards.
Declaration errors latch `Ok() == false` and fail the descriptor build
(a missing descriptor is preferable to a wrong one): label/color counts
that disagree with the zone count, `Default()` out of range, duplicate
idents, fewer than two zones, or a stateful button first seen after
freeze. An `Anchor()` that names no emitted field (or the button's own
id) fails the same way; it is checked at descriptor `Finish()`, since
the target may live in a component managed later.
`DescriptorBuilder::LastError()` carries the reason for any of these.
The bank's `SchemaHash()` folds every cell's ident and zone count, so
reshaping the roster (or renaming an ident) invalidates saved preset
slots instead of misreading them. Adding a bank to existing firmware
is a `Manage()` addition and invalidates old slots like any other.
## HostLink
The bank emits one `kind: "buttons"` component (protocol §5.5).
Stateful buttons are ordinary 1-byte enum fields, so any host, including
one that predates the kind, renders them as an editable group and can
edit them; button-aware hosts use the `page`, `anchor`, and `btn` keys
to draw them inside the page cards. Momentary buttons emit into the
component's `modal` array.
Modules using the bank do not call `Host::Buttons()`, so the legacy
root `buttons` array (§5.3) is absent and nothing renders twice. The
legacy array remains supported for metadata-only firmware.
`Host::Buttons()` includes explicitly declared `.Tap()` and `.Hold()`
gestures alongside legacy `.Action()` entries in each root button's
`actions` array. Each entry includes its label and any `.GestureHelp()` text.
This registration exports metadata only; gesture handling still needs
the bank or the firmware's own button handling.
## Loop-less use
Without a `ControlLoop`, wire explicitly and drive the three phases
yourself: `bank.Attach(btns, n).Pages(pg)`, then per millisecond
`bank.PollButtons(t_ms, gated, active_page)`, per frame
`bank.Update(t_ms)` and `bank.Render(panel, active_page, t_ms)`.
## Not implemented
- **Double-tap.** Detecting it adds latency to every plain tap on that
button.
- **External field binding** (two buttons mutating one shared field
with different actions). Workaround: declare the second button
momentary and call `bank.SetZone(first, z)` from its callback.