Files
community-plugins/w-engine/w-engine-panel.luau
T
Jacob SabellaandGitHub 68c64f31b1 feat(w-engine): sync shell palette, cycle wallpapers, expose engine options (#161)
Noctalia derives its color scheme from whatever image is set as the
wallpaper. A live Wallpaper Engine scene is not an image the shell can
read, so the palette stayed pinned to whatever wallpaper came before.
Applying a wallpaper now also hands Noctalia a still of it -- the
Workshop preview, or a decoded frame for video wallpapers when ffmpeg is
present -- so the configured generator, scheme, mode and templates all
run exactly as they do for an ordinary wallpaper. The previous wallpaper
is saved per output and restored on stop. Toggle: sync_colors.

Add multi-select with timed cycling. Selected wallpapers carry a badge
showing their place in the rotation, and play in selection order or
shuffled; random plays a full pass before reshuffling so nothing repeats
back to back. Selection, interval and order are per output.

Add per-wallpaper settings behind a gear on each tile: the
linux-wallpaperengine switches (scaling, clamp, layer, FPS, volume,
mute, particles, mouse, parallax, fullscreen pause), plus the
wallpaper's own properties parsed from project.json and rendered by
declared type, honoring each property's visibility condition.

Move process ownership, the palette sync and the cycle timer into the
service. The panel is now pure UI talking over noctalia.state, so a
cycle keeps running while the panel is closed and one owner holds the
pid files.

Fix three process-handling bugs along the way:

- The running-process check anchored on "^linux-wallpaperengine", but
  packagers ship a wrapper that execs ./linux-wallpaperengine from its
  own directory, so no process ever matched and every apply leaked
  another GPU client onto the same output.
- Current builds ignore both SIGTERM and SIGINT, so the kill never
  landed either. Stopping now escalates to SIGKILL after a grace period.
- The reload sweep used pkill -f, which matches the shell running the
  command itself and killed it part way through, leaving orphans.

Cache a static still per wallpaper for the grid. Workshop previews are
frequently animated GIFs, and the grid decoded and animated all of them
continuously for as long as the panel was open.

plugin_api moves 3 -> 9: tile and control callbacks are Luau closures
rather than named globals, which the host resolves from API 9 onwards.
This is what the plugin already required; the manifest understated it.
2026-07-30 21:09:15 -04:00

1333 lines
37 KiB
Luau

--!nonstrict
-- W-Engine panel — browse Wallpaper Engine Workshop items and hand one, or a
-- rotation of several, to the service.
--
-- This entry owns no processes and no timers; every action is published on
-- noctalia.state for start.luau to carry out.
local REQUEST_KEY = "w_engine_request"
local STATUS_KEY = "w_engine_status"
local WORKSHOP_PATHS = {
"~/.steam/steam/steamapps/workshop/content/431960/",
"~/.local/share/Steam/steamapps/workshop/content/431960/",
"~/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/workshop/content/431960/",
"~/snap/steam/common/.local/share/Steam/steamapps/workshop/content/431960/",
"~/snap/steam/common/.local/share/steam/steamapps/workshop/content/431960/",
}
local ORDER_VALUES = { "sequential", "random" }
-- Panel size is fixed by plugin.toml, so grid density is configurable instead.
-- Tiles are sized from the width left over once gaps, frames and the scrollbar
-- are accounted for, which keeps the grid inside the panel at any column count.
local TILE_GAP = 12
local TILE_FRAME = 3 -- padding on each side of a preview, drawn as the selection frame
local GRID_WIDTH = 732 -- panel width from plugin.toml, less its insets and the scrollbar
local PREVIEW_RATIO = 5 / 9
local columns = 4
local previewWidth = 168
local previewHeight = 93
local workshopRoot = nil
local catalog = nil -- cached: the grid re-renders far more often than the disk changes
local catalogStamp = nil -- mtime of the Workshop dir the cache was built from
local isOpen = false
local outputName = nil
local multiSelect = false
local selected = {} -- ids, in the order they were picked
local minutesText = "15"
local order = "sequential"
local status = {}
-- Per-wallpaper configuration view.
local view = "grid" -- "grid" | "config" | "defaults"
local configId = nil
local configName = ""
local configProps = {}
local engineDraft = {} -- working copy of options[id].engine
local propsDraft = {} -- working copy of options[id].properties
local showUnlabeled = false
-- Global defaults view.
local defaultsDraft = {} -- working copy of defaults.engine
local clearOverrides = false
type Wallpaper = {
nb: string,
name: string,
path: string,
}
local thumbsRequested = false
-- Forward declaration: callbacks defined above the rendering section redraw
-- through this.
local render
local function asTable(value)
return type(value) == "table" and value or {}
end
local function tr(key, subst)
if subst then
return noctalia.tr(key, subst)
end
return noctalia.tr(key)
end
-- ── Workshop discovery ───────────────────────────────────────────────────────
local function candidateRoots()
local roots = {}
for _, path in ipairs(WORKSHOP_PATHS) do
table.insert(roots, path)
end
-- Extra roots live in the service's data.json (see README).
local dir = noctalia.pluginDataDir()
if dir then
local raw = noctalia.readFile(dir .. "/data.json")
if raw then
local decoded = noctalia.json.decode(raw)
if type(decoded) == "table" and type(decoded.personnalPath) == "table" then
for _, path in ipairs(decoded.personnalPath) do
if type(path) == "string" and path ~= "" then
if not path:match("/$") then
path = path .. "/"
end
table.insert(roots, path)
end
end
end
end
end
return roots
end
-- Cached static stills, one per wallpaper, kept beside the plugin's data.
local function thumbDir()
local dir = noctalia.pluginDataDir()
return dir and (dir .. "/thumbs") or nil
end
-- Builds every missing still in one pass, then redraws. Runs at most once per
-- script load; new subscriptions are picked up on the next reload.
local function requestThumbnails(thumbs)
if not thumbs or thumbsRequested or not workshopRoot or not noctalia.commandExists("ffmpeg") then
return
end
thumbsRequested = true
local cmd = "mkdir -p '"
.. thumbs
.. "'; for d in '"
.. noctalia.expandPath(workshopRoot)
.. "'*/; do id=$(basename \"$d\"); [ -f '"
.. thumbs
.. "'/\"$id\".jpg ] && continue; p=$(ls \"$d\"preview.* 2>/dev/null | head -1); "
.. "[ -n \"$p\" ] && ffmpeg -y -loglevel error -i \"$p\" -frames:v 1 "
.. "-vf 'scale=360:200:force_original_aspect_ratio=increase,crop=360:200' -q:v 4 '"
.. thumbs
.. "'/\"$id\".jpg >/dev/null 2>&1; done; true"
noctalia.runAsync(cmd, function()
catalog = nil
render()
end, 60000)
end
local function loadCatalog(): { Wallpaper }
local items = {}
workshopRoot = nil
-- Several stock locations can exist at once, and last match wins, so a
-- personnalPath entry takes precedence.
for _, path in ipairs(candidateRoots()) do
if noctalia.listDir(path) then
workshopRoot = path
end
end
if not workshopRoot then
noctalia.notify(tr("panel.title"), tr("panel.list_failed"))
return items
end
local entries, err = noctalia.listDir(workshopRoot)
if not entries then
warn(err or "could not list the Workshop directory")
noctalia.notify(tr("panel.title"), tr("panel.list_failed"))
return items
end
local thumbs = thumbDir()
local missing = false
for _, entryName in entries do
if tonumber(entryName) then -- Workshop items are numeric ids
local content = noctalia.readFile(workshopRoot .. entryName .. "/project.json")
if content then
local value = noctalia.json.decode(content)
if type(value) == "table" then
-- Prefer the cached still: Workshop previews are often animated
-- GIFs, which the grid would decode and animate for as long as
-- the panel is open.
local path = workshopRoot .. entryName .. "/" .. (value.preview or "preview.jpg")
local thumb = thumbs and (thumbs .. "/" .. entryName .. ".jpg") or nil
if thumb and noctalia.fileExists(thumb) then
path = thumb
else
missing = true
end
table.insert(items, {
nb = entryName,
name = value.title or entryName,
path = path,
})
end
else
noctalia.notify(tr("panel.title"), tr("panel.missing_project_json", { name = entryName }))
end
end
end
if missing then
requestThumbnails(thumbs)
end
return items
end
-- ── Talking to the service ───────────────────────────────────────────────────
local seq = 0
local function send(payload)
seq += 1
-- The service ignores a repeated nonce, so it must differ across script
-- reloads that reset the counter.
payload.nonce = tostring(seq) .. ":" .. tostring(os.clock and os.clock() or 0)
payload.output = outputName
noctalia.state.set(REQUEST_KEY, payload)
end
local function outputStatus()
local outputs = type(status) == "table" and status.outputs or nil
if type(outputs) ~= "table" or not outputName then
return {}
end
local entry = outputs[outputName]
return type(entry) == "table" and entry or {}
end
local function storedDefaults()
return asTable(asTable(status.defaults).engine)
end
-- ── Selection ────────────────────────────────────────────────────────────────
local function selectionIndex(id)
for index, value in ipairs(selected) do
if value == id then
return index
end
end
return nil
end
local function toggleSelection(id)
local index = selectionIndex(id)
if index then
table.remove(selected, index)
else
table.insert(selected, id)
end
send({ action = "select", ids = selected })
end
-- ── Wallpaper properties ─────────────────────────────────────────────────────
--
-- Every Workshop item declares its own settings in project.json under
-- general.properties: a name -> { type, text, value, ... } map. `type` selects
-- the control and `condition` gates its visibility, so the form differs per
-- wallpaper.
-- Types whose value is a path into the wallpaper's own asset tree. These are
-- left at their defaults.
local PROPERTY_SKIP = { file = true, directory = true, scenetexture = true }
local HTML_ENTITIES = {
[" "] = " ",
["&"] = "&",
["&lt;"] = "<",
["&gt;"] = ">",
["&quot;"] = '"',
["&apos;"] = "'",
["&#39;"] = "'",
}
-- Property labels are authored as HTML: <br> separates a translated label from
-- its English counterpart, and some carry <img>, <a> or <center> blocks for
-- decoration. Reduce all of it to plain text.
local function cleanLabel(text)
local label = tostring(text or "")
label = label:gsub("<%s*[Bb][Rr]%s*/?%s*>", " ")
label = label:gsub("<[^>]*>", "")
for entity, character in pairs(HTML_ENTITIES) do
label = label:gsub(entity, character)
end
label = label:gsub("\\[nrt]", " ") -- literal escape sequences, not whitespace
label = label:gsub("%s+", " ")
label = label:gsub("^%-+", "") -- authors prefix dashes to fake indentation
return (label:gsub("^%s+", ""):gsub("%s+$", ""))
end
local function loadProperties(id)
local raw = noctalia.readFile(workshopRoot .. id .. "/project.json")
if not raw then
return {}
end
local decoded = noctalia.json.decode(raw)
if type(decoded) ~= "table" then
return {}
end
local props = asTable(asTable(decoded.general).properties)
local list = {}
for name, def in pairs(props) do
if type(def) == "table" and type(def.type) == "string" and def.type ~= "" then
table.insert(list, {
name = name,
kind = def.type,
label = cleanLabel(def.text),
default = def.value,
min = tonumber(def.min),
max = tonumber(def.max),
step = tonumber(def.step),
fraction = def.fraction == true,
options = def.options,
condition = def.condition,
order = tonumber(def.order) or 0,
})
end
end
table.sort(list, function(a, b)
if a.order ~= b.order then
return a.order < b.order
end
return a.name < b.name
end)
return list
end
-- Conditions look like "audiobar.value == true" or
-- "a.value && (b.value == 2 || c.value > 3)". Some wallpapers instead use
-- JavaScript ternaries that rewrite label text rather than gate visibility, so
-- anything that fails to tokenize or parse is treated as visible.
local function tokenize(expr)
local tokens, i, n = {}, 1, #expr
while i <= n do
local two = expr:sub(i, i + 1)
local c = expr:sub(i, i)
if c:match("%s") then
i += 1
elseif two == "&&" or two == "||" or two == "==" or two == "!=" or two == ">=" or two == "<=" then
table.insert(tokens, { kind = "op", value = two })
i += 2
elseif c == "(" or c == ")" then
table.insert(tokens, { kind = c })
i += 1
elseif c == "!" or c == ">" or c == "<" then
table.insert(tokens, { kind = "op", value = c })
i += 1
elseif c == "'" or c == '"' then
local close = expr:find(c, i + 1, true)
if not close then
return nil
end
table.insert(tokens, { kind = "lit", value = expr:sub(i + 1, close - 1) })
i = close + 1
elseif c:match("%d") then
local s, e = expr:find("^%d+%.?%d*", i)
table.insert(tokens, { kind = "lit", value = tonumber(expr:sub(s, e)) })
i = e + 1
elseif c:match("[%a_]") then
local s, e = expr:find("^[%w_%.]+", i)
local word = expr:sub(s, e)
if word == "true" then
table.insert(tokens, { kind = "lit", value = true })
elseif word == "false" then
table.insert(tokens, { kind = "lit", value = false })
else
table.insert(tokens, { kind = "ref", value = word })
end
i = e + 1
else
return nil -- ternary, assignment, or anything else we do not model
end
end
return tokens
end
local function truthy(value)
if value == nil or value == false or value == 0 or value == "" then
return false
end
return true
end
local function compareValues(a, b, op)
-- JavaScript-ish coercion: these expressions freely compare a boolean
-- property against 1 or 0.
if type(a) == "boolean" and type(b) == "number" then
a = a and 1 or 0
end
if type(b) == "boolean" and type(a) == "number" then
b = b and 1 or 0
end
if op == "==" then
return a == b
elseif op == "!=" then
return a ~= b
end
local x, y = tonumber(a), tonumber(b)
if x == nil or y == nil then
return false
end
if op == ">" then
return x > y
elseif op == "<" then
return x < y
elseif op == ">=" then
return x >= y
end
return x <= y
end
local function evaluate(tokens, valueOf)
local pos = 1
local parseOr
local function peek()
return tokens[pos]
end
local function take()
local token = tokens[pos]
pos += 1
return token
end
local function parsePrimary()
local token = take()
if not token then
return nil, false
end
if token.kind == "(" then
local value, ok = parseOr()
if not ok then
return nil, false
end
local closing = take()
if not closing or closing.kind ~= ")" then
return nil, false
end
return value, true
elseif token.kind == "op" and token.value == "!" then
local value, ok = parsePrimary()
if not ok then
return nil, false
end
return not truthy(value), true
elseif token.kind == "lit" then
return token.value, true
elseif token.kind == "ref" then
return valueOf(token.value), true
end
return nil, false
end
local function parseComparison()
local left, ok = parsePrimary()
if not ok then
return nil, false
end
local token = peek()
while token and token.kind == "op" and token.value ~= "&&" and token.value ~= "||" and token.value ~= "!" do
take()
local right, rightOk = parsePrimary()
if not rightOk then
return nil, false
end
left = compareValues(left, right, token.value)
token = peek()
end
return left, true
end
local function parseAnd()
local left, ok = parseComparison()
if not ok then
return nil, false
end
local token = peek()
while token and token.kind == "op" and token.value == "&&" do
take()
local right, rightOk = parseComparison()
if not rightOk then
return nil, false
end
left = truthy(left) and truthy(right)
token = peek()
end
return left, true
end
parseOr = function()
local left, ok = parseAnd()
if not ok then
return nil, false
end
local token = peek()
while token and token.kind == "op" and token.value == "||" do
take()
local right, rightOk = parseAnd()
if not rightOk then
return nil, false
end
left = truthy(left) or truthy(right)
token = peek()
end
return left, true
end
local value, ok = parseOr()
if not ok or pos <= #tokens then
return nil
end
return truthy(value)
end
-- project.json stores colors as space-separated floats ("0 0.92 1"); the shell's
-- color picker speaks #RRGGBB.
local function colorToHex(value)
local parts = {}
for chunk in tostring(value or ""):gmatch("[^%s,]+") do
table.insert(parts, tonumber(chunk) or 0)
end
local function channel(index)
local scaled = math.floor((parts[index] or 0) * 255 + 0.5)
return math.max(0, math.min(255, scaled))
end
return string.format("#%02x%02x%02x", channel(1), channel(2), channel(3))
end
local function hexToColor(hex)
local clean = tostring(hex or ""):gsub("^#", "")
if #clean < 6 then
return "0 0 0"
end
local r = tonumber(clean:sub(1, 2), 16) or 0
local g = tonumber(clean:sub(3, 4), 16) or 0
local b = tonumber(clean:sub(5, 6), 16) or 0
return string.format("%.6f %.6f %.6f", r / 255, g / 255, b / 255)
end
-- ── Engine options ───────────────────────────────────────────────────────────
-- "" is the unset entry: no flag is emitted and linux-wallpaperengine's own
-- default applies. A nil element would truncate the array, so the sentinel is a
-- string.
local SCALING_VALUES = { "", "default", "stretch", "fit", "fill" }
local CLAMP_VALUES = { "", "clamp", "border", "repeat" }
-- "top" and "overlay" are also accepted, but render the wallpaper above every
-- window.
local LAYER_VALUES = { "", "background", "bottom" }
local ENGINE_TOGGLES = {
{ key = "silent", label = "silent" },
{ key = "noautomute", label = "noautomute" },
{ key = "no_audio_processing", label = "no_audio_processing" },
{ key = "disable_particles", label = "disable_particles" },
{ key = "disable_mouse", label = "disable_mouse" },
{ key = "disable_parallax", label = "disable_parallax" },
{ key = "no_fullscreen_pause", label = "no_fullscreen_pause" },
{ key = "fullscreen_pause_only_active", label = "fullscreen_pause_only_active" },
}
-- ── Rendering ────────────────────────────────────────────────────────────────
local function header()
local state = outputStatus()
local children = {
ui.label({
text = tr("panel.title"),
align = "center",
fontSize = 16,
fontWeight = "bold",
color = "on_surface",
flexGrow = 1,
}),
ui.button({
text = tr("panel.select_multiple"),
variant = "ghost",
selected = multiSelect,
tooltip = tr("panel.select_multiple_tooltip"),
onClick = function()
multiSelect = not multiSelect
render()
end,
}),
}
if state.current or state.restorable then
table.insert(
children,
ui.button({
text = tr("panel.stop"),
variant = "ghost",
tooltip = tr("panel.stop_tooltip"),
onClick = function()
send({ action = "stop" })
end,
})
)
end
local outputs = {}
local selectedIndex = 0
for index, output in ipairs(noctalia.outputs()) do
table.insert(outputs, output.name)
if output.name == outputName then
selectedIndex = index - 1
end
end
table.insert(children, ui.select({ options = outputs, selectedIndex = selectedIndex, onChange = "outputSelected" }))
table.insert(
children,
ui.button({
glyph = "tool",
variant = "ghost",
tooltip = tr("panel.defaults_tooltip"),
onClick = function()
defaultsDraft = {}
for key, value in pairs(storedDefaults()) do
defaultsDraft[key] = value
end
view = "defaults"
render()
end,
})
)
table.insert(children, ui.button({ glyph = "close", onClick = "onCloseClicked" }))
return ui.row({ align = "center", justify = "space_between", gap = 8 }, children)
end
-- The cycle controls only exist in multi-select mode: an interval means nothing
-- until there is a list to rotate through.
local function cycleBar()
local state = outputStatus()
local running = state.cycle_enabled == true
local summary
if running then
summary = tr("panel.cycle_running", {
count = #selected,
minutes = tonumber(state.cycle_minutes) or tonumber(minutesText) or 15,
})
else
summary = tr("panel.selected_count", { count = #selected })
end
return ui.row({
align = "center",
gap = 8,
padding = 8,
radius = 9,
fill = "surface_variant/0.45",
border = running and "primary/0.55" or "outline/0.35",
borderWidth = 1,
}, {
ui.label({ text = summary, fontSize = 12, color = "on_surface", flexGrow = 1 }),
ui.label({ text = tr("panel.every"), fontSize = 12, color = "on_surface_variant" }),
ui.input({
key = "cycle-minutes",
value = minutesText,
maxWidth = 56,
controlSize = "sm",
textAlign = "center",
onChange = "onMinutesChange",
}),
ui.label({ text = tr("panel.minutes"), fontSize = 12, color = "on_surface_variant" }),
ui.select({
options = { tr("panel.order_sequential"), tr("panel.order_random") },
selectedIndex = order == "random" and 1 or 0,
controlSize = "sm",
onChange = "onOrderChange",
}),
ui.button({
text = running and tr("panel.cycle_stop") or tr("panel.cycle_start"),
variant = running and "ghost" or "primary",
enabled = running or #selected > 0,
onClick = function()
if running then
send({ action = "cycle", enabled = false })
else
send({
action = "cycle",
enabled = true,
minutes = tonumber(minutesText) or 15,
order = order,
})
end
end,
}),
ui.button({
text = tr("panel.clear"),
variant = "ghost",
enabled = #selected > 0,
onClick = function()
selected = {}
send({ action = "select", ids = selected })
render()
end,
}),
})
end
-- A labelled row wrapping one control, so the form reads as a single column.
local function settingRow(label, control)
return ui.row({ align = "center", gap = 10 }, {
ui.label({ text = label, fontSize = 12, color = "on_surface", flexGrow = 1, maxLines = 2 }),
control,
})
end
local function selectRow(label, values, current, unsetLabel, onPick)
local options, selectedIndex = {}, 0
for index, value in ipairs(values) do
table.insert(options, value == "" and unsetLabel or value)
if value == (current or "") then
selectedIndex = index - 1
end
end
return settingRow(
label,
ui.select({
options = options,
selectedIndex = selectedIndex,
controlSize = "sm",
onChange = function(index)
local picked = values[(math.floor(tonumber(index) or 0)) + 1]
onPick(picked ~= "" and picked or nil)
end,
})
)
end
local function sliderRow(label, value, min, max, step, display, onSlide, onCommit)
return ui.row({ align = "center", gap = 10 }, {
ui.label({ text = label, fontSize = 12, color = "on_surface", flexGrow = 1, maxLines = 2 }),
ui.slider({
min = min,
max = max,
step = step,
value = value,
flexGrow = 1,
onChange = onSlide,
onDragEnd = onCommit,
}),
ui.label({ text = display, fontSize = 11, color = "on_surface_variant", maxWidth = 52 }),
})
end
-- One control for one wallpaper property, chosen by its declared type.
-- One builder per declared property type. Each takes the property and its
-- current value and returns a control, or nil when there is nothing to draw.
-- Adding support for a new type means adding an entry here and nothing else.
local PROPERTY_CONTROLS = {}
PROPERTY_CONTROLS.bool = function(prop, label, value)
return settingRow(
label,
ui.toggle({
checked = value == true,
onChange = function()
propsDraft[prop.name] = not (value == true)
render()
end,
})
)
end
PROPERTY_CONTROLS.slider = function(prop, label, value)
local min = prop.min or 0
local max = prop.max or 1
local step = prop.step or (prop.fraction and (max - min) / 100 or 1)
local number = tonumber(value) or min
local display = prop.fraction and string.format("%.2f", number) or tostring(math.floor(number))
return sliderRow(label, number, min, max, step, display, function(dragged)
propsDraft[prop.name] = tonumber(dragged) or number
end, function()
render() -- re-render once on release: other controls may be conditional on this
end)
end
PROPERTY_CONTROLS.color = function(prop, label, value)
local hex = colorToHex(value)
return settingRow(
label,
ui.row({ gap = 6, align = "center" }, {
ui.label({ text = hex, fontSize = 11, color = "on_surface_variant" }),
ui.box({
width = 30,
height = 20,
radius = 6,
fill = hex,
border = "outline",
borderWidth = 1,
onClick = function()
noctalia.openColorPicker(hex, function(picked)
if picked then
propsDraft[prop.name] = hexToColor(picked)
render()
end
end)
end,
}),
})
)
end
PROPERTY_CONTROLS.combo = function(prop, label, value)
local options, values, selectedIndex = {}, {}, 0
for index, option in ipairs(asTable(prop.options)) do
if type(option) == "table" then
table.insert(options, cleanLabel(option.label))
table.insert(values, option.value)
if option.value == value then
selectedIndex = index - 1
end
end
end
if #options == 0 then
return nil
end
return settingRow(
label,
ui.select({
options = options,
selectedIndex = selectedIndex,
controlSize = "sm",
onChange = function(index)
local picked = values[(math.floor(tonumber(index) or 0)) + 1]
if picked ~= nil then
propsDraft[prop.name] = picked
render()
end
end,
})
)
end
PROPERTY_CONTROLS.textinput = function(prop, label, value)
return settingRow(
label,
ui.input({
key = "prop-" .. prop.name,
value = tostring(value or ""),
controlSize = "sm",
onChange = function(typed)
propsDraft[prop.name] = tostring(typed or "")
end,
})
)
end
-- Not inputs: wallpaper authors use these purely as section headings.
PROPERTY_CONTROLS.text = function(_prop, label)
return ui.label({ text = label, fontSize = 12, fontWeight = "bold", color = "primary" })
end
PROPERTY_CONTROLS.group = PROPERTY_CONTROLS.text
local function propertyRow(prop, currentValue)
local build = PROPERTY_CONTROLS[prop.kind]
if not build then
return nil
end
-- A heading whose text was entirely markup is decoration, such as a banner
-- image or a donation link, rather than a setting. Its generated name is not
-- worth showing in its place.
if prop.label == "" and (prop.kind == "text" or prop.kind == "group") then
return nil
end
return build(prop, prop.label ~= "" and prop.label or prop.name, currentValue)
end
-- Draws the engine switches for either the global defaults or one wallpaper.
-- `fallback` holds the values a control falls back to when the draft has no entry
-- of its own, so a wallpaper's controls show what it currently inherits. Editing
-- a control writes into the draft, which is what makes it an override.
local function engineSection(draft, fallback, unsetLabel)
local function effective(key, default)
local value = draft[key]
if value == nil then
value = fallback[key]
end
if value == nil then
return default
end
return value
end
local fps = math.floor(tonumber(effective("fps", 30)) or 30)
local volume = math.floor(tonumber(effective("volume", 15)) or 15)
local children = {
ui.label({ text = tr("panel.playback"), fontSize = 13, fontWeight = "bold", color = "on_surface" }),
selectRow(tr("panel.engine.scaling"), SCALING_VALUES, effective("scaling"), unsetLabel, function(value)
draft.scaling = value
render()
end),
selectRow(tr("panel.engine.clamp"), CLAMP_VALUES, effective("clamp"), unsetLabel, function(value)
draft.clamp = value
render()
end),
selectRow(tr("panel.engine.layer"), LAYER_VALUES, effective("layer"), unsetLabel, function(value)
draft.layer = value
render()
end),
-- Only record a value that differs from what the control already shows.
-- Writing on every change would turn an untouched slider into an explicit
-- override the moment the form is submitted.
sliderRow(tr("panel.engine.fps"), fps, 5, 144, 1, tostring(fps), function(value)
local picked = math.floor(tonumber(value) or fps)
if picked ~= fps then
draft.fps = picked
end
end, function()
render()
end),
sliderRow(tr("panel.engine.volume"), volume, 0, 100, 1, tostring(volume), function(value)
local picked = math.floor(tonumber(value) or volume)
if picked ~= volume then
draft.volume = picked
end
end, function()
render()
end),
}
for _, toggle in ipairs(ENGINE_TOGGLES) do
local key = toggle.key
local checked = effective(key) == true
table.insert(
children,
settingRow(
tr("panel.engine." .. toggle.label),
ui.toggle({
checked = checked,
onChange = function()
draft[key] = not checked
render()
end,
})
)
)
end
return children
end
-- The value a property currently holds: the user's edit if there is one, else
-- the default the wallpaper ships with.
local function currentOf(prop)
local value = propsDraft[prop.name]
if value ~= nil then
return value
end
return prop.default
end
-- Resolves "somename.value" for the condition evaluator.
local function propertyResolver()
local byName = {}
for _, prop in ipairs(configProps) do
byName[prop.name] = prop
end
return function(reference)
local prop = byName[(reference:gsub("%.value$", ""))]
return prop and currentOf(prop) or nil
end
end
local function propertyVisible(prop, valueOf)
local condition = prop.condition
if type(condition) ~= "string" or condition:gsub("%s", "") == "" then
return true
end
local tokens = tokenize(condition)
if not tokens then
return true
end
local result = evaluate(tokens, valueOf)
if result == nil then
return true
end
return result
end
-- Wallpaper Engine names new properties "newpropertyN" and authors do not always
-- relabel them. Unlabelled properties go behind a disclosure.
local function propertySection()
local valueOf = propertyResolver()
local rows, unlabeled = {}, {}
for _, prop in ipairs(configProps) do
if not PROPERTY_SKIP[prop.kind] and propertyVisible(prop, valueOf) then
local control = propertyRow(prop, currentOf(prop))
if control then
table.insert(prop.label == "" and unlabeled or rows, control)
end
end
end
if #rows == 0 and #unlabeled == 0 then
table.insert(rows, ui.label({ text = tr("panel.no_properties"), fontSize = 12, color = "on_surface_variant" }))
return rows
end
if #unlabeled > 0 then
table.insert(
rows,
ui.row({ align = "center", gap = 10 }, {
ui.label({
text = tr("panel.unlabeled", { count = #unlabeled }),
fontSize = 12,
color = "on_surface_variant",
flexGrow = 1,
maxLines = 2,
}),
ui.toggle({
checked = showUnlabeled,
onChange = function()
showUnlabeled = not showUnlabeled
render()
end,
}),
})
)
if showUnlabeled then
for _, control in ipairs(unlabeled) do
table.insert(rows, control)
end
end
end
return rows
end
local function configHeader()
return ui.row({ align = "center", gap = 8 }, {
ui.button({ glyph = "close", variant = "ghost", tooltip = tr("panel.back"), onClick = function()
view = "grid"
render()
end }),
ui.label({
text = configName,
fontSize = 15,
fontWeight = "bold",
color = "on_surface",
flexGrow = 1,
maxLines = 1,
}),
ui.button({ glyph = "restore", variant = "ghost", tooltip = tr("panel.reset"), onClick = function()
engineDraft = {}
propsDraft = {}
send({ action = "options", id = configId, engine = {}, properties = {} })
render()
end }),
ui.button({ glyph = "check", text = tr("panel.apply_options"), variant = "primary", onClick = function()
send({ action = "options", id = configId, engine = engineDraft, properties = propsDraft })
end }),
})
end
-- Global defaults. Every wallpaper resolves these unless it overrides the key
-- itself, so this is the place to mute everything at once.
local function defaultsView()
local rows = engineSection(defaultsDraft, {}, tr("panel.engine.unset"))
table.insert(rows, ui.separator({}))
table.insert(
rows,
ui.row({ align = "center", gap = 10 }, {
ui.label({
text = tr("panel.clear_overrides"),
fontSize = 12,
color = "on_surface",
flexGrow = 1,
maxLines = 3,
}),
ui.toggle({
checked = clearOverrides,
onChange = function()
clearOverrides = not clearOverrides
render()
end,
}),
})
)
return ui.column({ flexGrow = 1, gap = 12 }, {
ui.row({ align = "center", gap = 8 }, {
ui.button({ glyph = "close", variant = "ghost", tooltip = tr("panel.back"), onClick = function()
view = "grid"
render()
end }),
ui.label({
text = tr("panel.defaults"),
fontSize = 15,
fontWeight = "bold",
color = "on_surface",
flexGrow = 1,
maxLines = 1,
}),
ui.button({ glyph = "restore", variant = "ghost", tooltip = tr("panel.reset"), onClick = function()
defaultsDraft = {}
send({ action = "defaults", engine = {}, clear_overrides = clearOverrides })
render()
end }),
ui.button({ glyph = "check", text = tr("panel.apply_options"), variant = "primary", onClick = function()
send({ action = "defaults", engine = defaultsDraft, clear_overrides = clearOverrides })
end }),
}),
ui.scroll({ key = "defaults-scroll", gap = 10, paddingRight = 8, align = "stretch", flexGrow = 1 }, rows),
})
end
local function configView()
local rows = engineSection(engineDraft, storedDefaults(), tr("panel.engine.inherit"))
table.insert(rows, ui.separator({}))
table.insert(
rows,
ui.label({ text = tr("panel.properties"), fontSize = 13, fontWeight = "bold", color = "on_surface" })
)
for _, row in ipairs(propertySection()) do
table.insert(rows, row)
end
return ui.column({ flexGrow = 1, gap = 12 }, {
configHeader(),
-- A distinct key from the grid's scroll, so the form opens at the top.
ui.scroll({ key = "config-scroll", gap = 10, paddingRight = 8, align = "stretch", flexGrow = 1 }, rows),
})
end
-- Seeds the form from the settings the service holds for this wallpaper.
local function openConfig(id, name)
configId = id
configName = name
configProps = loadProperties(id)
engineDraft = {}
propsDraft = {}
local stored = asTable(status.options)[id]
if type(stored) == "table" then
for key, value in pairs(asTable(stored.engine)) do
engineDraft[key] = value
end
for key, value in pairs(asTable(stored.properties)) do
propsDraft[key] = value
end
end
view = "config"
render()
end
local function tilePreview(item, isSelected, isCurrent)
local frame = "outline/0"
if isSelected then
frame = "primary"
elseif isCurrent then
frame = "secondary"
end
return ui.column({
padding = 3,
radius = 11,
border = frame,
borderWidth = (isSelected or isCurrent) and 2 or 1,
}, {
ui.image({
path = item.path,
width = previewWidth,
height = previewHeight,
fit = "cover",
radius = 8,
onClick = function()
if multiSelect then
toggleSelection(item.nb)
render()
else
send({ action = "apply", id = item.nb })
noctalia.notify(tr("panel.title"), tr("panel.apply", { name = item.name }))
end
end,
}),
})
end
local function tileCaption(item, index, isSelected)
-- Numbered to show rotation order.
local caption = isSelected and (tostring(index) .. ". " .. item.name) or item.name
-- A fixed label width keeps every tile the same size; long titles truncate.
return ui.row({ gap = 2, align = "center" }, {
ui.label({
text = caption,
fontSize = 11,
maxLines = 1,
maxWidth = previewWidth - 34,
textAlign = "center",
color = isSelected and "primary" or "on_surface",
}),
ui.button({
glyph = "settings",
variant = "ghost",
controlSize = "sm",
tooltip = tr("panel.configure"),
onClick = function()
openConfig(item.nb, item.name)
end,
}),
})
end
local function tile(item: Wallpaper, state)
local index = selectionIndex(item.nb)
local isSelected = multiSelect and index ~= nil
local isCurrent = state.current == item.nb
return ui.column({ gap = 4, key = item.nb, align = "center" }, {
tilePreview(item, isSelected, isCurrent),
tileCaption(item, index, isSelected),
})
end
render = function()
if not isOpen then
return
end
catalog = catalog or loadCatalog()
if view == "defaults" then
panel.render(defaultsView())
return
end
if view == "config" and configId then
panel.render(configView())
return
end
local state = outputStatus()
local rows = {}
local row = {}
for index, item in ipairs(catalog) do
table.insert(row, tile(item, state))
if #row == columns or index == #catalog then
table.insert(rows, ui.row({ gap = 12, align = "start" }, row))
row = {}
end
end
local body = { header() }
if multiSelect then
table.insert(body, cycleBar())
end
if #catalog == 0 then
table.insert(body, ui.label({ text = tr("panel.no_wallpapers"), fontSize = 12, color = "on_surface_variant" }))
end
-- paddingRight keeps the last column clear of the scrollbar.
table.insert(
body,
ui.scroll({ key = "grid-scroll", gap = 12, paddingRight = 8, align = "stretch", flexGrow = 1 }, rows)
)
panel.render(ui.column({ flexGrow = 1, gap = 12 }, body))
end
-- ── Handlers ─────────────────────────────────────────────────────────────────
-- Adopts the service's view of this output. Selection, interval and order are
-- persisted, so the panel reflects the rotation that is running.
local function adoptStatus()
local state = outputStatus()
selected = {}
if type(state.selection) == "table" then
for _, id in ipairs(state.selection) do
table.insert(selected, id)
end
end
if tonumber(state.cycle_minutes) then
minutesText = tostring(math.floor(tonumber(state.cycle_minutes)))
end
order = state.cycle_order == "random" and "random" or "sequential"
if state.cycle_enabled or #selected > 0 then
multiSelect = true
end
end
function outputSelected(_index, label)
if type(label) == "string" and label ~= "" then
outputName = label
adoptStatus()
render()
end
end
function onMinutesChange(value)
minutesText = tostring(value or "")
end
function onOrderChange(index, _label)
order = ORDER_VALUES[(math.floor(tonumber(index) or 0)) + 1] or "sequential"
end
function onCloseClicked()
panel.close()
end
function onOpen(_context)
isOpen = true
outputName = noctalia.focusedOutputName() or outputName
if not outputName then
local outputs = noctalia.outputs()
outputName = outputs[1] and outputs[1].name or nil
end
view = "grid"
columns = math.max(2, math.min(8, math.floor(tonumber(noctalia.getConfig("grid_columns")) or 4)))
local spacing = (columns - 1) * TILE_GAP + columns * TILE_FRAME * 2
previewWidth = math.max(60, math.floor((GRID_WIDTH - spacing) / columns))
previewHeight = math.floor(previewWidth * PREVIEW_RATIO)
-- Rebuild the catalog only when the Workshop directory has changed.
local stamp = nil
if workshopRoot then
local info = noctalia.fileInfo(workshopRoot)
stamp = info and info.mtime or nil
end
if catalog == nil or stamp == nil or stamp ~= catalogStamp then
catalog = nil
catalogStamp = stamp
end
status = noctalia.state.get(STATUS_KEY) or {}
adoptStatus()
-- The service publishes status on load and on every change, so no request is
-- needed here.
render()
end
function onClose()
isOpen = false
end
-- The entry stays resident after the surface closes, so this keeps firing; the
-- isOpen guard in render() suppresses work while hidden.
noctalia.state.watch(STATUS_KEY, function(value)
status = type(value) == "table" and value or {}
render()
end)