Skip to content

UI: Forms and Layout

Core Principle: Forms, Controls, and Encapsulation

Section titled “Core Principle: Forms, Controls, and Encapsulation”

Before touching the editor, it is worth understanding the two primitives the UI framework is built from — because they shape every decision made in this chapter.

A control is a specialised UI element: a button, a knob, a level meter, a text label. Controls are the leaves of the tree.

A form is a container that holds controls — and, crucially, a Lua script that handles dynamic interactions. Every form is [control container + Lua script]. Forms can contain other forms, which is what makes complex UIs manageable.

The guiding principle is encapsulation. Each form should be self-contained and know only what it needs to know about itself. A step button knows its own index and whether it is selected. It does not know how many other steps exist or what the global sequencer state is. The form above it passes in what it needs via macro parameters and listens for events. This keeps each piece small, testable, and reusable.

If you are coming from Kontakt, this is a significant shift. Kontakt scripting is essentially one global blob that has to manage every interaction in the instrument at once. Here, complex behaviour is decomposed into small forms that each handle their own concerns — the complexity stays local and proportional.

This will become concrete in a moment. The first form we build is the step button, and we will build it exactly once — then the containing form instantiates it 16 times.

Right-click patches -> main -> ui in the file tree and choose Create new UI form. Name it step_button.

Create new UI form context menu

A new minimal form opens — the editor’s “hello world” starting point.

Newly created form in the editor

This chapter involves editing YAML frequently and seeing the result in real time. Detach the YAML editor to open it in its own window alongside the form preview.

Detached YAML editor alongside the form preview

Remember: changes to YAML or Lua scripts only take effect after pressing Apply. Get into the habit of pressing it after every edit — it is easy to forget and wonder why nothing changed.

Replace the default YAML with the following:

width: 40
height: 72
controls:
base:
anchors: { left: 0, right: 0, bottom: 0, height: 57 }
type: button
props:
phaseBitmap: "step_button.png"
text: ""
onPressed: on_select
tintColor: "$stepColor"
led:
anchors: { hCenter: 0, top: 12, width: 30, height: 30 }
type: levelMeter
props:
orientation: vertical
value: 0.0
phaseBitmap: "led_p3_c2.png"
caption:
anchors: { left: 0, top: -3, right: 0, height: 16 }
type: text
props:
text: "$stepIdx"
fontScale: 0.8
fontWeight: 700

Three controls make up each step button:

  • base — the button graphic, drawn from the step_button.png bitmap. onPressed wires it to a Lua handler called on_select (defined in this form’s script). tintColor accepts the $stepColor macro, which the containing form uses to distinguish quarter-note steps from the rest.
  • led — a level meter driven by the led_p3_c2.png filmstrip, used here as an indicator that shows the steps current state: off, selected or playing.
  • caption — a text label showing the step number, taken from the $stepIdx macro parameter.

Both $stepIdx and $stepColor are macro parameters — values the containing form passes in when it instantiates this sub-form. The step button itself does not know which step it represents; the parent tells it via $stepIdx. This is encapsulation in practice: the form’s layout and behaviour are defined once; the parent supplies the varying data.

After pressing Apply, the editor renders a live preview of the form. The step button appears, but it will look slightly off — the $stepIdx caption and $stepColor tint are unresolved because no parent form is passing them in yet. This is perfectly normal at this stage. Macros are resolved at runtime by the containing form; in isolation the editor shows them as empty or default.

Step button preview with unresolved macros — expected at this stage

With a form on screen this is a good moment to get familiar with a few editor features that will save time throughout this chapter.

Zoom and pan. The preview can be zoomed with the mouse wheel and panned by clicking and dragging. Use this freely — complex forms get busy and being able to zoom into a specific control is useful.

Control annotations. By default every control in the preview has a dashed border drawn around it and a small square handle at its top-left corner. These are editor overlays — they are not part of the rendered UI. They can be toggled on and off with a tool button in the upper-right area of the preview panel.

Annotation toggle button in the upper-right of the preview panel

YAML navigation. Clicking the small square handle at the top-left of any control selects that control’s YAML block in the YAML editor. On a form with many controls this is the fastest way to jump to the definition you want to edit without scrolling through the whole file.

Clicking a control handle selects its YAML block in the editor

The YAML defines the layout. The form’s Lua script defines the behaviour — specifically, how the LED responds to the playhead and the selection state, and what happens when the button is pressed.

Open the script editor for this form and enter the following:

-- keep copies of the currently selected / playing step:
local stepPlaying
local stepSelected
-- $stepIdx is the macro parameter used by the parent form to identify this step button:
local ownStepIdxStr = sys_form_macros["$stepIdx"]
-- always make sure the macro parameter is valid:
local ownStepIdx = ownStepIdxStr ~= nil and tonumber(ownStepIdxStr) or 0
-- if anything changes, re-evaluate the LED state:
function setLEDState()
if ownStepIdx == stepPlaying then
-- we're being the currently played step (playhead)? => LED red (0.5: middle phase of the bitmap)
sys_modify_control("led", "props", { value = 0.5 })
elseif ownStepIdx == stepSelected then
-- we're being selected? => LED green (1.0: last phase of the bitmap)
sys_modify_control("led", "props", { value = 1.0 })
else
-- we're being none of the above? => LED off
sys_modify_control("led", "props", { value = 0.0 })
end
end
-- called when the playing step changes:
function stepPlayingChanged(step)
stepPlaying = step
setLEDState()
end
-- called when the selected step changes:
function stepSelectedChanged(step)
stepSelected = step
setLEDState()
end
-- subscribe to the global parameters; their changes drive the whole UI:
stepPlayingChanged(sys_subscribe_to_parameter("stepPlaying", stepPlayingChanged))
stepSelectedChanged(sys_subscribe_to_parameter("stepSelected", stepSelectedChanged))
-- called when this step button is pressed:
function on_select()
sys_set_parameter_value("stepSelected", ownStepIdx)
-- we are not informed by the parameter subscription, since we are the sender!
-- So we need to update the LED state explicitly:
stepSelected = ownStepIdx
setLEDState()
end

Reading the macro parameter. The script reads its own identity from sys_form_macros["$stepIdx"] — the table the UI framework populates with this instance’s resolved macros. The value comes in as a string, so tonumber converts it before use. If no parent has set it yet (e.g. during isolated editing), the fallback is 0.

The LED state machine. setLEDState is a single function that decides the LED’s appearance from two independent facts: which step is playing (stepPlaying) and which step is selected (stepSelected). Keeping all three outcomes in one place means adding a new state later only requires touching this one function.

The led control’s value prop drives the filmstrip position: 0.0 = off, 0.5 = red (playhead), 1.0 = green (selected).

Subscriptions. The two sys_subscribe_to_parameter calls at the bottom register callbacks for stepPlaying and stepSelected. Because sys_subscribe_to_parameter returns the parameter’s current value at the time of subscription, feeding that return value straight into the same handler applies the initial state and registers for future changes in one line — no separate initialisation step needed.

Pressing the button. on_select writes this step’s index to the stepSelected global parameter, which notifies every other step button so they can update their LEDs. But — following the same no-self-notification rule as the keyboard form — this button will not receive its own stepSelected callback. So on_select also updates the local stepSelected variable and calls setLEDState() directly, ensuring this button’s LED turns green immediately without waiting for a callback that will never come.

Create another new UI form the same way as before. Name it step_grid. This form holds all 16 step buttons — but unlike step_button, almost nothing lives in the YAML. The controls are created entirely at runtime by the Lua script.

Enter the following as the form’s YAML:

width: 640
height: 72
controls: {}

The only thing the YAML declares is the form’s dimensions. The empty controls map is a valid starting point — the script will populate it.

Open the script editor for this form and enter the following:

-- we'll be creating a bunch of near-identical controls here;
-- this is the principal YAML code for one of them, with the
-- dynamic modifications embedded as %d / %s placeholders, which
-- will be resolved by Lua string interpolation below:
local yamlTemplate = [[
pos: {x: %d, y: 0, w: 40, h: 72}
type: subForm
props:
form: step_button
macroParameters: { "$stepIdx": "%d", "$stepColor": "%s" }
]]
local xPos = 0
for i = 1, 16 do
local isQuarterNote = (i - 1) % 4 == 0
local tintColor = isQuarterNote and "#aaa" or ""
local controlName = string.format("step_%d", i)
-- string interpolation parameters (matching the template above):
-- * the x position of this step
-- * the $stepIdx macro — tells the sub-form which step it is
-- * the $stepColor macro — lighter tint on quarter-note beats
local controlYAML = string.format(yamlTemplate, xPos, i, tintColor)
sys_create_control(controlName, controlYAML)
xPos = xPos + 40
end

YAML as a template. Rather than writing out 16 nearly-identical subForm entries in YAML, the script defines one template string with %d and %s format specifiers and uses string.format to fill them in per iteration. The result of each interpolation is a complete, valid YAML control definition — the same thing you would write by hand, just generated.

sys_create_control. This UI Lua function takes a control name and a YAML string and adds the described control to the form at runtime. It is the dynamic counterpart to declaring a control in the YAML directly. Anything created this way behaves identically to a statically declared control.

Quarter-note accents. (i - 1) % 4 == 0 is true for steps 1, 5, 9, and 13 — the downbeats of each bar. Those steps receive a $stepColor of "#aaa", which the step button applies as a tint over its bitmap. The remaining steps pass an empty string, leaving the button at its default appearance. The visual distinction makes the grid readable at a glance.

The result. When the form loads, the loop runs once and all 16 step buttons appear side by side. Each one is a fully independent step_button sub-form instance — subscribed to stepPlaying and stepSelected, responding to presses, managing its own LED — without this grid form knowing anything about their internals.

At this point the step grid is already fully functional. Add it to a parent form or open it in isolation: pressing any key triggers the sequencer, the red playhead LED advances through the steps in time, and clicking a step lights it green as selected.

Step grid form with all 16 steps, playhead and selection working

Create another new UI form. Name it keyboard. This one renders a two-octave keyboard that shows which note the selected step is assigned to, and lets the user change it by clicking a key.

The YAML is once again almost empty — all the keys are created by the script:

width: 690
height: 130
controls: {}

The script is split into two logical sections. Enter both together in the form’s script editor (as always, remove the original editor content, then paste BOTH snippets into the editor one after another).

Section 1 — State management and interaction:

-- manage and update the keyboard state: keep copies of the current step data
-- + selected step and update the keyboard display accordingly:
local steps
local stepSelected
-- helper function to be called once anything changes (selected step or step data):
function updateKeys()
if (not stepSelected) or (not steps) then
return
end
local step = steps[stepSelected]
for i = 24, 47 do
local isKeyActive = step.note == i
sys_modify_control(tostring(i), "props", { value = isKeyActive })
end
end
-- helper function to be called once the step data changes:
function stepsChanged(value)
steps = value
updateKeys()
end
-- helper function to be called once the selected step changes:
function stepSelectedChanged(step)
stepSelected = step
updateKeys()
end
-- subscribe to the global parameters; their changes drive the whole UI:
stepsChanged(sys_subscribe_to_parameter("stepData", stepsChanged))
stepSelectedChanged(sys_subscribe_to_parameter("stepSelected", stepSelectedChanged))
-- called whenever the user clicks on a key:
function on_change(controlName, value)
if (not stepSelected) or (not steps) then
return
end
local step = steps[stepSelected]
-- the control name IS the MIDI note — it was set up that way deliberately,
-- so we can extract the note with a simple tonumber:
local midiNote = tonumber(controlName)
-- toggle behaviour: clicking the active key silences the step (note = -1);
-- clicking any other key assigns that note:
if step.note == midiNote then
step.note = -1
else
step.note = midiNote
end
-- write the modified data back to stepData; every subscriber
-- (the RT script, the rest of the UI) will be notified automatically:
sys_set_parameter_value("stepData", steps)
-- whenever we change a parameter with set_parameter_value(),
-- we (this form) are NOT notified on that particular change from
-- sys_subscribe_to_parameter (this is standard behaviour to
-- prevent update feedback loops).
-- Therefore, we need to trigger the key state update explicitly here:
updateKeys()
end

Section 2 — Dynamic key creation:

-- YAML template for a black key:
local blackKeyTemplate = [[
pos: {x: %d, y: %d, w: 30, h: 50}
type: toggle
props:
phaseBitmap: "black_key_p2_c2.png"
onChange: on_change
]]
-- YAML template for a white key:
local whiteKeyTemplate = [[
pos: {x: %d, y: %d, w: 40, h: 70}
type: toggle
props:
phaseBitmap: "white_key_p2_c2.png"
onChange: on_change
]]
-- positional layout of one octave (x/y relative to the octave's left edge):
local octaveLayout = {
{ x = 0, y = 60, isBlack = false }, -- C
{ x = 30, y = 0, isBlack = true }, -- C#
{ x = 50, y = 60, isBlack = false }, -- D
{ x = 80, y = 0, isBlack = true }, -- D#
{ x = 100, y = 60, isBlack = false }, -- E
{ x = 150, y = 60, isBlack = false }, -- F
{ x = 180, y = 0, isBlack = true }, -- F#
{ x = 200, y = 60, isBlack = false }, -- G
{ x = 230, y = 0, isBlack = true }, -- G#
{ x = 250, y = 60, isBlack = false }, -- A
{ x = 280, y = 0, isBlack = true }, -- A#
{ x = 300, y = 60, isBlack = false }, -- B
}
-- creates one octave of keys starting at the given MIDI note and x offset:
function createOctave(startKey, startX)
for i, v in ipairs(octaveLayout) do
-- Lua indices are 1-based, so subtract 1 before adding startKey:
local controlName = string.format("%d", i - 1 + startKey)
local controlYaml
if v.isBlack then
controlYaml = string.format(blackKeyTemplate, v.x + startX, v.y)
else
controlYaml = string.format(whiteKeyTemplate, v.x + startX, v.y)
end
sys_create_control(controlName, controlYaml)
end
end
createOctave(24, 0) -- C2–B2
createOctave(36, 350) -- C3–B3

Control names as data. Each key’s control name is set to its MIDI note number as a string ("24", "25", …). This is a deliberate convention: when on_change fires, tonumber(controlName) gives the MIDI note directly, with no lookup table needed.

Visual state vs. data state. The toggle controls are not the source of truth for which note is active — stepData is. updateKeys reads the selected step’s note from stepData and sets each key’s value prop accordingly. When the user clicks a key, on_change modifies stepData and writes it back via sys_set_parameter_value. Every other subscriber to stepData (the RT script, other forms) will be notified and can react. The keyboard form itself, however, will not receive its own callback — see below.

No self-notification. When a form calls sys_set_parameter_value, the framework deliberately does not fire that parameter’s subscription callback back to the same form. This is standard behaviour that prevents feedback loops: a write triggering a callback triggering another write, and so on. As a consequence, on_change must call updateKeys() explicitly after writing to stepData — the subscription alone is not enough to refresh the keyboard’s own display after a local change. Every other subscriber will be notified; only this form has to update itself manually.

Toggle behaviour. Clicking a key that is already active sets the step’s note to -1 (silence), matching the convention established in stepData’s default value. Clicking any other key assigns that note. This gives the keyboard a natural on/off feel without any separate “clear” button.

The octave layout table. Rather than computing key positions mathematically, the octaveLayout table records the exact x/y offset and black/white classification for each of the 12 chromatic pitches. createOctave iterates it twice — once at x offset 0 for C2–B2, once at 350 for C3–B3 — placing 24 keys in total.

Keyboard form showing two octaves, active note highlighted

The two forms built so far — step_grid and keyboard — are independent and fully functional on their own. The main form simply places them together on screen.

Open the main form’s YAML and replace its contents with the following:

width: 700
height: 560
controls:
bg_image:
anchors: { left: 0, top: 0, right: 0, bottom: 0 }
type: image
props:
image: plugin_bg.png
steps:
pos: { x: 30, y: 63, w: 640, h: 72 }
type: subForm
props:
form: step_grid
keyboard:
pos: { x: 5, y: 423, w: 690, h: 130 }
type: subForm
props:
form: keyboard

bg_image must be declared first — controls are rendered in declaration order, so the background image sits at the bottom of the stack and everything else is drawn on top. This also reserves the correct slot for the spectrum visualizer, which will be inserted between bg_image and the other controls in a later pass.

Press Apply. The step grid and keyboard appear together on the background, and the instrument is already 80% functional: the sequencer cycles through all 16 steps, the red playhead tracks which step is playing, clicking a step selects it (green LED), and the keyboard shows that step’s current note — which can be changed live while playback is running.

Main form with step grid and keyboard — working step sequencer

  • Global controls panel (volume, filter, delay, reverb)
  • Remaining per-step parameters (filter, filter env, velocity, wavetable position) — second loop