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")endShared ui.* controls
Section titled “Shared ui.* controls”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).
| Constructor | Props |
|---|---|
ui.column / ui.row | gap, padding, paddingH, paddingV, align (start/center/end/stretch), justify (start/center/end/space_between), fill, radius, border, borderWidth, minWidth, minHeight, onClick, onHover, tooltip |
ui.scroll | a 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.label | text, fontSize, color, fontWeight (thin…heavy), fontFamily (a family name; load a file with noctalia.loadFont), baseline (text/textFixedHeight/inkCentered/pictographic), maxWidth, maxLines, textAlign |
ui.markdown | text (markdown source - see below), width, height |
ui.glyph | name (Tabler/Nerd-Font glyph), size, color |
ui.image | path (plugin-relative, ~, or absolute), width, height, radius, fit (contain/cover/stretch), border, borderWidth, onClick, onHover, tooltip |
ui.pixelStream | path (companion socket), width, height, radius, and pointer and keyboard callbacks; see Pixel streams |
ui.box | fill, radius, border, borderWidth, softness, width, height, onClick, onHover, tooltip |
ui.separator | thickness, color, spacing, orientation (auto/horizontal/vertical) |
ui.spacer | flexible filler (use flexGrow) |
ui.progress | progress (0–1), fill, track, radius, width, height |
ui.button | text, 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.graph | values / values2 (arrays of 0–1 numbers), color / color2, lineWidth, fillOpacity, width, height, onPointerMove, onPointerLeave |
ui.toggle | checked (bool), enabled, onChange |
ui.slider | min, max, step, value, controlSize (sm/md/lg), enabled, onChange, onDragEnd |
ui.colorPicker | value, alpha, inputs, swatches, width, enabled, onChange, onChangeEnd; see Color picker |
ui.select | options (array of strings), selectedIndex, placeholder, controlSize (sm/md/lg), enabled, width, height, onChange |
ui.input | value (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:
opacityis a group opacity, it fades a container and all its children (text included), like CSSopacityon a parent. To make only the background semi-transparent, leaveopacityalone and give the container a translucentfillinstead:ui.column({ fill = "surface_container/0.6" }, …)keeps the text fully opaque. -
Layout default:
ui.column/ui.row/ui.scrollstretch their children across the cross axis (a column fills its width, a row fills its height), like CSS flexbox. Override per node withalign- e.g. a row of vertically-centered items usesalign = "center". -
Control height:
controlSizepicks 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, useheightinstead; it wins when both are set. (Not to be confused withui.glyph’s numericsize, which is a glyph size in px.) -
Tooltips:
ui.buttonaccepts atooltipstring, shown on hover.ui.box,ui.row,ui.column, andui.imagealso accepttooltip(requiresplugin_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.onClickfires on a button click;onChangefires 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);onSubmitfires 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) dotable.insert(rows, ui.button({key = task.id,text = task.title,onClick = function() completeTask(index) end,}))endHandlers 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
keyso 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, soonChange = function(value) ... endstill reads"true"/"false"for a toggle. -
Hover:
onHover = "name"fires on pointer enter and leave forui.row,ui.column,ui.box,ui.image, andui.button. The handler receives two strings: the state ("true"on enter,"false"on leave) and the node’skey(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 treefunction onChipHover(state, key)hovered = (state == "true") and key or nilendOnly the innermost hovered element reports: if a container with
onHovercontains 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:
onPointerMoveonui.graphfires 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", with0,0at the top-left, sonormYgrows downward. Map the x-position onto your own data: the graph does not know what its samples mean.onPointerLeaveis independent and fires with no arguments.function onGraphMove(normX)hoveredIndex = math.floor(tonumber(normX) * #series) + 1render()endfunction onGraphLeave()hoveredIndex = nilrender()endui.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:
onClickonui.rowandui.columnmakes 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 bareui.box. The same applies toui.imageandui.box(thumbnail grids, placeholder tiles). A clickable container joins the keyboard tab order and activates with Enter or Space. A container that declares onlyonHoverand noonClickdoes 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 emptyonClick/onHovername counts as unset, so clearing the name drops the target instead of leaving a dead one behind. -
Multiline input:
ui.inputwithmultiline = truebecomes a wrapping text area with vertical scrolling, Enter inserts a newline andonSubmitfires on Ctrl+Enter instead. Give it aheightorflexGrowfor the editing area;multilineandpasswordare mutually exclusive.submitOnEnter = trueflips the multiline bindings for chat composers: Enter firesonSubmit, Shift+Enter inserts the newline, and Ctrl+Enter still submits. Requiresplugin_api = 21. -
Input frame:
frameVisible = falsehides 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; setframeVisible = trueexplicitly to restore it on a retained keyed input. Requiresplugin_api = 27. -
Markdown:
ui.markdownrenders itstextprop (headings, lists, code blocks, tables) as a read-only block. The host re-parses only whentextor the surface scale changes, so re-rendering a streaming tree with unchanged text is cheap. Requiresplugin_api = 21. -
Follow-scroll:
ui.scrollwithstickToBottom = truestays pinned to the bottom while content grows, until the user scrolls away.onScrollreceives(offset, maxOffset), both delivered as strings.scrollToBottomRevjumps 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 requireplugin_api = 21. -
Reveal a child:
ui.scrollwithrevealKey = "hit-12"scrolls the descendant with thatkeyinto 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. Requiresplugin_api = 33.-- rows are keyed "hit-<index>"; onKey moves `selected` and re-rendersui.scroll({ flexGrow = 1, revealKey = "hit-" .. selected }, rows)A control inside a
ui.scrollthat gains keyboard focus (Tab, Shift+Tab) is scrolled into view by the host with no prop involved. -
Focus:
ui.inputwithfocus = truegrabs 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 freshkey. The seededvalueplaces the caret at the end. -
Controlled vs. uncontrolled:
toggle/slider/selectare value-driven - pass the current value on every render and update it from the callback.ui.inputis uncontrolled:valueseeds the field once, then the host owns the text as the user types - re-rendering never overwrites it. Read edits throughonChange/onSubmit, and give the input a stablekeyso it keeps its text across renders. -
Identity: give list children a
keyprop so reordering reuses the same native controls. -
Images:
ui.imageloads local files only - download remote previews withnoctalia.download(url, dest, cb)first, then pass the saved path. -
Glyph-only buttons: when only
glyphis set, any previoustextis cleared so retained buttons do not keep stale labels across re-renders. -
Ticks:
setWantsSecondTicks(true)runsupdate()on second boundaries (clocks/timers).setNeedsFrameTick(true)additionally deliversonFrameTick(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" }), }))endBar-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, andui.scrollare skipped with a warning. Pointer controls (ui.button,ui.toggle,ui.slider,ui.progress,ui.graph, …) work. - Clicks compose: an inline control consumes its own clicks; the widget-level
onClick/onRightClickstill fire for the rest of the capsule (and its padding). - Drag and drop works (
plugin_api = 38):ui.dragSource/ui.dropZonebehave 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.
panel.* - declarative panel UI
Section titled “panel.* - declarative panel UI”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 = 300height = "content"max_height = 700Set 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.
Context menus
Section titled “Context menus”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) endend
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 andenableddefaults totrue - 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.
Keyboard focus
Section titled “Keyboard focus”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.
Persistent panels
Section titled “Persistent panels”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
placementis neither read nor offered as a setting (positionstill applies) - no outside-click dismissal, so
dismiss_on_outside_click = falseis required keyboard_focus = "exclusive"is rejected: a surface that is never dismissed would hold the keyboard forever- no
ui.selectdropdowns 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 key | plugin_settings key | Notes |
|---|---|---|
placement | <entry>_placement | attached or floating (default floating) |
position | <entry>_position | auto, 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_click | Open 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>_layer | top (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.
Handling keys directly
Section titled “Handling keys directly”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() endendchord 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.inputhas focus, plain printable keys type into it and never reachonKey, so acapture_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, socapture_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
onKeyis left to the host rather than swallowed. keyboard_focusmust not benone. 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 oneonKey(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 = 420height = 410placement = "floating"position = "center"open_near_click = falseAn on-screen keyboard, which needs every one of the options above:
[[panel]]id = "keys"entry = "keys.luau"width = "fill"height = 240position = "bottom_center"keyboard_focus = "none"dismiss_on_outside_click = falsepersistent = 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() endfunction onCloseClicked() panel.close() endFrom 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:
noctalia msg panel-toggle noctalia/example:panelThe 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()).
layer.* - declarative layer UI
Section titled “layer.* - declarative layer UI”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, ornil. - Ticks:
update()runs on thenoctalia.setUpdateIntervalinterval (default 1000 ms).setNeedsFrameTick(true)deliversonFrameTick(deltaMs)every frame while the layer is shown. As for desktop widgets,deltaMsarrives as a string-formatted number; usetonumber. - Input: pointer and keyboard input drive controls like in a panel, and keys arrive only while the layer holds keyboard focus (see the
keyboardmanifest key).
Color picker
Section titled “Color picker”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})valueis#RRGGBB, or#RRGGBBAAwithalpha = true; callbacks report the same form. Like a slider’svalue, it is applied only when the prop changes between renders, never while the user is dragging, and passing theonChangevalue straight back is safe.alpha(defaultfalse) adds a transparency strip and a percentage field.inputspicks 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.swatchesis up to 16 hex colors shown as chips; clicking one selects its color and keeps the current transparency. Invalid entries are logged and skipped.widthis 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.
Pixel streams
Section titled “Pixel streams”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 likeui.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). EveryonPointerDownis followed by exactly oneonPointerUporonPointerCancelwhen 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 lastonPointerMoveposition. WithoutonScrollthe 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_keyschords still go to its globalonKey.
Companion protocol
Section titled “Companion protocol”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:
| Direction | Message | Fields | Meaning |
|---|---|---|---|
| Noctalia → companion | HELLO | 1, version (1) | First message on every connection. Close the connection if you do not speak this version. |
| Noctalia → companion | REQUEST | 2, 0 | Noctalia is ready for the next frame and no longer reads the buffer of the previous FRAME. The first REQUEST follows HELLO. |
| companion → Noctalia | BUFFER | 1, id, width, height | Attaches shared buffer id (0 to 3). Carries exactly one file descriptor (SCM_RIGHTS). |
| companion → Noctalia | FRAME | 2, id | Buffer 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.
Drag and drop
Section titled “Drag and drop”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:
| Constructor | Props |
|---|---|
ui.dragSource | dragType (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.dropZone | accepts (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 flashend- Sortable lists: place a thin
expandOnDrag+hitSlopinsertion zone before every row and one after the last; withliftFromLayouton the source the list shows exactly one open gap that follows the pointer. Encode the insertion point invalue("before:<id>","end", …) - the callback never receives coordinates. The same recipe works sideways: in aui.rowmake the zones narrow (width = 4, fullheight) and the gap opens horizontally (a bar widget’s pills, for example). - Cross-container moves: use the same
dragTypein every compatible container and a differentvalueper container. Keep a container-levelui.dropZonearound 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:
payload16 KiB;dragType,value,onDrop, and eachacceptsentry 256 bytes; at most 16acceptsentries. - Update the model before slow persistence: mutate +
render()first, then save; keep a snapshot to roll back on failure.enabled = falseon 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.