Skip to content

Declarative UI

Desktop widgets, panels, and (optionally) bar widgets do not have to patch a fixed control. They can describe their UI as a retained ui.* tree; Noctalia diffs each render against the previous tree and updates native controls in place.

desktopWidget.* - declarative desktop widget UI

Section titled “desktopWidget.* - declarative desktop widget UI”

A desktop widget hands its tree to desktopWidget.render(tree). Build trees with the ui.* constructors (available in every desktop-widget script); each takes a props table and an optional children array:

render(tree) · setWantsSecondTicks(bool) · setNeedsFrameTick(bool)

local color = noctalia.getConfig("color")
function update()
desktopWidget.setWantsSecondTicks(true) -- tick update() on second boundaries
desktopWidget.render(ui.column({ gap = 10, align = "center" }, {
ui.label({ text = os.date("%H:%M:%S"), fontSize = 32, fontWeight = "bold", color = color }),
ui.row({ gap = 8 }, {
ui.button({ text = "Ping", variant = "primary", onClick = "ping" }),
}),
}))
end
function ping()
noctalia.notify("Desktop widget", "button clicked")
end

The available controls and their props (sizes are logical px, scaled with the surface). Desktop widgets and panels share this vocabulary - panels additionally use the interactive controls below (ui.input, ui.select, etc.).

ui.label and ui.glyph inherit the host’s text defaults when a prop is unset: in a bar widget they use the bar’s (or widget’s) font_family/font_weight and the widget scale, matching the imperative barWidget.setText look. There, labels without color also use the widget foreground and glyphs without color use the widget icon color; in panels and desktop widgets an unset color stays on_surface. An explicit fontSize/size/fontWeight/fontFamily/color prop overrides the default. In bar widgets, ui.button renders compact: it hugs its content instead of using the taller settings-panel control height (pass width/height or controlSize to size it explicitly).

ConstructorProps
ui.column / ui.rowgap, padding, paddingH, paddingV, align (start/center/end/stretch), justify (start/center/end/space_between), fill, radius, border, borderWidth, minWidth, minHeight, onClick, onHover, tooltip
ui.scrolla vertically scrolling container; same layout props as a column plus fill, radius, border, borderWidth, stickToBottom (bool), onScroll, scrollToBottomRev (number - see below), revealKey (string - see below)
ui.labeltext, fontSize, color, fontWeight (thin…heavy), fontFamily (a family name; load a file with noctalia.loadFont), baseline (text/textFixedHeight/inkCentered/pictographic), maxWidth, maxLines, textAlign
ui.markdowntext (markdown source - see below), width, height
ui.glyphname (Tabler/Nerd-Font glyph), size, color
ui.imagepath (plugin-relative, ~, or absolute), width, height, radius, fit (contain/cover/stretch), border, borderWidth, onClick, onHover, tooltip
ui.pixelStreampath (companion socket), width, height, radius, and pointer and keyboard callbacks; see Pixel streams
ui.boxfill, radius, border, borderWidth, softness, width, height, onClick, onHover, tooltip
ui.separatorthickness, color, spacing, orientation (auto/horizontal/vertical)
ui.spacerflexible filler (use flexGrow)
ui.progressprogress (0–1), fill, track, marker (0–1 reference line along the track, absent = hidden; it overhangs the track slightly, so leave room if the parent clips), markerColor, radius, width, height
ui.buttontext, glyph, fontSize, glyphSize, variant (default/primary/secondary/destructive/outline/ghost), contentAlign (start/center/end), controlSize (sm/md/lg), tooltip, enabled, selected, onClick, onRightClick, onHover
ui.graphvalues / values2 (arrays of 0–1 numbers), color / color2, lineWidth, fillOpacity, width, height, onPointerMove, onPointerLeave
ui.togglechecked (bool), enabled, onChange
ui.slidermin, max, step, value, controlSize (sm/md/lg), enabled, onChange, onDragEnd
ui.colorPickervalue, alpha, inputs, swatches, width, enabled, onChange, onChangeEnd; see Color picker
ui.selectoptions (array of strings), selectedIndex, placeholder, controlSize (sm/md/lg), enabled, width, height, onChange
ui.inputvalue (initial text only - see below), placeholder, fontSize, controlSize (sm/md/lg), password (bool), multiline (bool), submitOnEnter (bool - see below), frameVisible (bool - see below), focus (bool), enabled, onChange, onSubmit

Every control also accepts width, height, flexGrow, opacity, and visible. Colors are a palette role token (primary, on_surface, …), a role token with an alpha suffix (primary/0.6, the role at 60% alpha, resolved live against the palette so it still tracks the theme), or a hex value (#rrggbb / #rrggbbaa). An unknown control type or prop is logged and skipped - typos surface in the log instead of failing silently.

  • Translucent background, opaque content: opacity is a group opacity, it fades a container and all its children (text included), like CSS opacity on a parent. To make only the background semi-transparent, leave opacity alone and give the container a translucent fill instead: ui.column({ fill = "surface_container/0.6" }, …) keeps the text fully opaque.

  • Layout default: ui.column / ui.row / ui.scroll stretch their children across the cross axis (a column fills its width, a row fills its height), like CSS flexbox. Override per node with align - e.g. a row of vertically-centered items uses align = "center".

  • Control height: controlSize picks a named height tier - sm (32px), md (38px, the default), lg (44px) - scaled with the surface, so a row of small buttons, selects and inputs lines up without hardcoded pixels. Use it to fit more into a dense panel. For an exact height, use height instead; it wins when both are set. (Not to be confused with ui.glyph’s numeric size, which is a glyph size in px.)

  • Tooltips: ui.button accepts a tooltip string, shown on hover. ui.box, ui.row, ui.column, and ui.image also accept tooltip (requires plugin_api = 32), which wraps the node in a hover target so a plain container or glyph can carry one. Dropping the prop on a later render clears it. Tooltips appear in bar widgets and panels; desktop widgets do not show them.

  • Callbacks: a callback prop takes either a function (requires plugin_api = 9) or the name of a global function in your script. onClick fires on a button click; onChange fires when a toggle/slider/select/input value changes (the value is passed as a string - "true"/"false" for a toggle, a number for a slider, the text for an input, the index for a select); onSubmit fires when the user presses Enter in an input.

  • Closures: a function callback captures the surrounding scope, which is the simplest way to tell rows of a list apart - each row’s handler already knows which row it belongs to:

    for index, task in ipairs(tasks) do
    table.insert(rows, ui.button({
    key = task.id,
    text = task.title,
    onClick = function() completeTask(index) end,
    }))
    end

    Handlers belong to the render that produced them: re-rendering replaces them, and a click on a node the current tree no longer contains does nothing. Give repeated nodes a key so their handlers survive a re-render unchanged, the same identity keys already give the reconciler. A function callback receives the same string arguments a named one does, so onChange = function(value) ... end still reads "true"/"false" for a toggle.

  • Hover: onHover = "name" fires on pointer enter and leave for ui.row, ui.column, ui.box, ui.image, and ui.button. The handler receives two strings: the state ("true" on enter, "false" on leave) and the node’s key (empty when the node has none), so a single named handler can serve a whole keyed list (a closure captures the row it belongs to instead):

    -- track which chip is hovered, then re-render the tree
    function onChipHover(state, key)
    hovered = (state == "true") and key or nil
    end

    Only the innermost hovered element reports: if a container with onHover contains a button or another interactive child, hovering that child makes it the hover target and the container reports "false". Every "true" is matched by a "false", including when the hovered node disappears in a later render, so a hover flag never gets stuck on.

  • Graph pointer tracking: onPointerMove on ui.graph fires while the pointer is over the graph and receives the position normalized to the graph’s own box as two strings, "0.0000" to "1.0000", with 0,0 at the top-left, so normY grows downward. Map the x-position onto your own data: the graph does not know what its samples mean. onPointerLeave is independent and fires with no arguments.

    function onGraphMove(normX)
    hoveredIndex = math.floor(tonumber(normX) * #series) + 1
    render()
    end
    function onGraphLeave()
    hoveredIndex = nil
    render()
    end
    ui.graph({ values = series, onPointerMove = "onGraphMove", onPointerLeave = "onGraphLeave" })

    Positions are coalesced: a graph never has more than one pending pointer event, so a fast pointer cannot flood a slow script, and the newest event always wins. Both edges share that stream, so a leave can never arrive before a position it followed. Every entered graph reports its leave, including when the graph disappears from a later render or its callbacks are rewired, so a hover highlight never gets stuck on; that teardown leave needs a named handler, because a closure from the render that dropped the graph is already superseded. A graph with neither prop takes no pointer input at all; an empty callback name counts as unset. Requires plugin_api = 29.

  • Clickable containers: onClick on ui.row and ui.column makes the whole container a click target while keeping its layout and sizing, so an entire chip (glyph + label) activates as one unit rather than only a bare ui.box. The same applies to ui.image and ui.box (thumbnail grids, placeholder tiles). A clickable container joins the keyboard tab order and activates with Enter or Space. A container that declares only onHover and no onClick does not swallow clicks: they pass through to an enclosing click target, so a hover cue can wrap something that is already clickable. On these four, an empty onClick/onHover name counts as unset, so clearing the name drops the target instead of leaving a dead one behind.

  • Multiline input: ui.input with multiline = true becomes a wrapping text area with vertical scrolling, Enter inserts a newline and onSubmit fires on Ctrl+Enter instead. Give it a height or flexGrow for the editing area; multiline and password are mutually exclusive. submitOnEnter = true flips the multiline bindings for chat composers: Enter fires onSubmit, Shift+Enter inserts the newline, and Ctrl+Enter still submits. Requires plugin_api = 21.

  • Input frame: frameVisible = false hides the input’s native background and border while preserving text editing, selection, cursor, scrolling, and focus behavior. Use it when a parent surface provides the visual frame. The frame is visible by default; set frameVisible = true explicitly to restore it on a retained keyed input. Requires plugin_api = 27.

  • Markdown: ui.markdown renders its text prop (headings, lists, code blocks, tables) as a read-only block. The host re-parses only when text or the surface scale changes, so re-rendering a streaming tree with unchanged text is cheap. Requires plugin_api = 21.

  • Follow-scroll: ui.scroll with stickToBottom = true stays pinned to the bottom while content grows, until the user scrolls away. onScroll receives (offset, maxOffset), both delivered as strings. scrollToBottomRev jumps to the bottom on the first render that sees it and again whenever the number changes; the jump lands after the next layout pass, so it reaches content added in the same render. All three require plugin_api = 21.

  • Reveal a child: ui.scroll with revealKey = "hit-12" scrolls the descendant with that key into view by the smallest distance that shows it whole, and leaves the offset alone when it is already fully visible - what a keyboard selection in a long list needs. The reveal lands after the next layout pass, so it reaches a child added in the same render; a key no child carries yet waits for the render that adds one. It re-applies when the value changes or the child reappears, never on an unchanged re-render, so the user’s own scrolling is not undone. Requires plugin_api = 33.

    -- rows are keyed "hit-<index>"; onKey moves `selected` and re-renders
    ui.scroll({ flexGrow = 1, revealKey = "hit-" .. selected }, rows)

    A control inside a ui.scroll that gains keyboard focus (Tab, Shift+Tab) is scrolled into view by the host with no prop involved.

  • Focus: ui.input with focus = true grabs keyboard focus once, when the control is created, never on later re-renders, so it won’t steal focus while the user interacts elsewhere. To focus again (e.g. a new document in the same editor slot), give the input a fresh key. The seeded value places the caret at the end.

  • Controlled vs. uncontrolled: toggle/slider/select are value-driven - pass the current value on every render and update it from the callback. ui.input is uncontrolled: value seeds the field once, then the host owns the text as the user types - re-rendering never overwrites it. Read edits through onChange/onSubmit, and give the input a stable key so it keeps its text across renders.

  • Identity: give list children a key prop so reordering reuses the same native controls.

  • Images: ui.image loads local files only - download remote previews with noctalia.download(url, dest, cb) first, then pass the saved path.

  • Glyph-only buttons: when only glyph is set, any previous text is cleared so retained buttons do not keep stale labels across re-renders.

  • Ticks: setWantsSecondTicks(true) runs update() on second boundaries (clocks/timers). setNeedsFrameTick(true) additionally delivers onFrameTick(deltaMs) every frame for continuous animation - frames are coalesced, a slow script only ever sees the latest one.

  • Position is host-owned: the user places, sizes, and rotates the tile in the desktop-widgets editor; the script only renders content and reads its declared settings.

The reference implementation is the official noctalia/timer plugin - a countdown timer with start/pause/reset buttons and a progress bar.

barWidget.render - declarative bar widget UI

Section titled “barWidget.render - declarative bar widget UI”

A [[widget]] entry can render a ui.* tree with barWidget.render(tree) instead of the imperative setText/setGlyph/setImage patches - use it for composite content (multiple labels, glyph+text combinations, inline buttons). Simple widgets should keep the imperative API; both remain available, but once a script calls render() the tree replaces the built-in glyph/text row (later setText/setGlyph calls have no visible effect and log a warning).

function update()
local container = barWidget.isVertical() and ui.column or ui.row
barWidget.render(container({ gap = 6, align = "center" }, {
ui.glyph({ name = "cpu", size = 14 }),
ui.label({ text = readLoad(), fontWeight = "bold" }),
ui.button({ glyph = "refresh", glyphSize = 12, variant = "ghost", onClick = "onRefresh" }),
}))
end

Bar-specific constraints:

  • Cross-axis is the bar thickness: the widget capsule clips to it, so keep the tree one control tall (row on a horizontal bar, column on a side bar - branch on barWidget.isVertical()). Only the main axis grows with content.
  • No keyboard: the bar never takes keyboard focus, so ui.input, ui.select, and ui.scroll are skipped with a warning. Pointer controls (ui.button, ui.toggle, ui.slider, ui.progress, ui.graph, …) work.
  • Wheel: a slider in a bar widget consumes vertical wheel events over it and steps its value, so the wheel no longer reaches the widget’s own scroll handling while the pointer is over a slider. Scroll gestures that start elsewhere keep their original target.
  • Clicks compose: an inline control consumes its own clicks; the widget-level onClick/onRightClick still fire for the rest of the capsule (and its padding).
  • Drag and drop works (plugin_api = 38): ui.dragSource / ui.dropZone behave as in panels, with the ghost confined to the widget’s extent - see Drag and drop.
  • Ticks stay on noctalia.setUpdateInterval - there is no frame-tick API in the bar.

The declarative widget in the official noctalia/example plugin is the reference implementation.

A panel is a pop-up surface that describes its UI as a ui.* tree, exactly like a desktop widget - but it is opened by id rather than placed, and it can hold the interactive controls (ui.input, ui.select, ui.slider, ui.toggle, ui.scroll) because it takes keyboard focus while open. Render in onOpen (and again from any callback that changes state):

render(tree) · close() · openContextMenu(request) · setWantsSecondTicks(bool) · setNeedsFrameTick(bool)

Panel size is host-owned: declare width and height on the [[panel]] entry, so the surface is the same size on every open. There is no setSize at runtime. Each axis takes a positive number (logical px) or the string "fill", which spans the output’s available extent on that axis, the compositor subtracts every exclusive zone (bars, docks, third-party clients), so a height = "fill" panel sits exactly between them. "fill" requires placement = "floating". Fixed pixel sizes are clamped to the output, so an oversized value degrades to full-available rather than a broken surface.

height = "content" fits the panel to the tree it renders (requires plugin_api = 42). The panel follows its content as it changes, up to max_height (logical px, required). The panel reserves that maximum for as long as it is open, so keep it close to what the content can really need. Content taller than that needs a ui.scroll with flexGrow = 1 to scroll inside the panel. The panel keeps the edge it opens from: below a top bar it grows downward, above a bottom bar upward, and a centered panel grows both ways. Until the first panel.render() the panel is only as tall as its padding, so render in onOpen. Not available for persistent = true panels.

[[panel]]
id = "picker"
entry = "panel.luau"
width = 300
height = "content"
max_height = 700

Set dismiss_on_outside_click = false for auth-style prompts (password / API key) so a misclick does not discard the panel; the default is true. Requires plugin_api = 8. Escape and an explicit Close / Cancel control still dismiss it.

setWantsSecondTicks(true) runs update() on second boundaries; setNeedsFrameTick(true) additionally delivers onFrameTick(deltaMs) every frame while the panel is open, for animation the script drives itself. Ticks stop on close and resume on the next open, and frames are coalesced - a slow script only ever sees the latest one. Requires plugin_api = 18.

panel.openContextMenu(request) opens a native, theme-aware menu at the pointer position that triggered a ui.button’s onRightClick callback. Call it directly from that callback: the host deliberately keeps the Wayland coordinates and input serial private, and the function returns false when no live direct pointer callback is active. Requires plugin_api = 28.

local selectedEntryId = "clip-42"
function openEntryMenu()
panel.openContextMenu({
items = {
{ kind = "header", label = "Clipboard" },
{ id = "copy", label = "Copy" }, -- kind = "item" may be explicit
{ kind = "item", id = "pin", label = "Pin" },
{ kind = "separator" },
{ id = "delete", label = "Delete", enabled = selectedEntryId ~= "" },
},
onActivate = "onEntryMenuAction",
context = selectedEntryId,
maxVisible = 12,
})
end
function onEntryMenuAction(actionId, entryId)
if actionId == "copy" then
copyEntry(entryId)
elseif actionId == "pin" then
pinEntry(entryId)
elseif actionId == "delete" then
deleteEntry(entryId)
end
end
panel.render(ui.button({
text = "Clipboard entry",
onRightClick = "openEntryMenu",
}))

The request accepts these item shapes:

  • an action: { id = "copy", label = "Copy", enabled = true }; kind = "item" is optional and enabled defaults to true
  • a non-interactive heading: { kind = "header", label = "Clipboard" }
  • a separator: { kind = "separator" }

onActivate names a global panel callback. It receives the selected action ID and, when supplied, the request’s optional scalar context (string, number, or boolean). Action IDs must be unique. A request must contain at least one enabled action and may contain up to 64 items. maxVisible defaults to 12 and accepts 1-30; longer menus scroll. IDs and callback names are limited to 128 bytes and labels to 512 bytes. Invalid requests raise a descriptive Luau argument error.

The menu closes on activation, outside click, Escape, panel close/reload, or plugin disable. Calling panel.close() and opening a menu in the same callback closes the panel and discards the menu request. Context menus work in both regular and persistent plugin panels.

By default a panel takes keyboard focus when the user clicks it (keyboard_focus = "on_demand"), and "exclusive" takes it as soon as the panel opens - what a search or password field needs. Both only decide the initial focus, and only on compositors without focus-grab support; on the others the panel settles on on-demand focus shortly after opening either way.

keyboard_focus = "none" is different in kind: the panel never takes keyboard focus, not even when clicked, so the window the user is typing into keeps it. That is what an on-screen keyboard, a macro pad, or any panel that drives another application needs. Because outside-click dismissal works by either covering the screen with a click catcher or grabbing focus, and neither is compatible with never touching focus, keyboard_focus = "none" requires dismiss_on_outside_click = false. Requires plugin_api = 10.

Normally the shell keeps one panel open at a time: opening the control center closes yours. A panel with persistent = true lives outside that slot, so it stays on screen until an explicit toggle, panel.close(), or noctalia ipc panel-close <id> dismisses it. Several persistent panels can be open at once, alongside a normal panel. Requires plugin_api = 11.

Persistent panels trade away the features that only make sense for a transient pop-up:

  • always floating, so placement is neither read nor offered as a setting (position still applies)
  • no outside-click dismissal, so dismiss_on_outside_click = false is required
  • keyboard_focus = "exclusive" is rejected: a surface that is never dismissed would hold the keyboard forever
  • no ui.select dropdowns inside the panel yet

Placement matches built-in shell panels (attached anchors the panel’s own surface to the bar edge, while floating opens detached). The host injects four standard settings for every [[panel]] entry. You do not declare them as [[panel.setting]] unless you need a custom key. They appear in Settings → Plugins (gear on the plugin row) with the same controls as built-in panels: a segmented Attached / Floating toggle, Position and Layer dropdowns (only when floating), and Open Near Click.

[[panel]] manifest keyplugin_settings keyNotes
placement<entry>_placementattached or floating (default floating)
position<entry>_positionauto, center, or a screen anchor; pins a floating panel, and aligns an attached panel to the matching end of the bar when the anchor lies on the bar’s edge
open_near_click<entry>_open_near_clickOpen near the bar widget that toggled the panel instead of at position (default false)
dismiss_on_outside_click(none, manifest only)Close when clicking outside (default true; requires plugin_api = 8)
keyboard_focus(none, manifest only)on_demand (default), exclusive, or none (requires plugin_api = 10)
persistent(none, manifest only)Keep open when another panel opens (requires plugin_api = 11)
capture_keys(none, manifest only)Key chords the panel handles itself while focused (requires plugin_api = 13)
layer<entry>_layertop (default) or overlay; only applies when floating (optional manifest default requires plugin_api = 30)

Declare defaults on the [[panel]] table; users override through the plugin settings GUI or [plugin_settings."author/plugin"]. The host supplies every standard setting even when the manifest declares no default, so plugin maintainers do not need to add anything for users to choose a layer. Position and open-near-click follow the same rules as built-in panels (pinned screen positions disable open-near-click).

Layer controls the floating surface’s layer-shell layer. top keeps the panel under fullscreen windows; overlay keeps it above them, which is what an on-screen keyboard or heads-up panel needs. An attached panel ignores this setting and sits at its host bar’s configured layer.

Official defaults: noctalia/example:panel ships floating + center; noctalia/wallhaven:browser ships attached + auto.

A panel that needs raw key presses declares the chords it wants in capture_keys. While the panel holds keyboard focus, a listed chord is delivered to a global onKey(chord, pressed) instead of being handled by the host, and pressed distinguishes press from release. This is what a stopwatch, a game, or any hold-to-act interaction needs.

[[panel]]
id = "timer"
entry = "panel.luau"
capture_keys = ["space", "ctrl+r"]
function onKey(chord, pressed)
if chord == "space" then
if pressed then arm() else start() end
elseif chord == "ctrl+r" and pressed then
reset()
end
end

chord is the exact string from the manifest, so match on what you declared rather than on a key name the host invented. Chord syntax is the same as elsewhere in Noctalia (space, ctrl+r, shift+Return).

Constraints worth knowing before designing around it:

  • Declared chords only. The panel never receives keys it did not list. This keeps a panel from observing everything a user types into its own fields, and keeps the event rate down.
  • A focused text input wins first. If a ui.input has focus, plain printable keys type into it and never reach onKey, so a capture_keys = ["space"] panel with a text field still gets spaces typed normally.
  • Escape always dismisses. The panel-close action stays with the host and cannot be captured, so a panel can never make itself impossible to close from the keyboard. This tracks the user’s own Cancel keybind, so rebinding it keeps working.
  • A captured chord outranks the shell navigation keybinds, inside that panel only. Capture is checked before the keybind actions in [keybinds] are applied, so capture_keys = ["space"] means space no longer triggers Validate on a focused control while that panel is up. Cancel is the one exception. Nothing changes for other panels or the rest of the shell.
  • Capturing is declared, not decided. The script runs off the main thread, so it cannot answer “did you consume this?” in time. Listing a chord is the decision to consume it. A chord you list but do not handle in onKey is left to the host rather than swallowed.
  • keyboard_focus must not be none. A panel that never takes focus never receives a key, so the combination is rejected at parse time.
  • Key repeat is filtered. Holding a captured chord produces one onKey(chord, true) and one onKey(chord, false), not a stream of presses, so hold-to-act works as written.
  • Super chords are rejected: those belong to the compositor.
[[panel]]
id = "settings"
entry = "panel.luau"
width = 420
height = 410
placement = "floating"
position = "center"
open_near_click = false

An on-screen keyboard, which needs every one of the options above:

[[panel]]
id = "keys"
entry = "keys.luau"
width = "fill"
height = 240
position = "bottom_center"
keyboard_focus = "none"
dismiss_on_outside_click = false
persistent = true
[plugin_settings."noctalia/wallhaven"]
browser_placement = "floating"
browser_position = "bottom_left"
local enabled = true
local function render()
panel.render(ui.column({ gap = 16 }, {
ui.row({ align = "center", justify = "space_between" }, {
ui.label({ text = "My Panel", fontSize = 16, fontWeight = "bold", color = "primary", flexGrow = 1 }),
ui.button({ glyph = "close", onClick = "onCloseClicked" }),
}),
ui.toggle({ checked = enabled, onChange = "onToggle" }),
}))
end
function onOpen(context) render() end -- context is the optional string from `panel-open <id> [context]`
function onToggle(value) enabled = value == "true"; render() end
function onCloseClicked() panel.close() end

From a widget or shortcut script, toggle a panel by its full entry id with noctalia.togglePanel("author/plugin:panel"). The same panel can also be opened, closed, or toggled externally over IPC:

Terminal window
noctalia msg panel-toggle noctalia/example:panel

The reference implementation is the panel entry of the official noctalia/example plugin - a settings-style panel exercising every interactive control. noctalia/wallhaven:browser is a network-backed panel (thumbnail grid, filters, download + apply via noctalia.wallpaperDirectory() and noctalia.setWallpaper()).

A [[layer]] entry describes its UI with the same ui.* tree as a desktop widget or panel. Requires plugin_api = 36.

render(tree) · setNeedsFrameTick(bool) · outputName()

layer.render(ui.column({ fill = "#000000", flexGrow = 1 }, {}))
  • Per output: each shown output runs its own instance. layer.outputName() returns the connector of the output this instance draws on, or nil.
  • Ticks: update() runs on the noctalia.setUpdateInterval interval (default 1000 ms). setNeedsFrameTick(true) delivers onFrameTick(deltaMs) every frame while the layer is shown. As for desktop widgets, deltaMs arrives as a string-formatted number; use tonumber.
  • Input: pointer and keyboard input drive controls like in a panel, and keys arrive only while the layer holds keyboard focus (see the keyboard manifest key).

ui.colorPicker embeds the shell’s color picker: a saturation/brightness plane, a hue strip, and text fields, with corners that follow the shell’s corner radius setting (requires plugin_api = 41). To ask for a color in a separate dialog instead, use noctalia.openColorPicker.

ui.colorPicker({
key = "accent", value = accent, alpha = true, width = 260,
swatches = { "#EF4444", "#F97316", "#EAB308", "#22C55E", "#06B6D4", "#3B82F6", "#8B5CF6", "#EC4899" },
onChange = function(color) accent = color; preview() end, -- while dragging or typing
onChangeEnd = function(color) save(color) end, -- once per finished change
})
  • value is #RRGGBB, or #RRGGBBAA with alpha = true; callbacks report the same form. Like a slider’s value, it is applied only when the prop changes between renders, never while the user is dragging, and passing the onChange value straight back is safe.
  • alpha (default false) adds a transparency strip and a percentage field.
  • inputs picks the text fields: "full" (default) is a HEX / RGB / HSL format switch with the fields of the chosen format, "hex" is the hex field alone, "none" hides the fields. The format switch opens a dropdown, which only panels can show; elsewhere use "hex" or "none". Typing needs keyboard focus, so fields are only editable in panels and layers that take it.
  • swatches is up to 16 hex colors shown as chips; clicking one selects its color and keeps the current transparency. Invalid entries are logged and skipped.
  • width is the picker’s width in logical pixels; without it the picker fills the width its container offers. Its height follows from the width.
  • onChange(color) fires continuously and is coalesced, so a slow script only sees the newest color. onChangeEnd(color) fires once when a drag is released, a typed value is applied, or a swatch is clicked: persist from there.

ui.pixelStream shows live frames produced by a companion process the plugin runs, for example a phone screen mirror or a remote preview (requires plugin_api = 40). The companion owns capture, decoding, audio, and any device input; Noctalia only displays its pixels and reports what the user does over them. Frame data never passes through the plugin script.

ui.pixelStream({
key = "screen", path = noctalia.pluginDataDir() .. "/screen.sock", width = 320, height = 640, radius = 12,
onPointerDown = function(x, y) sendTouch("down", x, y) end,
onPointerMove = function(x, y) lastX, lastY = x, y; if touching then sendTouch("move", x, y) end end,
onPointerUp = function(x, y) sendTouch("up", x, y) end,
onPointerCancel = function() sendTouch("cancel") end,
onScroll = function(dx, dy) sendScroll(lastX, lastY, tonumber(dx), tonumber(dy)) end,
onTextInput = function(text) sendText(text) end,
onKeyEvent = function(chord, pressed) sendKey(chord, pressed == "true") end,
})

The frame is fitted inside width × height with its aspect ratio kept; supply both. Until the first frame arrives, and after path changes, the control is empty. When the companion disconnects, the last frame stays on screen and Noctalia reconnects every second.

Pointer. Positions are strings from "0.0000" to "1.0000", relative to the displayed frame (letterboxing excluded) with 0,0 at the top-left.

  • onPointerMove(x, y) fires while the pointer is over the frame, and during a left drag also outside it, pinned to the nearest edge. Moves are coalesced like ui.graph’s: a slow script only sees the newest position, but never one from after a later press, release, click, or scroll.
  • onPointerDown(x, y) / onPointerUp(x, y) bracket a left-button drag that starts on the frame. onPointerCancel() ends a drag without a release: the companion disconnected, the frame size changed, the control’s callbacks changed, or it left the tree (including the panel closing). Every onPointerDown is followed by exactly one onPointerUp or onPointerCancel when the latter is declared.
  • onRightClick(x, y) / onMiddleClick(x, y) fire when that button is pressed on the frame and released over the control, with the press position.
  • onScroll(dx, dy) reports whole wheel steps over the frame, positive right and down; pair it with the last onPointerMove position. Without onScroll the wheel scrolls the surrounding content as usual.

Keyboard. Declaring onTextInput or onKeyEvent lets the user focus the control by clicking it; keys arrive only while it holds focus, so this works in panels and layers that take keyboard focus, not in bar widgets. This is separate from a panel’s capture_keys / global onKey: the focused control receives everything typed into it, including key repeats.

  • onTextInput(text) receives the UTF-8 text of each text-producing key press (no Ctrl or Alt held). Composition previews are not delivered; finished characters are.
  • onKeyEvent(chord, pressed) receives every other key as a chord such as "Return", "BackSpace", or "Ctrl+c" (modifiers in the order Ctrl, Alt, Shift, followed by the XKB key name), and "true" on press or repeat, "false" on release. Super chords are never delivered.
  • Host keys keep priority: the Cancel keybind (Escape) still dismisses the panel, the Tab keybinds still move focus, and a panel’s capture_keys chords still go to its global onKey.

The companion listens on a filesystem Unix socket of type SOCK_SEQPACKET at path, ideally inside a directory only the user can access. Noctalia connects while the control exists, accepts only a peer running as the same user, and closes the connection when the control is removed. Noctalia does not start or stop the companion; manage it from the plugin, for example with the panel lifecycle callbacks.

Every message is a sequence of little-endian unsigned 32-bit fields:

DirectionMessageFieldsMeaning
Noctalia → companionHELLO1, version (1)First message on every connection. Close the connection if you do not speak this version.
Noctalia → companionREQUEST2, 0Noctalia is ready for the next frame and no longer reads the buffer of the previous FRAME. The first REQUEST follows HELLO.
companion → NoctaliaBUFFER1, id, width, heightAttaches shared buffer id (0 to 3). Carries exactly one file descriptor (SCM_RIGHTS).
companion → NoctaliaFRAME2, idBuffer id holds the frame to show. Only valid as the answer to a REQUEST.

A buffer is a memfd holding width × height RGBA pixels, 4 bytes each (red, green, blue, alpha), rows top to bottom with no padding. It must be at least that large and sealed against shrinking (MFD_ALLOW_SEALING, then F_ADD_SEALS with F_SEAL_SHRINK). Width and height are 1 to 4096, at most 4,194,304 pixels. Pixels should be opaque (alpha 255). A buffer can be replaced by sending BUFFER with the same id again, which is how the frame size changes.

There is at most one outstanding REQUEST. Answer it when a frame newer than the last one sent exists, and immediately on a new connection, so a reconnecting shell gets the current picture. Between sending FRAME for a buffer and receiving the next REQUEST, do not write to or replace that buffer; render into another one instead (two buffers are enough). Noctalia sends REQUEST after it has uploaded a frame for display, so the stream is paced by the shell and pauses while the control is not drawn: render only the latest frame and drop older ones rather than queueing them.

Any malformed or out-of-order message closes the connection, followed by a reconnect a second later.

Panels can offer pointer drag and drop for reorderable lists, kanban-style groups, and cross-container moves (requires plugin_api = 5); bar widgets rendered with barWidget.render can too (requires plugin_api = 38), for example to reorder a row of workspace pills. The host owns the whole interaction - pointer capture, hit testing, the drag ghost, target highlighting, and cursor changes - and the plugin receives one callback after a completed drop. The plugin owns its data: mutate the model in the callback and re-render. Nothing moves by itself.

ui.dragSource marks a subtree (usually a grip glyph, or a whole row) as draggable. ui.dropZone is a flex container that accepts drops. Both take the common layout props of a column/row plus:

ConstructorProps
ui.dragSourcedragType (required string, matched against accepts), payload (required opaque string, first callback argument), enabled, tooltip, previewAncestor (int 0-8: how many parent levels the ghost shows; 1 previews the row around a grip), liftFromLayout (bool: remove the previewed row from layout while dragging - pair with expandOnDrag insertion zones), grabCursor (bool, default true: show the grab hand on hover; set false when the whole source is an ordinary clickable item rather than a grip - dragging always shows the grabbing hand), onClick (callback for a press released before the drag threshold - how a source that wraps a whole item stays clickable, since its input area covers the item; a completed drag never fires it) plugin_api = 38
ui.dropZoneaccepts (required array of drag types; {} accepts nothing), value (required opaque string, second callback argument), onDrop (required callback), direction (column/row), enabled, expandOnDrag (bool: a thin zone animates to the dragged item’s size along its thin axis while targeted - a 3px-high rule between rows grows in height, a 4px-wide one between the members of a row grows in width), hitSlop (number: extra drag-only hit distance around the zone, without changing layout or normal clicks)

A press becomes a drag only after a small movement threshold, so clicks on controls inside a dragged row still work. While dragging, the source dims (or lifts out of layout), a ghost of the previewed subtree follows the pointer, and the accepted target under the pointer highlights with the primary color. Releasing over an accepted zone calls onDrop(payload, value); releasing anywhere else cancels silently. Nested zones resolve to the deepest accepting zone; zones with hitSlop are considered first, closest wins.

local items = { "alpha", "beta", "gamma" }
local function row(id)
return ui.row({ key = "row-" .. id, gap = 8, align = "center" }, {
ui.dragSource({
key = "grip-" .. id, dragType = "item", payload = id,
previewAncestor = 1, liftFromLayout = true,
width = 24, height = 24,
}, { ui.glyph({ name = "menu-2", size = 14 }) }),
ui.label({ text = id, flexGrow = 1 }),
})
end
local function gap(anchor, place) -- thin insertion zone between rows
return ui.dropZone({
key = "gap-" .. place .. "-" .. anchor,
accepts = { "item" }, value = place .. "|" .. anchor, onDrop = "onReorder",
height = 3, expandOnDrag = true, hitSlop = 48,
})
end
function onReorder(id, target)
local place, anchor = string.match(target, "^([^|]+)|(.+)$")
moveItem(id, anchor, place) -- plugin-owned mutation
render() -- re-render so the drop lands without a flash
end
  • Sortable lists: place a thin expandOnDrag + hitSlop insertion zone before every row and one after the last; with liftFromLayout on the source the list shows exactly one open gap that follows the pointer. Encode the insertion point in value ("before:<id>", "end", …) - the callback never receives coordinates. The same recipe works sideways: in a ui.row make the zones narrow (width = 4, full height) and the gap opens horizontally (a bar widget’s pills, for example).
  • Cross-container moves: use the same dragType in every compatible container and a different value per container. Keep a container-level ui.dropZone around each list so an empty list stays a valid target.
  • Validation is strict: a missing, mistyped, empty, or over-limit required prop logs and disables that control for the render - it never silently reuses the previous value. Limits: payload 16 KiB; dragType, value, onDrop, and each accepts entry 256 bytes; at most 16 accepts entries.
  • Update the model before slow persistence: mutate + render() first, then save; keep a snapshot to roll back on failure. enabled = false on sources/zones is useful while a save is in flight.
  • Scope and limits: panels and bar widgets (the constructors are rejected with a log message on desktop widgets and layers); drags stay inside one panel or one bar widget - the ghost is drawn in the widget’s own extent, so the bar’s cross-axis clip applies; only the left button drags; there is no OS/Wayland drag and drop, no keyboard drag (keep a non-drag alternative such as a move-to selector for keyboard users), and the mouse wheel cannot scroll the panel while a drag is active.