Perch
Lua APIWidgets

Widgets

widget.new, the widget handle and its verbs, surfaces and placement options, and the widget types. Always granted.

7 min readUpdated Sep 9, 2026

A widget is the one thing a mod creates: a handle with an id, a label, an urgency, a field schema and one display tree per surface. This page is the reference for the handle. What the surfaces mean and how widgets share the notch is on Widgets in the modding section.

widget.new

widget.new(def: widget_def_t<F>): widget_t<F>
local w = widget.new {
  id = "clock", label = "World clock", urgency = urgency.ambient,
  fields = { city = field.string(""), time = field.string(""), frac = field.number(0) },
}

Creates a handle. Nothing reaches Perch until :register().

KeyTypeDefaultMeaning
idstringrequired[A-Za-z0-9_-], 1 to 48 characters, unique within the mod
labelstringthe idthe name in Layout, the Priority Stack and the gallery
urgencyenum_t<"urgency">urgency.ambienthow the widget competes for the notch
fieldsFrequiredthe schema of the data it pushes; see Fields

The verbs below all return the handle, so they chain:

local w = widget.new { id = "hello", label = "Hello", fields = { msg = field.string("") } }
  :place(surface.page, displays.card)
  :register()

Inside your Lua the id is "clock". Everywhere else (config, the settings window, the arbiter) it is "<mod id>/clock". You never write the long form, and a mod can only drive widgets it owns.

w

w:place(where: enum_t<"surface">, tree: node_t | (f: F) -> node_t, opts: place_options_t?): widget_t<F>

Maps one surface to one display tree. Call it once per surface the widget can appear on; a widget needs at least one before it registers. The tree is normally a display function, called once with the widget’s field refs when you place it. Re-placing a surface replaces its tree; placements survive a re-register.

SurfaceWhere
surface.notch_primarythe collapsed pill, when this widget leads
surface.notch_secondarya chip at the right end of another widget’s pill
surface.notch_ambientthe collapsed pill, as an idle filler
surface.pagea card in a page slot of the expanded panel
surface.transienta popup that shows, then retracts
OptionApplies toMeaning
timeout_mssurface.transienthow long a raised transient stays before Perch retires it. Default 2200; 0 means the mod retires it
withsurface.notch_secondarythe one leader this chip may sit beside: a handle of your own, or a builtin id such as "media"
envelopeanyoverride the size class with ui.envelope.wide or ui.envelope.tall
media:place(surface.notch_primary, displays.pill)
media:place(surface.notch_secondary, displays.chip, { with = "discord" })
media:place(surface.page, function(f: displays.fields_t): node_t return displays.card(f, act) end)

w

w:preview(data: any): widget_t<F>

Seed data for the settings window: the Layout gallery tile and the page-slot preview render the real tree over it, so the widget never shows an empty state there. Give every bound field a plausible value; ui.asset("preview/cover.png") embeds a package image. Validated against the schema; must come before :register().

w:preview { game = "Team Fortress 2", count_text = "1,436,921" }

w

w:register(): widget_t<F>

Registers the widget with Perch, or upserts it if the id exists: the descriptor swaps in place and every page slot and Priority Stack row that pointed at it keeps pointing at it.

Where you may call it:

  • at the top of the entry file, while it loads: recorded and merged before the package constructs;
  • in the boot-time perch.on_configure: queued and registered at start;
  • any time after perch.on_start: registered live.

w
, w

w:present(data: any?): widget_t<F>
w:retire(): widget_t<F>

Presence is what puts a widget on screen: in the notch, in a transient, in its page slot. A registered widget with no presence renders nothing. data is validated against the schema and merged into the widget’s payload.

  • Before perch.on_start the last present per widget is remembered and applied at start, with its data.
  • On a surface.transient placement, present starts or restarts the placement’s timeout_ms.
  • Any ui.draw node whose data field is in the payload runs its painter.
perch.audio.volume.on_change:connect(function(v: volume_state_t)
  hud:present({ frac = v.level, muted = v.muted })   -- retires itself after timeout_ms
end)

w

w:push(data: any): widget_t<F>

Merges fields into the payload. Keys you omit keep their value; only the nodes whose bound field changed repaint. Validated like present.

  • Before perch.on_start a push is parked and delivered right after the queued presence.
  • Beyond 40 pushes per rolling 4 seconds, pushes are parked and merged per key (newest wins) and delivered within 250 ms. Nothing is dropped.
  • A push never overwrites a ui.field the user is typing in.
w:push({ pos_frac = pos / dur })

w

w:relabel(label: string): widget_t<F>

Renames the widget in the gallery and the Priority Stack in place. Placements and the user’s layout survive.

w

w:remove(): ()

Removes the widget entirely. Presence exits with its normal animation, then the arbiter entry, the display trees, the preview data, the Priority Stack row and any page slot naming it go too. A page slot that loses its widget heals the way it does at boot. No prompt is shown; the mod owns the flow.

w.on_visibility

w.on_visibility: signal_t<boolean>

Fires with true when the widget’s page card comes on screen (the panel is expanded and its page is active) and false when it leaves. Poll expensive data only while visible.

w.on_visibility:connect(function(visible: boolean)
  if visible then poll:start() else poll:stop() end
end)

Taps, clicks and scrubs do not arrive on the handle. The node that takes the input carries the function; see Input.

Errors

ErrorCause
widget.new: needs { id = <string>, ... }no id
widget.new: urgency must be urgency.ambient/event/transientan unknown urgency
widget.new: a widget declares `fields = { name = field.string(), … }no schema
widget.new: fields.<name> must be a field.*() schemaa value in fields that is not a constructor
widget '<id>': unknown field '<key>' (declare it in fields = { … })a push, present or preview with an undeclared key
widget '<id>': field '<key>' is <kind> but got <type>a push with the wrong type
widget:place: unknown surface '<x>'not one of the five surfaces
widget:place: the display must be a ui.* tree tablethe display function returned something that is not a tree
widget:register: place() at least one surface firstnothing placed
widget:register: widget id must be [A-Za-z0-9_-], 1-48 charsa bad id
duplicate widget id <id>two load-time registrations with one id

Types

export type widget_def_t<F> = { id: string, label: string?, urgency: enum_t<"urgency">?, fields: F }

The table widget.new takes. F is the fields table; widget.new turns it into the typed refs on w.fields.

export type widget_t<F> = {
	id: string,
	fields: F,
	on_visibility: signal_t<boolean>,
	place: (self: widget_t<F>, where: enum_t<"surface">, tree: node_t | ((f: F) -> node_t), opts: place_options_t?) -> widget_t<F>,
	preview: (self: widget_t<F>, data: any) -> widget_t<F>,
	register: (self: widget_t<F>) -> widget_t<F>,
	present: (self: widget_t<F>, data: any?) -> widget_t<F>,
	push: (self: widget_t<F>, data: any) -> widget_t<F>,
	retire: (self: widget_t<F>) -> widget_t<F>,
	relabel: (self: widget_t<F>, label: string) -> widget_t<F>,
	remove: (self: widget_t<F>) -> (),
}

The handle. Write it as widget_t<displays.fields_t> when you keep handles in a table.

export type place_options_t = { timeout_ms: number?, with: any_widget_t?, envelope: enum_t<"envelope">? }

The third argument of place, described above.

export type any_widget_t = { id: string }

A handle with its schema erased: what input_context_t.widget and place_options_t.with carry.

declare surface: { notch_primary: enum_t<"surface">, notch_secondary: enum_t<"surface">, notch_ambient: enum_t<"surface">, page: enum_t<"surface">, transient: enum_t<"surface"> }
declare urgency: { ambient: enum_t<"urgency">, event: enum_t<"urgency">, transient: enum_t<"urgency"> }

The two enums. On the wire each member is its name as a string.

esc
Type to search
navigate open