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().
| Key | Type | Default | Meaning |
|---|---|---|---|
id | string | required | [A-Za-z0-9_-], 1 to 48 characters, unique within the mod |
label | string | the id | the name in Layout, the Priority Stack and the gallery |
urgency | enum_t<"urgency"> | urgency.ambient | how the widget competes for the notch |
fields | F | required | the 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.
| Surface | Where |
|---|---|
surface.notch_primary | the collapsed pill, when this widget leads |
surface.notch_secondary | a chip at the right end of another widget’s pill |
surface.notch_ambient | the collapsed pill, as an idle filler |
surface.page | a card in a page slot of the expanded panel |
surface.transient | a popup that shows, then retracts |
| Option | Applies to | Meaning |
|---|---|---|
timeout_ms | surface.transient | how long a raised transient stays before Perch retires it. Default 2200; 0 means the mod retires it |
with | surface.notch_secondary | the one leader this chip may sit beside: a handle of your own, or a builtin id such as "media" |
envelope | any | override 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_startthe lastpresentper widget is remembered and applied at start, with its data. - On a
surface.transientplacement,presentstarts or restarts the placement’stimeout_ms. - Any
ui.drawnode whosedatafield 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_starta 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.fieldthe 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
| Error | Cause |
|---|---|
widget.new: needs { id = <string>, ... } | no id |
widget.new: urgency must be urgency.ambient/event/transient | an unknown urgency |
widget.new: a widget declares `fields = { name = field.string(), … } | no schema |
widget.new: fields.<name> must be a field.*() schema | a 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 table | the display function returned something that is not a tree |
widget:register: place() at least one surface first | nothing placed |
widget:register: widget id must be [A-Za-z0-9_-], 1-48 chars | a 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.