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.
An explicit fontSize/size/fontWeight/fontFamily 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 |
ui.scroll | a vertically scrolling container; same layout props as a column plus fill, radius, border, borderWidth, stickToBottom (bool), onScroll, scrollToBottomRev (number - 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 |
ui.box | fill, radius, border, borderWidth, softness, width, height, onClick, onHover |
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 |
ui.toggle | checked (bool), enabled, onChange |
ui.slider | min, max, step, value, controlSize (sm/md/lg), enabled, onChange, onDragEnd |
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), 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_variant/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. Dropping the prop on a later render clears it. Tooltips appear for buttons 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. -
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. -
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. -
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). - 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() · 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.
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.
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 or context menus 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 three 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, a Position dropdown (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; only applies when floating |
open_near_click | <entry>_open_near_click | Open near the bar widget that toggled the panel (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) |
Declare defaults on the [[panel]] table; users override through the plugin settings GUI or
[plugin_settings."author/plugin"]. Position and open-near-click follow the same rules as built-in panels (pinned
screen positions disable open-near-click).
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()).
Drag and drop (panels only)
Section titled “Drag and drop (panels only)”Panels can offer pointer drag and drop for reorderable lists, kanban-style groups, and cross-container moves
(requires plugin_api = 5). 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) |
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 fixed-height zone animates to the dragged row’s height while targeted), 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. - 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 only (the constructors are rejected with a log message on other surfaces); drags stay inside one panel; 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.