Skip to content

Forms & configuration UI

Forms are Ethos's built-in UI toolkit: labelled lines holding native fields (numbers, choices, sources, switches, colors, ...). They look and behave like the rest of the radio's menus, handle touch, encoder and keys for you, and are the right tool for every settings screen.

You build a form in:

  • a widget's configure(widget) callback (the widget settings page),
  • a system tool's create() callback (the tool's page),
  • a task's or source's configure callback.

Lines and fields

Every field sits on a line. The line has a label on the left; fields go on the right.

local line = form.addLine("Max RPM")
form.addNumberField(line, nil, 0, 20000,
  function() return config.maxRpm end,            -- getter: current value
  function(v) config.maxRpm = v end)              -- setter: called on change

Every field takes a getter and a setter instead of a variable. Ethos calls the getter whenever it needs to show the value, and the setter when the pilot changes it. Passing nil as the rect lets Ethos place the field.

Field Call Value type
Number form.addNumberField(line, rect, min, max, get, set) integer
Choice form.addChoiceField(line, rect, values, get, set) value from values
On/off form.addBooleanField(line, rect, get, set) boolean
Source form.addSourceField(line, rect, get, set) Source
Switch form.addSwitchField(line, rect, get, set) Source
Sensor form.addSensorField(line, rect, get, set [, filter]) Source
Color form.addColorField(line, rect, get, set) color
Text form.addTextField(line, rect, get, set) string
Time form.addTimeField(line, rect, get, set) seconds
Slider form.addSliderField(line, rect, min, max, get, set) integer
File form.addFileField(line, rect, path, type, get, set) path
Button form.addButton(line, rect, { text=, icon=, press= })
Static text form.addStaticText(line, rect, text)

Choice values

A choice field's values is a list of {label, value} pairs:

local MODES = { { "Off", 0 }, { "Beep", 1 }, { "Voice", 2 } }   -- built once, at file level

local line = form.addLine("Alert")
form.addChoiceField(line, nil, MODES,
  function() return config.alert end,
  function(v) config.alert = v end)

Tuning number fields

The field objects returned by add*Field have methods. Number fields have the most:

local f = form.addNumberField(line, nil, 0, 500, getV, setV)
f:decimals(1)         -- 0..500 shows as 0.0..50.0
f:suffix("V")
f:step(5)
f:default(111)        -- long-press resets to this
f:help("Cell voltage that triggers the low-battery call")

NumberEditLib has the full list. All fields share field:enable(bool) and field:focus() from FormFieldLib.

Enable/disable only when it changes

field:enable() is one of the most-called methods in real projects. Calling it from wakeup every tick with the same value causes needless redraws. Track the last state and call it only when it changes.

Several fields on one line

form.getFieldSlots(line, specs) splits the right side of a line into rects. A number is a fixed width, 0 means "share the remaining space", a string reserves room for that text:

local line = form.addLine("Range")
local slots = form.getFieldSlots(line, { 0, "-", 0 })
form.addNumberField(line, slots[1], -1024, 1024, function() return w.min end, function(v) w.min = v end)
form.addStaticText(line, slots[2], "-")
form.addNumberField(line, slots[3], -1024, 1024, function() return w.max end, function(v) w.max = v end)

Expansion panels

Group advanced settings so the page stays short:

local panel = form.addExpansionPanel("Advanced")
panel:open(false)                                  -- start collapsed
local line = panel:addLine("Smoothing")            -- or form.addLine("Smoothing", panel)
form.addNumberField(line, nil, 0, 10, getS, setS)

Dialogs

form.openDialog({
  title = "Reset stats?",
  message = "This clears min/max for every sensor.",
  buttons = {
    { label = "Reset",  action = function() resetStats(); return true end },  -- true closes the dialog
    { label = "Cancel", action = function() return true end },
  },
  options = TEXT_LEFT,
})

Buttons are laid out right to left: the first entry in buttons is the rightmost, and the initial focus goes to the leftmost (last) one. In the example above "Cancel" sits on the left and has focus, which is the safe default for a destructive action.

For long operations, a wait dialog with progress:

local dlg = form.openWaitDialog({ title = "Saving", message = "Writing settings...", progress = true })
-- later, from wakeup:
if dlg then                         -- guard: it can be nil (see warning below)
  dlg:value(percent)
  if percent >= 100 then dlg:close(); dlg = nil end
end

Guard dialog handles

In practice openWaitDialog has been seen returning nil during a page reload, and code that then called dlg:value() crashed with attempt to index a nil value. Always check the handle, and close dialogs in your tool's close() callback.

Rebuilding a form

To redraw a page with different content (for example after the pilot picks a mode), clear it and build it again:

local function build(state)
  form.clear()
  local line = form.addLine("Mode")
  form.addChoiceField(line, nil, MODES, function() return state.mode end,
    function(v) state.mode = v; build(state) end)   -- rebuild for the new mode
  if state.mode == 2 then
    -- extra lines for mode 2 only
  end
end

Rebuilding is not free

Ethos keeps some form allocations alive after form.clear(), outside the reach of Lua's garbage collector. Measured on an X18RS, each rebuild of a complex page retains RAM that collectgarbage() can't recover. Rebuild only when the structure actually changes, use field:enable() to show and hide options instead, and reuse getter/setter closures rather than creating new ones on every build. See Saving RAM.

Widget configuration example

local function configure(widget)
  local line = form.addLine("Source")
  form.addSourceField(line, nil, function() return widget.source end,
    function(v) widget.source = v end)

  line = form.addLine("Warning below")
  local f = form.addNumberField(line, nil, 0, 1000, function() return widget.warn end,
    function(v) widget.warn = v end)
  f:decimals(1)

  line = form.addLine("Color")
  form.addColorField(line, nil, function() return widget.color end,
    function(v) widget.color = v end)
end

Pair it with read/write so the settings survive a restart. See Saving settings.