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.
This commit is contained in:
Jacob Sabella
2026-07-30 21:09:15 -04:00
committed by GitHub
parent f2ce7651b2
commit 68c64f31b1
6 changed files with 2255 additions and 227 deletions
+53
View File
@@ -10,6 +10,8 @@ You need Steam itself to download wallpapers from the Workshop, plus the `linux-
You also need `kill` to stop duplicate wallpaper processes when changing wallpapers, and `setsid` to start the wallpaper process correctly.
`ffmpeg` is optional. It is used to take a still frame from video wallpapers for the color sync, and to build the panel's preview thumbnails.
For more details, see the install section below.
## Plugin
@@ -32,9 +34,40 @@ noctalia msg panel-toggle tadomika_ari/w-engine:w-engine-panel
| Left click on the bar glyph | Open or close the W Engine panel |
| Click a wallpaper entry in the panel | Apply that wallpaper |
| Select an output | Change the monitor where the wallpaper is displayed |
| Click the gear on a wallpaper | Open that wallpaper's own settings |
| Click `Select multiple` | Pick several wallpapers and cycle through them on a timer |
| Click `Stop` | Stop the wallpaper on that output and restore the previous Noctalia wallpaper |
## Settings
Shell colors:
Noctalia builds its color scheme from the current wallpaper image, which a live Wallpaper Engine scene is not, so without this the shell stays themed for whatever wallpaper was set before. With `Sync shell colors with the wallpaper` enabled, which is the default, applying a wallpaper also hands Noctalia a still of it, and the usual palette generator, scheme, mode and templates all run as they do for a normal wallpaper. The still is the Workshop preview, or a frame decoded from the video for video wallpapers when `ffmpeg` is installed. The wallpaper Noctalia had before is saved per output and restored by `Stop`.
Wallpapers per row:
How many wallpapers the panel shows in each row, from 2 to 8, 4 by default. The panel is a fixed size, so fewer columns means larger previews.
Cycling:
Press `Select multiple`, then click the wallpapers you want. Each one is numbered with its place in the rotation, in the order you clicked them. Set how long each stays up in minutes, pick `In order` or `Random`, then press `Start cycle`. Random plays a shuffled pass over the whole selection before reshuffling, so a wallpaper cannot come up twice in a row. Each output keeps its own selection, interval and order, and the cycle keeps running once the panel is closed. Applying a single wallpaper stops the cycle on that output.
Global defaults:
The wrench beside the screen selector opens the same `Playback` controls, applied to every wallpaper. Setting `Mute this wallpaper` there mutes the whole library in one step rather than one wallpaper at a time. A wallpaper's own settings override these key by key, and anything left on `Engine default` falls through to `linux-wallpaperengine`.
Applying defaults can optionally clear the matching per-wallpaper overrides. That only affects the settings actually changed at the same time, so an override left untouched keeps its value.
Per-wallpaper settings:
The gear icon on a wallpaper opens its own settings, saved per wallpaper and used every time it is applied, including inside a cycle.
`Playback` holds the `linux-wallpaperengine` switches: scaling and clamp mode, Wayland layer, FPS limit, volume, mute, automute, audio processing, particles, mouse interaction, parallax and the fullscreen pause behavior. Anything left on `Engine default` emits no flag, so the engine's own default applies.
`Wallpaper properties` is whatever that wallpaper exposes. Wallpaper Engine items declare their own settings in `project.json`, so this part is built fresh for each one: `bool` becomes a toggle, `slider` a slider using the declared min, max and step, `color` a swatch that opens Noctalia's color picker, `combo` a dropdown of the author's options and `textinput` a text field. `text` and `group` are headings, and `file`, `directory` and `scenetexture` are left alone because they point into the wallpaper's own assets. Properties can declare a condition on another property and the form honors it, so switching off `Audio Bar` also hides its color and count. Some authors never rename the properties they add, leaving Wallpaper Engine's placeholder names; those are collected behind a toggle at the end of the list. Property labels are frequently authored as HTML, using `<br>` to separate a translated label from its English counterpart, so the markup is stripped and headings that consist only of decoration are omitted.
These are command-line arguments, so `Apply` restarts the wallpaper on any output showing it, and the restore icon clears everything back to the wallpaper's own defaults.
Custom path:
To define a custom path for Steam or the Workshop, you can add a specific path for W Engine.
@@ -48,6 +81,25 @@ You can create a `data.json` file in `$NOCTALIA_STATE_HOME` based on this exampl
Replace `path1` with your personal path or add other paths. The path is loaded after a reload or if you reopen the widget.
The same file also stores the selection, cycle and per-wallpaper settings, so keep the other keys if it already exists.
## IPC
Beyond opening the panel, the service accepts:
```sh
# Advance to the next wallpaper in the selection now
noctalia msg plugin tadomika_ari/w-engine:start all next [connector]
# Pause the cycle, leaving the current wallpaper up
noctalia msg plugin tadomika_ari/w-engine:start all cycle-stop [connector]
# Stop the wallpaper and restore the previous Noctalia wallpaper
noctalia msg plugin tadomika_ari/w-engine:start all stop [connector]
```
The optional payload is an output connector such as `DP-1`. Without it, the focused output is used.
## Requirements
- noctalia ≥ 5.0.0
@@ -55,6 +107,7 @@ Replace `path1` with your personal path or add other paths. The path is loaded a
- `kill`
- `pkill`
- `setsid`
- `ffmpeg` (optional, for video wallpaper colors and preview thumbnails)
- Wallpaper Engine
- Steam for Workshop access
+29 -6
View File
@@ -1,13 +1,36 @@
id = "tadomika_ari/w-engine"
name = "W Engine"
version = "1.0.0"
plugin_api = 3
version = "1.1.0"
# Tile and control callbacks are Luau closures rather than named globals, which
# the host resolves from plugin API 9 onwards.
plugin_api = 9
author = "TadomiKa-Ari"
license = "MIT"
dependencies = ["linux-wallpaperengine", "kill", "setsid"]
dependencies = ["linux-wallpaperengine", "kill", "setsid", "pkill"]
icon = "movie"
description = "A Noctalia widget for applying Wallpaper Engine wallpapers from Steam Workshop with one click"
tags = ["wallpaper", "utility"]
description = "Apply Wallpaper Engine wallpapers from the Steam Workshop, with palette sync and timed cycling"
tags = ["wallpaper", "desktop", "theming"]
# Noctalia builds its palette from the current wallpaper image, which a live
# Wallpaper Engine scene is not. With this on, applying a wallpaper also sets a
# still of it (the Workshop preview, or a decoded frame for video wallpapers when
# ffmpeg is installed) so the usual generator, scheme, mode and templates run.
[[setting]]
key = "sync_colors"
type = "bool"
label_key = "settings.sync_colors.label"
description_key = "settings.sync_colors.description"
default = true
# Panel size is fixed by this manifest, so grid density is exposed instead.
[[setting]]
key = "grid_columns"
type = "int"
label_key = "settings.grid_columns.label"
description_key = "settings.grid_columns.description"
default = 4
min = 2
max = 8
[[widget]]
id = "w-engine-widget"
@@ -21,4 +44,4 @@ height = 600
[[service]]
id = "start"
entry = "start.luau"
entry = "start.luau"
+802 -7
View File
@@ -1,11 +1,806 @@
-- reset section
--!nonstrict
-- W-Engine service — owns every linux-wallpaperengine process, the palette sync
-- and the cycle timer.
--
-- The panel publishes requests on noctalia.state and this entry carries them
-- out, so a cycle continues while the panel is closed and a single owner holds
-- the pid files.
noctalia.runAsync("pkill linux-wallpaper")
local entries = noctalia.listDir("/tmp")
for _, entrieName in entries do
if entrieName:match("^w%-engine") then -- match for w-engine tmp file
noctalia.removeFile("/tmp/" .. entrieName)
local TICK_MS = 1000
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/",
}
-- Persisted across reloads (pluginDataDir()/data.json).
local data = {
personnalPath = {},
selection = {}, -- output -> { id, ... } in the order the user picked them
cycle = {}, -- output -> { enabled, minutes, order }
current = {}, -- output -> id currently playing
saved_wallpaper = {}, -- output -> the Noctalia wallpaper from before we took over
options = {}, -- id -> { engine = {...}, properties = {...} }, see buildLaunchArgs
defaults = {}, -- { engine = {...} } applied to every wallpaper, see optionsFor
}
-- linux-wallpaperengine switches that take no value. Stored as booleans; the
-- flag is emitted only when true.
local ENGINE_FLAGS = {
{ key = "silent", flag = "--silent" },
{ key = "noautomute", flag = "--noautomute" },
{ key = "no_audio_processing", flag = "--no-audio-processing" },
{ key = "disable_particles", flag = "--disable-particles" },
{ key = "disable_mouse", flag = "--disable-mouse" },
{ key = "disable_parallax", flag = "--disable-parallax" },
{ key = "no_fullscreen_pause", flag = "--no-fullscreen-pause" },
{ key = "fullscreen_pause_only_active", flag = "--fullscreen-pause-only-active" },
}
-- Switches taking a value. scaling and clamp bind to the preceding --bg, so they
-- are emitted right after it; the rest are process-wide.
local ENGINE_SCREEN_VALUES = {
{ key = "scaling", flag = "--scaling" },
{ key = "clamp", flag = "--clamp" },
}
local ENGINE_GLOBAL_VALUES = {
{ key = "layer", flag = "--layer" },
{ key = "fps", flag = "--fps" },
{ key = "volume", flag = "--volume" },
}
local elapsed = {} -- output -> seconds since that output last switched
local shuffled = {} -- output -> ids left to play in this random pass
local lastNonce = nil
local workshopRoot = nil
local rngState = 0
local function tr(key, subst)
if subst then
return noctalia.tr(key, subst)
end
return noctalia.tr(key)
end
-- ── Persistence ──────────────────────────────────────────────────────────────
local function dataPath()
local dir = noctalia.pluginDataDir()
if not dir then
return nil
end
return dir .. "/data.json"
end
local function asTable(value)
return type(value) == "table" and value or {}
end
local function loadData()
local path = dataPath()
if not path then
return
end
local raw = noctalia.readFile(path)
if not raw then
return
end
local decoded = noctalia.json.decode(raw)
if type(decoded) ~= "table" then
return
end
-- personnalPath is user-authored (see README); a file holding only that key
-- is valid.
data.personnalPath = asTable(decoded.personnalPath)
data.selection = asTable(decoded.selection)
data.cycle = asTable(decoded.cycle)
data.current = asTable(decoded.current)
data.saved_wallpaper = asTable(decoded.saved_wallpaper)
data.options = asTable(decoded.options)
data.defaults = asTable(decoded.defaults)
end
local function saveData()
local path = dataPath()
if not path then
return
end
local encoded = noctalia.json.encode(data, true)
if encoded then
noctalia.writeFile(path, encoded)
end
end
-- realod section
-- ── Workshop discovery ───────────────────────────────────────────────────────
local function candidateRoots()
local roots = {}
for _, path in ipairs(WORKSHOP_PATHS) do
table.insert(roots, path)
end
for _, path in ipairs(data.personnalPath) do
if type(path) == "string" and path ~= "" then
if not path:match("/$") then
path = path .. "/"
end
table.insert(roots, path)
end
end
return roots
end
-- Several stock locations can exist at once, and last match wins, so a
-- personnalPath entry takes precedence.
local function resolveWorkshopRoot()
local root = nil
for _, path in ipairs(candidateRoots()) do
if noctalia.listDir(path) then
root = path
end
end
return root
end
local function itemDir(id)
workshopRoot = workshopRoot or resolveWorkshopRoot()
if not workshopRoot then
return nil
end
return workshopRoot .. id .. "/"
end
local function project(id)
local dir = itemDir(id)
if not dir then
return nil, nil
end
local raw = noctalia.readFile(dir .. "project.json")
if not raw then
return nil, dir
end
local decoded = noctalia.json.decode(raw)
if type(decoded) ~= "table" then
return nil, dir
end
return decoded, dir
end
-- ── Palette sync ─────────────────────────────────────────────────────────────
--
-- Noctalia derives its palette from the wallpaper image, so setting a still that
-- represents the live wallpaper runs the configured generator, scheme, mode and
-- templates as for any other wallpaper.
--
-- The still is the Workshop preview, which Noctalia decodes directly (.jpg, .png
-- and .gif). Video wallpapers use a frame decoded with ffmpeg instead, as their
-- preview is often a stylised thumbnail rather than a frame of the video.
local function syncEnabled()
return noctalia.getConfig("sync_colors") ~= false
end
local function shellQuote(path)
return "'" .. noctalia.expandPath(path):gsub("'", "'\\''") .. "'"
end
local function framePath(id)
local dir = noctalia.pluginDataDir()
if not dir then
return nil
end
return dir .. "/frames/" .. id .. ".jpg"
end
-- Calls back with a path to an image representing item `id`, or nil.
local function resolveColorSource(id, callback)
local info, dir = project(id)
if not dir then
callback(nil)
return
end
local preview = dir .. ((info and info.preview) or "preview.jpg")
if not noctalia.fileExists(preview) then
preview = nil
end
local kind = string.lower(tostring((info and info.type) or ""))
local file = info and info.file
local frame = framePath(id)
if kind ~= "video" or type(file) ~= "string" or file == "" or not frame then
callback(preview)
return
end
if noctalia.fileExists(frame) then
callback(frame)
return
end
if not noctalia.commandExists("ffmpeg") then
callback(preview)
return
end
local dataDir = noctalia.pluginDataDir()
if dataDir then
noctalia.mkdirAll(dataDir .. "/frames")
end
-- One frame, scaled down: the generator only needs the colors. Workshop file
-- names contain spaces and non-ASCII, so paths are quoted.
local cmd = "ffmpeg -y -loglevel error -ss 1 -i "
.. shellQuote(dir .. file)
.. " -frames:v 1 -vf scale=960:-2 "
.. shellQuote(frame)
noctalia.runAsync(cmd, function(result)
-- A wallpaper shorter than the seek offset produces no output and no error,
-- so the file is the success signal rather than the exit code.
if noctalia.fileExists(frame) then
callback(frame)
else
callback(preview)
end
end, 20000)
end
-- Captures the Noctalia wallpaper for an output once, so stopping can restore
-- it. Later applies must not overwrite the stash with a generated still.
local function setShellWallpaper(output, path)
if data.saved_wallpaper[output] ~= nil then
noctalia.setWallpaper(output, path)
return
end
noctalia.runAsync("noctalia msg wallpaper-get " .. output, function(result)
local previous = noctalia.string.trim(result.stdout or "")
if result.exitCode == 0 and previous ~= "" then
data.saved_wallpaper[output] = previous
saveData()
end
noctalia.setWallpaper(output, path)
end, 3000)
end
local function syncPalette(output, id)
if not syncEnabled() then
return
end
resolveColorSource(id, function(path)
if path then
setShellWallpaper(output, path)
end
end)
end
local function restoreShellWallpaper(output)
local previous = data.saved_wallpaper[output]
data.saved_wallpaper[output] = nil
saveData()
if type(previous) == "string" and previous ~= "" and noctalia.fileExists(previous) then
noctalia.setWallpaper(output, previous)
end
end
-- ── linux-wallpaperengine processes ──────────────────────────────────────────
local function pidFile(output)
return "/tmp/w-engine-" .. output .. ".pid"
end
-- The recorded pid for this output, when that process is still one of ours. The
-- pid file is the source of truth across script reloads.
local function livePid(output)
local raw = noctalia.readFile(pidFile(output))
if not raw then
return nil
end
local pid = noctalia.string.trim(raw)
if pid == "" or not tonumber(pid) then
return nil
end
local cmdline = noctalia.readFile("/proc/" .. pid .. "/cmdline")
if not cmdline then
return nil
end
-- Some packages wrap the binary and exec ./linux-wallpaperengine from its own
-- directory, so argv[0] is not always the bare name.
if cmdline:find("linux-wallpaperengine", 1, true) then
return pid
end
return nil
end
-- Options are resolved in three layers. The global defaults apply to every
-- wallpaper, a wallpaper's own engine settings override them key by key, and any
-- key still absent produces no flag, leaving linux-wallpaperengine's default.
--
-- defaults = { engine = { silent = true, fps = 24, ... } }
-- options[id] = { engine = { fps = 60 },
-- properties = { embers = false, barcolor = "0.5 0.2 0.1" } }
--
-- Properties are not layered: they are declared by one wallpaper and mean
-- nothing to any other.
local function optionsFor(id)
local engine = {}
for key, value in pairs(asTable(data.defaults.engine)) do
engine[key] = value
end
local entry = data.options[id]
if type(entry) ~= "table" then
return engine, {}
end
for key, value in pairs(asTable(entry.engine)) do
engine[key] = value
end
return engine, asTable(entry.properties)
end
-- Wallpaper properties are typed in project.json but arrive here as plain Luau
-- values; the CLI wants booleans as true/false, colors as the "r g b" string the
-- panel already stores, and everything else as a bare number.
local function propertyValue(value)
if type(value) == "boolean" then
return value and "true" or "false"
end
return tostring(value)
end
local function buildLaunchArgs(output, id)
local engine, properties = optionsFor(id)
local args = { "--screen-root", output, "--bg", id }
for _, spec in ipairs(ENGINE_SCREEN_VALUES) do
local value = engine[spec.key]
if value ~= nil and value ~= "" and value ~= "default" then
table.insert(args, spec.flag)
table.insert(args, tostring(value))
end
end
for _, spec in ipairs(ENGINE_GLOBAL_VALUES) do
local value = engine[spec.key]
if value ~= nil and value ~= "" then
table.insert(args, spec.flag)
table.insert(args, tostring(value))
end
end
for _, spec in ipairs(ENGINE_FLAGS) do
if engine[spec.key] == true then
table.insert(args, spec.flag)
end
end
-- Sorted so a relaunch with unchanged settings produces an identical command.
local names = {}
for name in pairs(properties) do
table.insert(names, name)
end
table.sort(names)
for _, name in ipairs(names) do
table.insert(args, "--set-property")
table.insert(args, name .. "=" .. propertyValue(properties[name]))
end
local quoted = {}
for _, arg in ipairs(args) do
table.insert(quoted, "'" .. tostring(arg):gsub("'", "'\\''") .. "'")
end
return table.concat(quoted, " ")
end
local function launch(output, id)
local cmd = "setsid linux-wallpaperengine "
.. buildLaunchArgs(output, id)
.. " > /dev/null 2>&1 & echo $! > "
.. pidFile(output)
noctalia.runAsync(cmd, nil, 5000)
end
-- linux-wallpaperengine ignores SIGTERM and SIGINT, so stopping escalates to
-- SIGKILL after a grace period.
local function killCommand(pid)
return "kill "
.. pid
.. " 2>/dev/null; n=0; while kill -0 "
.. pid
.. " 2>/dev/null && [ $n -lt 10 ]; do sleep 0.2; n=$((n+1)); done; kill -9 "
.. pid
.. " 2>/dev/null; true"
end
local function stopProcess(output, onStopped)
local pid = livePid(output)
if not pid then
noctalia.removeFile(pidFile(output))
if onStopped then
onStopped()
end
return
end
noctalia.runAsync(killCommand(pid), function()
noctalia.removeFile(pidFile(output))
if onStopped then
onStopped()
end
end, 6000)
end
-- ── Status published to the panel ────────────────────────────────────────────
local function cycleFor(output)
local cycle = data.cycle[output]
if type(cycle) ~= "table" then
return { enabled = false, minutes = 15, order = "sequential" }
end
return {
enabled = cycle.enabled == true,
minutes = tonumber(cycle.minutes) or 15,
order = cycle.order == "random" and "random" or "sequential",
}
end
local function selectionFor(output)
local ids = data.selection[output]
return type(ids) == "table" and ids or {}
end
local function publishStatus()
local outputs = {}
for _, output in ipairs(noctalia.outputs()) do
local name = output.name
local cycle = cycleFor(name)
local remaining = nil
if cycle.enabled then
remaining = math.max(0, cycle.minutes * 60 - (elapsed[name] or 0))
end
outputs[name] = {
current = data.current[name],
selection = selectionFor(name),
cycle_enabled = cycle.enabled,
cycle_minutes = cycle.minutes,
cycle_order = cycle.order,
remaining_seconds = remaining,
restorable = data.saved_wallpaper[name] ~= nil,
}
end
noctalia.state.set(STATUS_KEY, {
outputs = outputs,
options = data.options,
defaults = data.defaults,
sync_colors = syncEnabled(),
})
end
-- ── Applying a wallpaper ─────────────────────────────────────────────────────
local function apply(output, id)
if type(output) ~= "string" or output == "" or type(id) ~= "string" or id == "" then
return
end
data.current[output] = id
elapsed[output] = 0
saveData()
stopProcess(output, function()
launch(output, id)
end)
syncPalette(output, id)
publishStatus()
end
-- ── Cycling ──────────────────────────────────────────────────────────────────
local function seedRng()
local seed = 0
if os and os.time then
seed = os.time()
end
rngState = seed % 2147483647
if rngState <= 0 then
rngState += 2147483646
end
end
local function randomIndex(n)
if n <= 1 then
return 1
end
if math.random then
return math.random(n)
end
rngState = (rngState * 16807) % 2147483647
return (rngState % n) + 1
end
-- Random plays a shuffled pass over the whole selection before reshuffling, so
-- no wallpaper repeats within a pass.
local function nextRandom(output, ids)
local remaining = shuffled[output]
if type(remaining) ~= "table" or #remaining == 0 then
remaining = {}
for _, id in ipairs(ids) do
table.insert(remaining, id)
end
for i = #remaining, 2, -1 do -- Fisher-Yates
local j = randomIndex(i)
remaining[i], remaining[j] = remaining[j], remaining[i]
end
-- A fresh pass must not open on the wallpaper that just finished.
if #remaining > 1 and remaining[1] == data.current[output] then
remaining[1], remaining[#remaining] = remaining[#remaining], remaining[1]
end
shuffled[output] = remaining
end
return table.remove(remaining, 1)
end
local function nextSequential(output, ids)
local current = data.current[output]
for index, id in ipairs(ids) do
if id == current then
return ids[(index % #ids) + 1]
end
end
return ids[1]
end
local function advance(output)
local ids = selectionFor(output)
if #ids == 0 then
return
end
local cycle = cycleFor(output)
local id
if cycle.order == "random" then
id = nextRandom(output, ids)
else
id = nextSequential(output, ids)
end
if id then
apply(output, id)
end
end
-- ── Requests from the panel ──────────────────────────────────────────────────
local function setCycle(output, enabled, minutes, order)
local cycle = cycleFor(output)
if enabled ~= nil then
cycle.enabled = enabled == true
end
if tonumber(minutes) then
cycle.minutes = math.max(1, math.floor(tonumber(minutes)))
end
if order == "random" or order == "sequential" then
cycle.order = order
end
data.cycle[output] = cycle
elapsed[output] = 0
shuffled[output] = nil
saveData()
end
local function handleRequest(request)
if type(request) ~= "table" then
return
end
if request.nonce ~= nil and request.nonce == lastNonce then
return
end
lastNonce = request.nonce
local output = request.output
if type(output) ~= "string" or output == "" then
output = noctalia.focusedOutputName()
end
if type(output) ~= "string" or output == "" then
return
end
local action = request.action
if action == "apply" then
-- A one-shot pick takes over from any cycle on that output.
setCycle(output, false)
apply(output, request.id)
elseif action == "select" then
local ids = {}
if type(request.ids) == "table" then
for _, id in ipairs(request.ids) do
if type(id) == "string" and id ~= "" then
table.insert(ids, id)
end
end
end
data.selection[output] = ids
shuffled[output] = nil
saveData()
publishStatus()
elseif action == "cycle" then
setCycle(output, request.enabled, request.minutes, request.order)
local cycle = cycleFor(output)
if cycle.enabled then
local ids = selectionFor(output)
if #ids == 0 then
setCycle(output, false)
noctalia.notify(tr("panel.title"), tr("panel.cycle_needs_selection"))
else
-- Start on the first pick straight away rather than leaving the
-- previous wallpaper up for a whole interval.
local first = cycle.order == "random" and nextRandom(output, ids) or ids[1]
apply(output, first)
noctalia.notify(
tr("panel.title"),
tr("panel.cycle_started", { count = #ids, minutes = cycle.minutes })
)
end
end
publishStatus()
elseif action == "stop" then
setCycle(output, false)
data.current[output] = nil
saveData()
stopProcess(output, nil)
restoreShellWallpaper(output)
publishStatus()
elseif action == "options" then
-- Whole-table replace, not a merge: the panel owns the form and sends the
-- complete state, so clearing a setting there has to clear it here.
local id = request.id
if type(id) == "string" and id ~= "" then
data.options[id] = {
engine = asTable(request.engine),
properties = asTable(request.properties),
}
saveData()
-- Options are command-line arguments, so they only take effect on a
-- fresh process. Restart any output currently showing this wallpaper.
if request.restart ~= false then
for output, current in pairs(data.current) do
if current == id then
local target = output
stopProcess(target, function()
launch(target, id)
end)
end
end
end
publishStatus()
end
elseif action == "defaults" then
local previous = asTable(data.defaults.engine)
local engine = asTable(request.engine)
data.defaults = { engine = engine }
-- Optionally drop per-wallpaper overrides, but only for the keys whose
-- default actually changed. Settings the user tuned for one wallpaper and
-- did not touch here keep their override.
if request.clear_overrides == true then
for key, value in pairs(engine) do
if previous[key] ~= value then
for _, entry in pairs(data.options) do
if type(entry) == "table" and type(entry.engine) == "table" then
entry.engine[key] = nil
end
end
end
end
for key in pairs(previous) do
if engine[key] == nil then
for _, entry in pairs(data.options) do
if type(entry) == "table" and type(entry.engine) == "table" then
entry.engine[key] = nil
end
end
end
end
end
saveData()
-- Every running wallpaper resolves these, so all of them restart.
if request.restart ~= false then
for output, id in pairs(data.current) do
if type(id) == "string" and id ~= "" then
local target, wallpaper = output, id
stopProcess(target, function()
launch(target, wallpaper)
end)
end
end
end
publishStatus()
elseif action == "status" then
publishStatus()
end
end
-- ── Lifecycle ────────────────────────────────────────────────────────────────
function update()
local switched = false
for _, output in ipairs(noctalia.outputs()) do
local name = output.name
local cycle = cycleFor(name)
if cycle.enabled and #selectionFor(name) > 1 then
elapsed[name] = (elapsed[name] or 0) + TICK_MS / 1000
if elapsed[name] >= cycle.minutes * 60 then
advance(name)
switched = true
end
end
end
if switched then
publishStatus()
end
end
function onIpc(event, payload)
-- Same verbs as the panel, for keybinds:
-- noctalia msg plugin tadomika_ari/w-engine:start all cycle-stop
if event == "cycle-stop" then
handleRequest({ action = "cycle", enabled = false, output = payload })
elseif event == "stop" then
handleRequest({ action = "stop", output = payload })
elseif event == "next" then
local output = payload
if type(output) ~= "string" or output == "" then
output = noctalia.focusedOutputName()
end
if output then
advance(output)
publishStatus()
end
end
end
function onConfigChanged()
-- Enabling the sync mid-session applies the palette for whatever is playing.
if syncEnabled() then
for output, id in pairs(data.current) do
if type(id) == "string" and id ~= "" then
syncPalette(output, id)
end
end
end
publishStatus()
end
noctalia.state.watch(REQUEST_KEY, handleRequest)
-- Sweep orphaned processes and the stale pid files left by a previous load.
--
-- Matches on the process name, which the kernel truncates to 15 characters. The
-- -f form would also match this command's own shell.
local entries = noctalia.listDir("/tmp")
if entries then
for _, entry in ipairs(entries) do
if entry:match("^w%-engine") then
noctalia.removeFile("/tmp/" .. entry)
end
end
end
seedRng()
loadData()
workshopRoot = resolveWorkshopRoot()
noctalia.setUpdateInterval(TICK_MS)
publishStatus()
-- The sweep completes before anything relaunches; its second pass runs a second
-- after the first.
noctalia.runAsync(
"pkill linux-wallpaper 2>/dev/null; sleep 1; pkill -KILL linux-wallpaper 2>/dev/null; true",
function()
-- Bring back whatever was playing before the reload, cycle included.
for _, output in ipairs(noctalia.outputs()) do
local name = output.name
local id = data.current[name]
if type(id) == "string" and id ~= "" then
elapsed[name] = 0
launch(name, id)
end
end
publishStatus()
end,
8000
)
+61 -3
View File
@@ -1,11 +1,69 @@
{
"panel": {
"apply": "Apply wallpaper {id}",
"apply": "Applying {name}",
"apply_options": "Apply",
"back": "Back to wallpapers",
"clear": "Clear",
"clear_overrides": "Also clear per-wallpaper overrides for the settings changed here",
"configure": "Configure this wallpaper",
"cycle_needs_selection": "Select at least one wallpaper before starting a cycle",
"cycle_running": "Cycling {count} wallpapers every {minutes} min",
"cycle_start": "Start cycle",
"cycle_started": "Cycling {count} wallpapers every {minutes} min",
"cycle_stop": "Stop cycle",
"defaults": "Global defaults",
"defaults_tooltip": "Settings applied to every wallpaper",
"engine": {
"clamp": "Clamp mode",
"disable_mouse": "Disable mouse interaction",
"disable_parallax": "Disable parallax",
"disable_particles": "Disable particles",
"fps": "FPS limit",
"fullscreen_pause_only_active": "Pause only for the active fullscreen window",
"inherit": "Use global default",
"layer": "Wayland layer",
"no_audio_processing": "Disable audio processing",
"no_fullscreen_pause": "Keep playing when an app is fullscreen",
"noautomute": "Disable automute",
"scaling": "Scaling mode",
"silent": "Mute this wallpaper",
"unset": "Engine default",
"volume": "Volume"
},
"every": "Every",
"list_failed": "Could not list Wallpaper Engine wallpapers",
"minutes": "min",
"missing_project_json": "No project.json for {name}",
"no_properties": "This wallpaper exposes no configurable properties",
"no_wallpapers": "No Wallpaper Engine wallpapers found",
"title": "W Engine"
"order_random": "Random",
"order_sequential": "In order",
"playback": "Playback",
"properties": "Wallpaper properties",
"reset": "Reset to defaults",
"select_multiple": "Select multiple",
"select_multiple_tooltip": "Pick several wallpapers and cycle through them on a timer",
"selected_count": "{count} selected",
"stop": "Stop",
"stop_tooltip": "Stop the wallpaper on this output and restore the previous Noctalia wallpaper",
"title": "W Engine",
"unlabeled": "Show {count} options the author left unnamed"
},
"plugin_description": "A widget for Wallpaper Engine. Set wallpaper from Wallpaper Engine with simple click.",
"plugin_name": "W Engine"
"plugin_name": "W Engine",
"settings": {
"grid_columns": {
"description": "How many wallpapers to show per row in the panel. The panel itself is a fixed size, so fewer columns means larger previews.",
"label": "Wallpapers per row"
},
"sync_colors": {
"description": "Generate the Noctalia palette from the wallpaper being applied, the same way an ordinary wallpaper does. Uses the Workshop preview, or a decoded frame for video wallpapers when ffmpeg is installed.",
"label": "Sync shell colors with the wallpaper"
}
},
"widget": {
"cycling": "W Engine — cycling wallpapers",
"idle": "W Engine",
"playing": "W Engine — wallpaper running"
}
}
+1275 -206
View File
File diff suppressed because it is too large Load Diff
+35 -5
View File
@@ -1,8 +1,38 @@
function update()
noctalia.setUpdateInterval(1000)
barWidget.setGlyph("movie")
--!nonstrict
-- W-Engine bar widget — opens the panel, and reflects what the service is doing.
local STATUS_KEY = "w_engine_status"
local function refresh(status)
local outputs = type(status) == "table" and status.outputs or nil
local cycling = false
local playing = 0
if type(outputs) == "table" then
for _, state in pairs(outputs) do
if type(state) == "table" then
if state.cycle_enabled then
cycling = true
end
if state.current then
playing += 1
end
end
end
end
barWidget.setGlyph("movie")
if playing == 0 then
barWidget.setTooltip(noctalia.tr("widget.idle"))
elseif cycling then
barWidget.setTooltip(noctalia.tr("widget.cycling"))
else
barWidget.setTooltip(noctalia.tr("widget.playing"))
end
end
function onClick()
noctalia.togglePanel("tadomika_ari/w-engine:w-engine-panel")
end
noctalia.togglePanel("tadomika_ari/w-engine:w-engine-panel")
end
noctalia.state.watch(STATUS_KEY, refresh)
refresh(noctalia.state.get(STATUS_KEY))