Perch
Lua APIInput

Input

Taps, clicks, wheel notches, scrubs and edits as functions on nodes, global hotkeys under perch.input, and the input types.

4 min readUpdated Sep 9, 2026

Input reaches a mod in two ways. Inside a widget, the node that took the tap, click, wheel notch, scrub or edit carries the function that handles it. Outside a widget, a mod can claim a global keyboard shortcut under perch.input.hotkeys. A mod never sees raw keystrokes.

Input on nodes

Any node accepts these props. A node with any of them becomes interactive (the island stops being click-through under it); a node with only on_scroll does not capture clicks.

PropFires onctx carries
on_clicka left tapbutton = "left"
on_righta right click; the island’s own menu is suppressed under itbutton = "right"
on_middlea middle clickbutton = "middle"
on_scrolla wheel notch, at most once per 80 ms per nodesteps, signed, positive is up
on_seeka scrub on a ui.bar with seek = true: live while dragging, once more on releasefrac, 0..1
on_changean edit in a ui.field, debounced 800 ms and flushed on blur(text, ctx)
ui.button { glyph = ui.glyph.play, on_click = function(ctx: input_context_t) toggle() end }
ui.column { on_scroll = function(ctx: input_context_t) perch.audio.volume.set(level + (ctx.steps or 0) * 2) end, ... }
ui.bar { value = f.pos_frac, seek = true, knob = true, on_seek = function(ctx: input_context_t) seek_to(ctx.frac or 0) end }
ui.field { value = f.text, on_change = function(text: string, ctx: input_context_t) perch.storage.set("text", text) end }

Every handler receives an input_context_t. It always carries widget (the handle) and, for the click props, button. Inside a repeater item it also carries index (1-based) and item (the element’s data, present while the list rows are resident):

ui.list { items = f.apps, item = function(it: app_refs_t): node_t
  return ui.image { src = it.icon, hover = ui.hover.lift,
    on_click = function(ctx: input_context_t) perch.apps.launch(entries[ctx.index or 1]) end }
end }

A display function runs when the widget is placed, often before the verbs it calls exist. Look the verb up at click time (on_click = function() act.play_pause() end) rather than capturing it (on_click = act.play_pause, which captures nil).

Hover

PropMeaning
hover = ui.hover.liftthe node pops up under the pointer (dock icons)
hover = ui.hover.softthe node brightens slightly (button weight)
hover = ui.hover.scopethe node defines a hover region for the two props below
hover_show, hover_hideon descendants of a scope: fade in, or out, while the scope is hovered

Hotkeys

Scope input.hotkeys. A mod claims a combination, Windows routes it to Perch from anywhere on the machine, and the mod hears on_press on the handle it got back. Claimed shortcuts are listed in Settings so the user can see what a mod took.

perch.input.hotkeys.register

perch.input.hotkeys.register(combo: hotkey_t): hotkey_handle_t?
local capture = perch.input.hotkeys.register { ctrl = true, shift = true, key = key.P }

combo is any of ctrl, shift, alt, win set to true, plus key, a member of the global key enum (key.P, key.F4, key.SPACE). A missing or non-enum key raises.

Returns the handle, or nil when another application already owns that combination. Windows allows one owner per shortcut system-wide, and the first claimant wins. It is not an error and not recoverable by retrying: tell the user and offer another combination.

MemberMeaning
handle.on_presssignal_t<>; fires once per press, not repeatedly while held
handle:release()gives the claim up. Releasing twice is harmless

Release the old handle before registering a new combination, otherwise your own mod is the application that already owns it. Perch releases anything a mod forgot when it stops.

--!strict
type settings_t = { letter: string? }

local capture: hotkey_handle_t? = nil

perch.on_configure:connect(function(s: settings_t)
  if capture then capture:release() end
  capture = perch.input.hotkeys.register { ctrl = true, shift = true, key = key[s.letter or "N"] }
  if capture == nil then
    w:present({ text = "Shortcut unavailable. Pick another in Settings." })
  else
    capture.on_press:connect(function() w:present({ text = "Captured" }) end)
  end
end)

Types

export type input_context_t = {
	widget: any_widget_t,
	index: number?,
	item: any?,
	frac: number?,
	steps: number?,
	button: mouse_button_t?,
}
export type mouse_button_t = "left" | "right" | "middle"

What every input handler receives. index and item inside a repeater item; frac on a seek; steps on a wheel notch; button on the click props.

export type input_handler_t = (ctx: input_context_t) -> ()
export type change_handler_t = (text: string, ctx: input_context_t) -> ()

The type of on_click, on_right, on_middle, on_scroll and on_seek; and of a ui.field’s on_change.

export type input_props_t = {
	on_click: input_handler_t?,
	on_right: input_handler_t?,
	on_middle: input_handler_t?,
	on_scroll: input_handler_t?,
	hover: enum_t<"hover">?,
	hover_show: boolean?,
	hover_hide: boolean?,
}

The input props every node accepts; node_props_t includes them.

export type hotkey_t = { ctrl: boolean?, shift: boolean?, alt: boolean?, win: boolean?, key: enum_t<"key"> }
export type hotkey_handle_t = { on_press: signal_t<>, release: (self: hotkey_handle_t) -> () }
declare key: { [string]: enum_t<"key"> }

A shortcut, the handle register returns, and the keyboard enum.

esc
Type to search
navigate open