Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Input

Pointer and keyboard input: clicks, drags and the wheel on any node, hover, scrolling containers, keyboard activation, and typing into a textfield, including password fields whose keys never reach Lua. A handler usually writes a named state, and the next pass shows the result.

local clicks = state("clicks", 0)

return panel {
    id = "bar",
    layer = "top",
    keyboard_interactivity = "on_demand",
    anchor = { top = true },
    child = rect {
        padding = 8,
        background = "#313244",
        accessible_name = "Increase clicks",
        on_click = function(rect, which)
            if which == "left" then clicks:set(clicks:get() + 1) end
        end,
        children = { text { content = clicks:map(function(n) return "Clicked " .. n end) } },
    },
}

Hit testing

Every pointer event asks which nodes lie under the pointer, from the surface down.

RuleDetail
TransformsA node is hit where it is painted, after scale, rotate and translate
StackingSiblings are asked topmost first: higher z, then later in declaration order
ClippingA point outside a node that clips (clip = "box" or "rounded", a mask, a scroll viewport, a surface) reaches none of its children; a child overflowing any other node is hit where it paints
Skippedvisible = false subtrees and nodes playing an exit. opacity = 0 is still hit
Pass-throughhittable = false skips a node and its descendants, so a decorative overlay does not swallow the click meant for what is under it. A descendant with hittable = true is hit again, and the skipped ancestor’s handlers, cursor and hover apply when it is. Hover, cursor, wheel and the surface input region skip it too
EdgesHalf-open: two nodes sharing an edge never both take it
RectsEvery rect argument and hover_rect value is the node’s surface-local { x, y, width, height } laid-out box, before transforms

Pointer

Any node takes clicks, drags and the wheel, hit-tested by its own box. For each event the innermost node with a handler for that event wins; a node without one is transparent, so a handle inside a draggable track leaves the track draggable, and a text with no handler passes the click to the row around it. A node with a handler or submits_secure = true shows the "pointer" cursor unless it sets its own cursor.

HandlerArgumentsContract
on_click(rect, button, pointer, modifiers)button is "left", "right", "middle", "back" or "forward"; pointer is { x, y } in the node’s own untransformed box (mapped back through its transforms and its ancestors’), unclampedFires on release over the same node that was pressed, with the same mouse button. Other mouse buttons are ignored. On a textfield only non-left buttons fire, and only the field’s own on_click, never an ancestor’s; a left press edits the field and a secure_submit field fires none. The press focuses the field and keeps a selection it lands in, so :cut() and :copy() act on it
on_double_click(rect, button, pointer, modifiers)As on_clickFires after on_click on the release completing a second click on the same node with the same button, within 400 ms and 4 px of the first click’s release. See below
on_press(rect, button, pointer, modifiers)As on_clickFires at press, for any of those buttons, before the release and so before on_click. A textfield calls only its own, for non-left buttons. See below
on_drag(rect, pointer, phase, modifiers, velocity)pointer is { x, y } in the node’s own untransformed box (mapped back through its transforms and its ancestors’), unclamped; phase is "start", "move" or "end"; velocity is { x, y } in surface px per secondLeft button only. See below
on_wheel(rect, steps, modifiers)steps is a number of wheel notchesVertical wheel only. See below
submits_secure = true—Sends the armed secure field on click, like Enter; works without on_click and runs before it

Click. A press arms the click and the release fires it. Leaving the node and coming back before release still clicks; the pointer leaving the surface cancels. The click also cancels if the node’s laid-out box moved between press and release, so give press feedback with scale or translate rather than width or margin. A textfield clicks only as the on_click row says. A link in a text (on_link) takes the click before any on_click, the text’s own included.

Double click. on_click fires for both clicks, then on_double_click for the second; a third click starts a new pair, so a triple click is one double and one single. The innermost node with on_double_click takes it, apart from on_click’s target: an icon with only on_double_click inside a row with on_click still lets each single click reach the row. The pair is the same node even if the first click moved or resized it. A left click on a textfield goes to the field and never doubles; its non-left clicks double on its own handler. Enter, Space and screen-reader activation never double-click, and a node with only on_double_click takes no keyboard focus. Wayland has no portable double-click setting, so the 400 ms and 4 px are fixed.

Modifiers. Each pointer handler takes modifiers after its own arguments (only on_drag’s velocity follows it), the { ctrl, shift, alt, super } booleans on_key reports, so a list can toggle on Ctrl-click or extend on Shift-click. Enter and Space activation report the keyboard’s modifiers too. Wayland tells a client the modifiers only while one of its surfaces has keyboard focus; otherwise all four read false.

local picked = state("picked", false)
return panel {
    id = "bar",
    layer = "top",
    anchor = { top = true },
    child = rect {
        padding = 8,
        background = picked:map(function(on) return on and "#89b4fa" or "#313244" end),
        on_click = function(_, _, _, modifiers)
            picked:set(modifiers.ctrl and not picked:get() or not modifiers.ctrl)
        end,
        children = { text { content = "Ctrl-click toggles" } },
    },
}

Press. on_press is the one place a callback can start a window move, resize or menu with toplevel(id): the compositor checks those requests against the press, and on_click fires on release, too late. An on_drag "start" works too. After the compositor takes the pointer it may send no release, so the engine ends the drag and the armed click; on_drag gets no "end" and on_click does not fire.

Drag. A left press on an on_drag node calls "start" at once, so clicking a slider track also seeks. Every pointer motion on that surface then calls "move", wherever the pointer is. "end" comes on the left release, when the pointer leaves the surface, or when the surface closes. rect stays the box from the press for the whole drag, but pointer is measured against the node’s current box, so a node that moves mid-drag shifts it. On release, "end" fires first and the click (if the node also has on_click) after it; a leave ends the drag and cancels the click.

velocity is how fast the pointer moved over the last 100 ms, in surface logical px per second, timed by the compositor’s event clock rather than when the shell got round to the event. It is { x = 0, y = 0 } on "start", and on "end" when the pointer was still for 100 ms before the release. Motion spanning under 8 ms keeps the last measured value, so a 1 px twitch after a rest does not read as a flick. A drag that ends without a release (the pointer leaving the surface, the surface closing, a compositor move) reports the last motion’s velocity, so a flick off the edge keeps its speed. The velocity on "end" is what a flick scroll keeps going with. mantle input drag reports zero.

Wheel. steps is positive away from the user (scroll up) and negative toward. One notch is 1; high-resolution wheels send fractions of a notch, and touchpads send distance divided by one notch’s 39 px. Horizontal motion never reaches on_wheel. The innermost on_wheel node or scroll container under the pointer takes the whole event, with no chaining to a parent; on a node that is both, the scroll wins.

local level = state("brightness", 0.5)
local function clamp(value) return math.max(0, math.min(1, value)) end
local percent = level:map(function(value) return string.format("%d%%", math.floor(value * 100 + 0.5)) end)

return panel {
    id = "bar",
    layer = "top",
    anchor = { top = true },
    child = row {
        padding = 14,
        spacing = 12,
        radius = 12,
        background = "#1e1e2e",
        children = {
            icon { name = "display-brightness-symbolic", size = 18, foreground = "#f9e2af", align_v = "center" },
            rect {
                width = 200,
                height = 10,
                radius = 5,
                align_v = "center",
                clip = "rounded",
                background = "#45475a",
                -- A press is "start", so clicking the track also seeks.
                on_drag = function(rect, pointer, phase) level:set(clamp(pointer.x / rect.width)) end,
                on_wheel = function(_, steps) level:set(clamp(level:get() + steps * 0.05)) end,
                children = { rect { height = "fill", background = "#f9e2af", width = percent } },
            },
            text { content = percent, width = 36, foreground = "#cdd6f4", align_v = "center" },
        },
    },
}

Hover

hover(name) returns a read-only boolean signal, false until the pointer first arrives. Bind it to a node’s hover and the engine writes it as the pointer moves; read the same signal anywhere else to react. The name is the identity: every hover("wifi") call returns the same signal, and it survives reloads.

APIContract
hover = hover(name)Any node kind. true while the pointer is over the node or any of its children (hit-tested, so clipping and stacking apply). Pointer leaving the surface, or the surface closing, turns every hover off. When layout moves nodes under a still pointer, hover follows
on_hover(inside)Called with true/false on each crossing caused by the pointer. Layout moving a node under or off a still pointer updates hover but does not call it; the next pointer motion does. Refused unless the same node has hover
hover_rect(name)Read-only signal of the node’s rect from the last time its hover turned on. It keeps that rect after the pointer leaves, reads { x = 0, y = 0, width = 1, height = 1 } before the first hover, and is updated before on_hover runs. Use it as a tooltip popup’s anchor_rect
cursorThe pointer shape over a node: one of the cursor names, defaults in common properties. The innermost node that sets one wins, and an explicit one beats a kind’s default
local over = hover("wifi")

return panel {
    id = "bar",
    layer = "top",
    anchor = { top = true },
    child = rect {
        padding = 6,
        radius = 6,
        hover = over,
        background = over:map(function(on) return on and "#45475a" or "#313244" end),
        on_hover = function(inside) log.debug("wifi hovered:", inside) end,
        on_click = function() process.detach("nm-connection-editor", {}) end,
        children = { icon { name = "network-wireless-symbolic", size = 16 } },
    },
}

Pointer position

pointer(name) returns a read-only signal of the pointer’s { x, y } in logical pixels from the top-left corner of one node, or nil while the pointer is outside it. Bind it to a node’s pointer; the name is the identity, as for hover.

APIContract
pointer = pointer(name)Any node kind. A table while the pointer is over the node or any of its children (the same rule as hover), nil otherwise, including after it leaves the surface. Coordinates are in the node’s own untransformed space and unclamped, like on_click’s pointer. Updated once per motion batch and when layout moves the node under a still pointer
CostMotion writes it only while something reads it. A signal nothing reads costs the hit walk the pointer already does; a read one re-resolves only the instances that read it. One output’s instance holds the position while the pointer is on it, as the name is one signal across outputs

A pointer read only inside a callback (on_click, on_press, on_key) is always nil: nothing read it, so motion never wrote it. Bind it to a node’s pointer, or read it in a computed that a node reads.

local at = pointer("canvas")

return panel {
    id = "bar",
    layer = "top",
    anchor = { top = true },
    child = rect {
        width = 200,
        height = 60,
        pointer = at,
        children = {
            rect {
                width = 8,
                height = 8,
                radius = 4,
                background = "#f5c2e7",
                visible = at:map(function(p) return p ~= nil end),
                translate = at:map(function(p) return { x = p and p.x - 4 or 0, y = p and p.y - 4 or 0 } end),
            },
        },
    },
}

Scroll

scroll(name) returns a read-only signal holding a scroll offset in px, 0 at first. Bind it to the scroll property of a row, column or list and the wheel moves that container’s children. Like hover, the name is the identity and survives reloads.

RuleDetail
AxisA column or vertical list scrolls with the vertical wheel, a row or horizontal list with the horizontal one only
DistanceOne wheel notch is 39 px; a touchpad scrolls the distance it reports
BoundThe offset stays within [0, content − viewport]: the wheel stops at the ends the last layout measured, and layout clamps again and writes the clamped value back. A map of it that read an offset layout then moved (content that shrank, or a :reveal or :scroll_to) lays out again before the frame is drawn. The container needs a bounded size on its axis (fixed, "fill" or max_*); one sized by its content has nothing to scroll
CostWhile only scroll properties read the signal, the wheel moves the laid-out children without a layout pass. A map or :get() of it, or a scroll inside a list item, costs a pass per wheel event, or per frame while an eased notch runs
InertiaFingers lifting from a touchpad coast to a stop in 0.75 s; no animate needed. Wheel notches don’t
Smoothanimate = { scroll = 160 } on the container eases each wheel notch: notches add to a target that stops at the ends, and the offset follows it frame by frame. The signal holds the offset on screen, so a map of it lays out each frame from what that frame draws. :reveal, :scroll_to and :scroll_by ease too; a touchpad, a high-resolution wheel and reset_on_close move it at once. See animation
:reveal(index)On the next pass, scrolls the least distance that shows the index-th visible child (1-based; a list’s items in source order). An index past the end does nothing; below 1 raises. Only a scroll signal has it
:scroll_to(offset)On the next pass, moves to offset px, clamped to the bound. NaN and infinity raise. Only a scroll signal has it
:scroll_by(delta)Like :scroll_to, but adds delta to where the offset is headed, as a wheel notch does: during an eased run the signal reads the offset on screen, so :scroll_to(s:get() + step) would fall short, while clicks of :scroll_by(step) add up. A request waits for its container to be shown, and sums with an earlier one in the same turn before clamping (:scroll_to(900) then :scroll_by(-50) asks for 850). reset_on_close drops it, and so does the next pass when no container in any surface holds the signal (a hidden one still does), so an area built later starts at the top; a request made in the same turn as the area it scrolls lands. A container that is leaving (playing its exit) does not hold it

Arrow buttons for a carousel; strip:scroll_to(0) would rewind it:

local strip = scroll("carousel")
local cards = {}
for i = 1, 12 do cards[i] = rect { width = 120, height = 80, radius = 8, background = "#313244" } end
local function arrow(label, step)
    return rect {
        padding = 6,
        accessible_name = label,
        on_click = function() strip:scroll_by(step) end,
        children = { text { content = label } },
    }
end

return panel {
    id = "carousel",
    layer = "top",
    child = row { spacing = 8, children = {
        arrow("<", -128),
        row { width = 376, spacing = 8, scroll = strip, animate = { scroll = 200 }, children = cards },
        arrow(">", 128),
    } },
}

Keyboard controls and accessibility

A node with on_click, submits_secure = true, on_key or on_key_release becomes a keyboard control when it has a nonempty accessible_name. The name is announced through AT-SPI. Give every textfield an accessible_name too; fields take focus through their existing callbacks or secure_submit. Keys reach controls only on the surface with keyboard focus. Panels use keyboard_interactivity = "on_demand" or "exclusive"; shown popups under that surface share its focus scope.

Tab moves to the next control in document order across the focused surface and its shown popups. Shift+Tab moves backward, and both wrap. Hidden, leaving and zero-size controls are skipped. Tab on the only control, while it holds focus, moves nothing, shows the outline and reaches on_key. Enter or Space activates a named control once per press, calling on_click(rect, "left", pointer) with pointer at the node’s centre, and running submits_secure when set. Pointer presses also focus named controls.

A panel, window or popup takes on_escape() for dismissing without a field. It fires once per Escape press (not on repeat) on the innermost shown popup under the focused surface that declares it (no order is promised among sibling popups), else the surface itself, and never on a surface without keyboard focus. A focused field keeps its own Escape while it has text to clear (or a composition) or declares on_cancel; otherwise Escape reaches on_escape, so a launcher can clear on the first Escape and close on the second.

local open = state("menu_open", true)

return panel {
    id = "menu",
    layer = "overlay",
    anchor = { top = true },
    keyboard_interactivity = "on_demand",
    visible = open,
    on_escape = function() open:set(false) end,
    child = text { content = "Menu" },
}

Key handlers

on_key(key) on any node or surface hears the keys of a custom control: a grid, a menu, a slider. key is { name, text, modifiers = { ctrl, shift, alt, super }, repeat }.

FieldMeaning
nameThe xkb keysym name for the key under the active layout: "a" ("A" with Shift), "Return", "Escape", "space", "Down", "Page_Down", "F5", "KP_Enter". on_key names are case-sensitive. mantle input key takes the same names in any case
textWhat the key types, nil when it types nothing: Return, arrows, F-keys and every Ctrl chord
modifiersWhich of Ctrl, Shift, Alt and Super are held. mantle input key sets Ctrl and Shift only
repeattrue for an auto-repeat while the key is held, false for the press

A key goes to the focused node first, then each ancestor up to the surface, which hears it last whether or not it holds a control. A handler that returns true stops it; returning nothing or false passes it up. A node without on_key is skipped. Without focus on a control, only the surface’s own on_key hears keys, while the surface has the keyboard. A control’s on_key runs before its Enter/Space activation, and a handled Escape never reaches on_escape.

A node with on_key takes Tab focus, a press and the outline like any named control, but only with an accessible_name, so a screen reader can announce it. Without one it is not focusable, and hears just what its descendants pass up. That is how a container sees the keys of the field inside it.

A focused plain textfield takes the keys it edits with (characters, Backspace, Delete, Enter, caret motion, Ctrl+A/Z/Y, and Escape, unless escape = "pass" or there is nothing to clear and no on_cancel) and passes the rest up: Up, Down, paging, Tab when the field is the only control, an arrow at the caret’s edge, F-keys and other Ctrl chords (Ctrl+C, Ctrl+X and Ctrl+V copy, cut and paste while a field takes them, and reach on_key otherwise). Tab moves control focus when it can, so on_key hears Tab only where nothing moves. Repeats arrive with repeat = true.

A key typed into a secure_submit field never reaches on_key, and while one is armed no on_key on that surface is called.

on_key_release(key) hears a key going up, routed the same way: focused node first, then each ancestor, then the surface; true stops it. key is the press’s name and text with the modifiers held at release, and no repeat. A release is heard only when its press was offered to on_key, whether or not a handler took it, and goes to the node that press was routed from even if the press moved focus: never for a key a field typed or pasted with, a Tab that moved focus, a key typed into an armed secure_submit field, a bare modifier, a key already held when the keyboard entered, or after the keyboard left. Like on_key, it makes a node focusable only with an accessible_name. mantle input key sends the release right after the press.

local picked = state("picked", 1)

return panel {
    id = "grid",
    layer = "top",
    keyboard_interactivity = "on_demand",
    child = row {
        accessible_name = "Picker",
        on_key = function(key)
            if key.name == "Right" and not key.modifiers.ctrl then
                picked:set(picked:get() + 1)
                return true
            elseif key.name == "Left" then
                picked:set(math.max(1, picked:get() - 1))
                return true
            end
        end,
        children = { text { content = picked:map(tostring) } },
    },
}

The engine draws a black and white outline around the focused control only when Tab, Shift+Tab or an assistive-technology action moved focus there. Focus from a press, autofocus or focus_target(name):request() from a click draws none, and a press hides an outline Tab drew. focus_ring = false keeps the outline off a node. The outline follows the node’s radius, corner_smoothing and shape.

focused(name) returns a read-only boolean signal, false until the bound node first holds focus. Bind it to a node’s focused and the engine sets it true while that node or any node inside it holds control focus, however focus got there. Like hover(name), the name is the identity and survives reloads. It is not focus_target(name), the handle :request() uses to move focus.

focus_visible(name) is the same signal under the rule the engine’s outline follows (CSS :focus-visible): true only while the bound node or one inside it holds control focus that Tab, Shift+Tab, an assistive-technology action or a :request() from a key callback or an Enter or Space activation moved there, and false after a press, autofocus or a :request() from a click, while focused(name) stays true. It ignores focus_ring. Bind it to a node’s focus_visible, and draw a ring that shows for keyboard users only:

local search = focused("search")

return panel {
    id = "launcher",
    layer = "top",
    keyboard_interactivity = "on_demand",
    child = rect {
        padding = 6,
        radius = 8,
        border_width = 2,
        focused = search,
        border_color = search:map(function(on) return on and "#89b4fa" or "#45475a" end),
        children = {
            textfield {
                width = 240,
                height = 28,
                autofocus = true,
                focus_ring = false,
                accessible_name = "Search",
                on_change = function(text) end,
            },
        },
    },
}
local ring = focus_visible("save")

return panel {
    id = "dialog",
    layer = "top",
    keyboard_interactivity = "on_demand",
    child = rect {
        padding = 4,
        radius = 8,
        border_width = 2,
        focus_visible = ring,
        border_color = ring:map(function(on) return on and "#89b4fa" or "#00000000" end),
        children = {
            rect {
                padding = 6,
                background = "#45475a",
                accessible_name = "Save",
                focus_ring = false,
                on_click = function() end,
                children = { text { content = "Save" } },
            },
        },
    },
}

Mantle publishes the resolved node tree through AT-SPI. Text nodes expose their displayed text; plain fields expose their draft. Secure fields expose a password role but no value or length. Assistive-technology focus and click actions use the same control path as keyboard input. A screen reader can also click a button on a surface without keyboard focus; the keyboard stays where it is.

Text fields

A textfield is a text input, one line unless multiline. The engine holds what the user types (the draft); Lua sees it through callbacks, and sets it only with :set_text. The field with focus is the one keys go to. It has no intrinsic width, so give it width; height defaults to one line (nodes).

A field takes the keyboard only when both hold:

ConditionDetail
The surface has keyboard focusA panel needs keyboard_interactivity = "on_demand" or "exclusive" (keyboard focus); a popup shown under the focused surface shares its keys
The field can use keysIt has secure_submit, on_change or on_submit. A field with none of them (even with on_cancel) never takes focus, and a press on it acts like a press on empty space

A press on the field focuses it and puts the caret under the pointer. autofocus focuses it without a press.

PropertyContract
on_change(text)Every edit that changes the text, with the whole draft. Caret moves call nothing
on_submit(text)Enter, with the whole draft (possibly ""). The draft then clears and on_change("") follows; the field keeps focus. A held Enter does not repeat
on_cancel(cleared)Escape. The draft clears, the field drops focus, on_change("") fires if there was text, then on_cancel gets whether text was removed. Without on_cancel, Escape clears and the field keeps focus
on_key(key)The keys the field does not edit with, from its own on_key up (key handlers): Up, Down, paging, Left and Right when the caret cannot move that way and Shift is up, and Tab when the field is the only control. The draft is untouched
escapeWhat Escape does: "clear" (the default) as under on_cancel; "blur" keeps the draft and drops focus, then calls on_cancel(false); "pass" keeps the draft and focus and does not take the key, which goes up through on_key and then to the surface’s on_escape. A secure_submit field ignores it
autofocustrue: take the keys, with the draft reset to initial_text ("" when unset) and a call to on_change with it, when the surface gains keyboard focus or the field appears under it. The first visible such field or focusable control in document order wins. A field never takes over from one already typing, nor re-takes one the user just clicked away from. A control arms only while no control holds focus, once per appearance or keyboard enter
focus_targetA focus_target(name) handle. :request() from an on_click, on_key, or an edit typed or committed into the field (on_change, on_submit, on_cancel) focuses the first visible plain field with that name on the same keyboard-focused surface or a popup under it, after the callback’s state changes appear. It keeps that field’s draft and caret and does not call on_change. A key callback’s request shows the focus outline
focus_target(name):set_text(text)Replaces the draft of every plain field with that focus_target and an on_change or on_submit, hidden ones too, from any callback, once it returns: caret at the end, undo history cleared, on_change not called, composition discarded. A field without focus keeps the text for when it takes the keys. Never reaches a secure_submit field. Control characters other than \n, or over 64 KiB, raise; a single-line field refuses a \n with a warning
focus_target(name):cut(), :copy(), :paste(), :select_all()The chords’ actions, on the plain field with that focus_target that holds the keyboard now; any other field, and any secure_submit, is left alone. They count only inside an on_click, on_key or edit callback caused by a real key press or pointer click, since the compositor wants that event’s serial for the clipboard; key repeat and mantle input key cannot cut or copy. They apply when the callback returns. A click moves keyboard focus off the field, so call :request() first in the same callback
initial_textSeeds the draft once, when the field enters the tree (a new node: a changed id or key counts as new, and a field that leaves and returns is seeded again from the value then). Plain fields only; secure_submit refuses it. Like set_text: cut at max_length, caret at the end, no undo history, no on_change; hidden and disabled fields are seeded too. Later changes to the value are ignored and an emptied field stays empty: use set_text to push new text. Read without subscribing: writing the signal alone does not re-resolve the field
disabledtrue: the field draws as usual but takes no focus. Tab skips it, a press and autofocus pass over it, :request() finds nothing, and no caret shows. A focused field that becomes disabled loses focus and keeps its draft. A disabled secure_submit field is not a destination. set_text still reaches it. Dim it by binding colours to the same signal
max_lengthMost grapheme clusters the field holds; 0 is unlimited. Typing, paste, IME commits and set_text cut the insert at the limit, secure fields included. Lowering it below the current text keeps that text: only the insert is limited, so edits can then only shorten it. The cut is silent: a limit below a password’s length truncates it, on a lock field too
secure_submit, mask_characterSee secure fields
placeholder, placeholder_color, font_size, foreground, text_alignAppearance; see textfield
KeyPlain fieldSecure field
TextInserts at the caret, replacing a selection; on_changeAppends
Enteron_submit, then clearsSends
EscapeClears; with on_cancel, also drops focusClears, stays armed, on_cancel
Backspace, DeleteOne character, or the selectionBackspace only
Ctrl+Backspace, Ctrl+DeleteOne wordNothing
Left, Right, Home, EndMove the caret; Ctrl+Left/Right by word; Shift selects. Left/Right with nowhere to go (and no Shift) pass up to on_keyNothing
Ctrl+ASelects allNothing
Ctrl+Z, Ctrl+Shift+Z, Ctrl+YUndo or redo the last plain edits; on_changeNothing
Ctrl+CCopies selected textNothing
Ctrl+XCopies selected text and deletes it as one undo step; on_change. Nothing without a selectionNothing
Ctrl+VReplaces selection with clipboard text; on_changeAppends clipboard text to the native buffer
Up, Down, Page Up, Page DownPass up to on_keyNothing
Multiline: Return, Up, Down, Home, EndReturn inserts a newline and submit_key submits; Up and Down move by visual row (from the first or last row they pass up to on_key); Home and End go to the row’s ends, Ctrl+Home and Ctrl+End to the text’sNever multiline
Tab, Shift+TabMoves between controls; when the field is the only control, passes up to on_key ("ISO_Left_Tab" for Shift+Tab)Moves between controls
Any other Ctrl chordLeft to the compositorSame

Selection and clipboard. Dragging or Shift+clicking with the pointer selects too. A double-click selects the word under the pointer (a space run or a single punctuation mark when it lands on one), and a triple-click the line: newline to newline in a multiline field, all the text in a single-line one. Presses within 400 ms and 4 logical pixels of the last on the same field form a series, and a fourth starts over as a single click. Dragging or Shift+clicking after a double or triple click grows the selection by whole words or lines. Paste accepts up to 64 KiB of valid UTF-8 without control characters (a multiline field also takes newlines; \r\n and \r become \n). A paste is dropped if the selection, field, or keyboard focus changes before the read ends. Copy and cut work only with a plain-field selection. has_selection(name) is a read-only boolean signal, false until the plain field whose focus_target is name holds the keyboard and has text selected. It survives reloads. Editing keys repeat while held; Escape, undo and redo do not. The undo stack retains at most 100 snapshots and 1 MiB of saved text. Submit, leaving the field, and fresh autofocus clear that history. When text-input-v3 enters the field’s own surface, composition appears underlined. Preedit alone does not call on_change; committed text and surrounding deletions do. Raw typing resumes when composition ends. A pointer press or Escape discards uncommitted composition. Secure fields do not use an IME.

Draft lifetime. Each field keeps its own draft. Clicking elsewhere, moving to another field, or the surface losing the keyboard stops typing but keeps the draft; focusing the field again resumes it, though a click that returns to a field puts the caret where it was left. Undo history does not survive leaving a field. Enter and Escape clear it. An autofocus arm starts it empty. It is dropped when the field’s node leaves the tree or its surface closes. Restoring a draft is silent: on_change does not fire, so reset a field with set_text.

To return typing to a field after a click changes the view, give the field the handle and call :request() from the on_click. focus_target("") and a focus_target property that is not a handle raise. on_key, and edits typed or committed into a field (on_change, on_submit, on_cancel), can request too; the on_change an autofocus arm or set_text fires cannot. Requests from anywhere else, to a hidden or masked field, or to a surface other than the focused one and its popups do nothing.

local search_focus = focus_target("search")

return panel {
    id = "search",
    layer = "top",
    keyboard_interactivity = "on_demand",
    child = row { children = {
        textfield {
            focus_target = search_focus,
            autofocus = true,
            width = 180,
            height = 32,
            on_change = function() end,
        },
        rect {
            on_click = function() search_focus:request() end,
            children = { text { content = "Return to search" } },
        },
    } },
}

A Cut button that shows whether it can act:

local note = focus_target("note")
local selected = has_selection("note")

return panel {
    id = "note",
    layer = "top",
    keyboard_interactivity = "on_demand",
    child = row { children = {
        textfield { focus_target = note, width = 180, height = 32, on_change = function() end },
        rect {
            on_click = function()
                note:request()
                note:cut()
            end,
            children = { text { content = selected:map(function(on) return on and "Cut" or "Nothing selected" end) } },
        },
    } },
}

A right-click menu on a field. The press focuses the field without clearing a selection it lands in, so :cut() acts on it:

local note = focus_target("note")
local menu = state("note_menu", false)
return panel {
    id = "note",
    layer = "top",
    keyboard_interactivity = "on_demand",
    child = column { children = {
        textfield {
            focus_target = note,
            width = 180,
            height = 32,
            on_change = function() end,
            on_click = function(_, button)
                if button == "right" then menu:set(true) end
            end,
        },
        row {
            visible = menu,
            children = {
                rect {
                    on_click = function() note:request(); note:cut(); menu:set(false) end,
                    children = { text { content = "Cut" } },
                },
            },
        },
    } },
}

focus_target and autofocus also work on any focusable control. A request from a key callback shows the outline, so Lua can rove focus with the arrow keys. On a node that takes no focus they do nothing, and set_text does nothing on a control.

local next_button = focus_target("next")

return panel {
    id = "toolbar",
    layer = "top",
    keyboard_interactivity = "on_demand",
    child = row { children = {
        rect {
            accessible_name = "First",
            autofocus = true,
            width = 40,
            height = 32,
            on_key = function(key)
                if key.name == "Right" then next_button:request() return true end
            end,
        },
        rect { accessible_name = "Next", focus_target = next_button, width = 40, height = 32, on_key = function() end },
    } },
}

A search field that picks from a list moves the selection with on_key and closes on a second Escape. The app launcher recipe is the full version.

local query = state("query", "")
local selected = state("selected", 1)
local open = state("launcher_open", true)

return textfield {
    width = 240,
    height = 32,
    placeholder = "Search apps",
    autofocus = true,
    on_change = function(text)
        query:set(text)
        selected:set(1)
    end,
    on_key = function(key)
        local step = ({ Up = -1, Down = 1 })[key.name]
        if step then selected:set(math.max(1, selected:get() + step)) end
    end,
    -- First Escape clears the text; a second one, on an empty field, closes.
    on_cancel = function(cleared)
        if not cleared then open:set(false) end
    end,
}

Secure fields

secure_submit = { capability, action } turns a textfield into a password field. Its keys go to a native buffer and leave the Renderer as one message to that target; no Lua value ever holds the secret or its length.

TargetEffect
{ capability = "lock", action = "authenticate" }Checked by PAM; success unlocks the session. A lock surface needs exactly one reachable field with this target (lock)
{ capability = "polkit", action = "authenticate" }Answers the current polkit request (polkit)
{ capability = "network", action = "connect" }The password for the network being joined (network)
{ capability = "network", action = "vpn_secret", name = request.id .. "/" .. field }One secret of a VPN activation’s pending request (network)
{ capability = "bluetooth", action = "pair", name = request.id .. "/" .. request.mac }Answers a Bluetooth PIN or passkey entry request (bluetooth)
{ capability = "secrets", action = "store", name = "mail" }Stores a named value in the session Secret Service (secrets)
Any other pairRefused when the field is laid out, so no password is typed into nowhere
RuleDetail
ArmingWhen the surface gains keyboard focus, the sole visible secure field in it (and in popups shown under it) is armed with no click. With two or more, a press picks one. A field revealed later under existing focus arms if none is armed
KeysTyped text appends, Backspace removes one character, Escape clears the buffer, stays armed and calls the field’s on_cancel(cleared). There is no caret or selection, and no on_key; on_change and on_submit never fire
SendingEnter, or a click on a submits_secure = true node, sends the buffer and wipes it. An empty buffer is sent only to network/connect, where it joins an open network
FocusA click on anything but a field keeps the field armed, so a submits_secure = true node works. Tab to a named button keeps the buffer too: typing stops, Enter on a submits_secure = true button sends it, and Escape still clears it and calls on_cancel. Focusing another field, plain or secure, or the keyboard leaving the surface, disarms it and wipes the buffer
PriorityWhile a secure field is armed, plain fields in the same focus take no keys
on_escapeA surface’s on_escape fires when the buffer is empty and the field has no on_cancel, so it reveals that one bit (ADR-0320)
mask_characterDrawn once per typed character. Default "•"; only the first character counts; "" draws nothing and hides the length. Only secure fields draw it. An empty field shows its placeholder
maskedfalse draws the typed text instead of the mask, for a PAM prompt meant to be seen (prompt.echo). Keys, sending and the no-IME rule stay the same, and the text still never reaches Lua. Turning it off wipes what was typed under the mask, so a password typed early is never shown
return lock {
    id = "lock",
    child = column {
        width = "fill",
        height = "fill",
        align_h = "center",
        align_v = "center",
        spacing = 12,
        background = "#11111b",
        children = {
            textfield {
                width = 280,
                height = 40,
                placeholder = "Password",
                accessible_name = "Password",
                mask_character = "•",
                secure_submit = { capability = "lock", action = "authenticate" },
            },
            rect {
                padding = 8,
                radius = 8,
                background = "#89b4fa",
                submits_secure = true,
                accessible_name = "Unlock",
                children = { text { content = "Unlock", foreground = "#11111b" } },
            },
        },
    },
}

How do I…

TaskAnswer
Make a sliderThe example under pointer
Move a selection through a list with the arrow keyson_key on the field (key handlers); the app launcher adds scroll(name):reveal
Close a search box on a second EscapeThe same example: on_cancel(cleared) closes only when cleared is false
Let Escape reach the surface from a fieldescape = "pass" on the textfield
Handle arrows or shortcuts on a custom controlon_key (key handlers)
Close a dialog or menu on Escapeon_escape on the surface (keyboard controls)
Ask for a passwordThe lock example under secure fields
Reach a button by keyboard or screen readerGive the node with on_click or submits_secure an accessible_name (keyboard controls)
Hide or restyle the focus outlinefocus_ring = false on the control, and style a wrapper from focus_visible(name), or focused(name) to track any focus (keyboard controls)
Show a tooltip on hoverTooltip, with hover_rect as the anchor
Open a menu on right clickBelow
Reorder a list by draggingBelow

Right-click menu. on_click reports the mouse button and the node’s rect, which is what a popup’s anchor_rect wants. The click is a pointer release, so the popup may take its grab. The menu hangs from the node, not the click point: no handler reports the pointer position of a click.

local menu_open = state("context_open", false)
local menu_at = state("context_at", { x = 0, y = 0, width = 1, height = 1 })

local function item(label, run)
    return rect {
        width = "fill",
        padding = 8,
        radius = 6,
        on_click = function()
            menu_open:set(false)
            run()
        end,
        children = { text { content = label, foreground = "#cdd6f4" } },
    }
end

local files = row {
    padding = 8,
    spacing = 8,
    radius = 8,
    background = "#1e1e2e",
    on_click = function(rect, which)
        if which == "right" then
            menu_at:set(rect)
            menu_open:set(true)
        else
            process.detach("nautilus", {})
        end
    end,
    children = {
        icon { name = "folder", size = 18, align_v = "center" },
        text { content = "Files", foreground = "#cdd6f4", align_v = "center" },
    },
}

return {
    panel { id = "bar", layer = "top", anchor = { top = true }, child = files },
    popup {
        id = "files_menu",
        parent = "bar",
        anchor_rect = menu_at,
        anchor = "bottom",
        gravity = "bottom_right",
        offset = { y = 4 },
        visible = menu_open,
        on_dismiss = function() menu_open:set(false) end,
        child = column {
            width = 160,
            padding = 4,
            radius = 10,
            background = "#1e1e2e",
            border_width = 1,
            border_color = "#45475a",
            children = {
                item("New window", function() process.detach("nautilus", { "--new-window" }) end),
                item("Downloads", function() process.detach("nautilus", { os.getenv("HOME") .. "/Downloads" }) end),
            },
        },
    },
}

Drag to reorder. Each row is an on_drag node in a keyed list. pointer is measured against the row’s current slot, so pointer.y divided by the row pitch counts the rows to move from where it is now; offsetting from the slot at the press would undo each move on the next motion. The dragged row jumps slot by slot rather than following the pointer; to make it follow, also bind its translate to the drag offset. There is no drag-and-drop between surfaces or applications, and no drag image.

local items = state("order", { "Music", "Mail", "Files", "Terminal" })
local STEP = 32 -- row height 28 + spacing 4

local function index_of(name)
    for index, other in ipairs(items:get()) do
        if other == name then return index end
    end
end

local function row_for(name)
    return rect {
        width = 200,
        height = 28,
        padding = 6,
        background = "#313244",
        cursor = "grab",
        on_drag = function(_, pointer, phase)
            if phase ~= "move" then return end
            -- `pointer` is relative to the row's current slot, so the offset counts rows from there.
            local order = { table.unpack(items:get()) }
            local now = index_of(name)
            local target = math.max(1, math.min(#order, now + math.floor(pointer.y / STEP)))
            if target ~= now then
                table.insert(order, target, table.remove(order, now))
                items:set(order)
            end
        end,
        children = { text { content = name } },
    }
end

return panel {
    id = "bar",
    layer = "top",
    anchor = { top = true },
    child = list { spacing = 4, items = items, each = row_for, key = function(name) return name end },
}

Gotchas

TrapFix
A textfield is invisible or cannot be clickedIt has no intrinsic width. Give it width
A field in a panel shows no caret and takes no keysSet the panel’s keyboard_interactivity to "on_demand" (or "exclusive" for a modal)
A field with only on_key/on_cancel ignores clicksAdd on_change or on_submit
Tab skips a button, or Tab does nothingGive the button an accessible_name, and the panel a keyboard_interactivity other than "none"
A clicked or autofocus field shows no focus outlineThe outline follows keyboard navigation only. Bind focus_visible(name) to draw your own ring by the same rule, or focused(name) for a style that tracks any focus
Tab stopped reaching on_keyWith two or more controls in the focus scope, Tab moves focus instead. It reaches on_key only when the focused node is the sole control
The mouse wheel does nothing over a scrolling rowRows scroll on the horizontal axis. Use a column, or an on_wheel on a node around it that moves the row
A scroll container never scrollsBound its size on the scroll axis; content-sized means nothing overflows
on_hover is refusedAdd hover = hover("name") on the same node
A container’s hover stays on while the pointer is over a childHover covers the whole subtree. Give the child its own hover for innermost-only behaviour
A click is lost when the node grows on pressThe release must land on the same laid-out box. Animate scale instead
A tooltip or menu anchored to a scaled node is offRects are laid-out boxes before transforms. Anchor on an untransformed parent
Lua needs to prefill or clear a fieldCall focus_target(name):set_text(text); "" clears. autofocus also re-arms empty, and Escape and Enter clear
A typed password shows up in on_changeIt cannot: a secure_submit field never calls it. Plain fields also stop taking keys while a secure field is armed

See also: nodes (textfield, list), surfaces (keyboard_interactivity, popups, lock), signals (state the handlers write), animation (press and hover motion), capabilities (lock, polkit, network).

Source: pointer, press and click, callbacks, wheel, keyboard, text fields, secure fields, hit testing, hover, scroll.