Files
community-plugins/w-engine/w-engine-panel.luau
T
Tadomika-AriandGitHub 7fa96aceef feat(w-engine): Add favorite section (#270)
* feat!: add favorite section

* fix: change visual for 1 scroll: wallpaper and favorite wallpaper

* feat!: update for v1.2.0

* fix: rm line downgrade quality

* fix: add downgrade quality for improve storage cache

* feat!: update thumbnail

* feat!: add favorites list in data.json

* feat!: add save favorites list across start plugin
2026-08-07 22:24:22 -04:00

1435 lines
40 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
-- Favorite list
local FavoriteList = {}
local function getDataPath()
return noctalia.pluginDataDir() .. "/data.json"
end
local function loadData()
local raw = noctalia.readFile(getDataPath())
if not raw then
return {}
end
local ok, decoded = pcall(function()
return noctalia.json.decode(raw)
end)
if not ok or type(decoded) ~= "table" then
return {}
end
return decoded
end
local function saveData(data)
return noctalia.writeFile(getDataPath(), noctalia.json.encode(data))
end
local function loadFavoriteList()
local data = loadData()
FavoriteList = data.favorites or {}
end
local function persistFavoriteList()
local data = loadData() -- récupère les autres clés existantes (ex: settingsPath)
data.favorites = FavoriteList
saveData(data)
end
local function addFavoriteList(item)
noctalia.notify("Add Favorite")
table.insert(FavoriteList, item)
persistFavoriteList()
end
local function removeFavoriteList(item, nb)
noctalia.notify("Remove Favorite")
table.remove(FavoriteList, nb)
persistFavoriteList()
end
local function checkInFavoriteList(item)
local nb = 1
for _, entryName in ipairs(FavoriteList) do
if entryName.name == item.name then
removeFavoriteList(item, nb)
return
end
nb = nb + 1
end
addFavoriteList(item)
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.button({
glyph = "star",
variant = "ghost",
controlSize = "sm",
tooltip = "Add at your favorite wallpaper",
onClick = function()
checkInFavoriteList(item)
render()
end
}),
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
local favoriteRows = {}
local favoriteRow = {}
for index, item in ipairs(FavoriteList) do
table.insert(favoriteRow, tile(item, state))
if #favoriteRow == columns or index == #FavoriteList then
table.insert(favoriteRows, ui.row({ gap = 12, align = "start" }, favoriteRow))
favoriteRow = {}
end
end
-- Single scroll for all list (Wallpaper and Favorite Wallpaper)
local scrollContent = {}
table.insert(scrollContent, ui.label({ text = "Favorites", fontSize = 13, fontWeight = "bold", color = "on_surface" }))
if #FavoriteList == 0 then
table.insert(scrollContent, ui.label({ text = "No favorite", fontSize = 12, color = "on_surface_variant" }))
else
for _, r in ipairs(favoriteRows) do
table.insert(scrollContent, r)
end
end
table.insert(scrollContent,
ui.separator({ spacing = 12 }
))
table.insert(scrollContent,
ui.label({ text = "All wallpapers", fontSize = 13, fontWeight = "bold", color = "on_surface" }
))
for _, r in ipairs(rows) do
table.insert(scrollContent, r)
end
table.insert(body,
ui.scroll({ key = "grid-scroll", gap = 12, paddingRight = 8, align = "stretch", flexGrow = 1 }, scrollContent))
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
loadFavoriteList()
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)