diff --git a/w-engine/README.md b/w-engine/README.md index d0e94a3..7560cbe 100644 --- a/w-engine/README.md +++ b/w-engine/README.md @@ -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 `
` 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 diff --git a/w-engine/plugin.toml b/w-engine/plugin.toml index 4141d0b..61ae557 100644 --- a/w-engine/plugin.toml +++ b/w-engine/plugin.toml @@ -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" \ No newline at end of file +entry = "start.luau" diff --git a/w-engine/start.luau b/w-engine/start.luau index d2153e5..0089bdb 100644 --- a/w-engine/start.luau +++ b/w-engine/start.luau @@ -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 \ No newline at end of file +-- ── 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 +) diff --git a/w-engine/translations/en.json b/w-engine/translations/en.json index 402af90..9792c64 100644 --- a/w-engine/translations/en.json +++ b/w-engine/translations/en.json @@ -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" + } } diff --git a/w-engine/w-engine-panel.luau b/w-engine/w-engine-panel.luau index e9da910..265994d 100644 --- a/w-engine/w-engine-panel.luau +++ b/w-engine/w-engine-panel.luau @@ -1,263 +1,1332 @@ --!nonstrict --- W-Engine panel — picks a Wallpaper Engine workshop item and applies it via --- linux-wallpaperengine. - --- Local variable - -wallpaperPath = {"~/.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/"} -wallpaperPathSelected = nil - -local pickSlots = {} -local columns = 4 -local previewWidth = 180 -local previewHeight = 100 - -local outputFinalList = {} -local outputSelectedName = nil -local lastOutputUse = nil - -local pidList: { killSystem } = {} - --- load personnal path - -local dir = noctalia.pluginDataDir() -local path = dir .. "/data.json" -local raw = noctalia.readFile(path) -if raw then - local data = noctalia.json.decode(raw) - if data and data.personnalPath then - for _, p in ipairs(data.personnalPath) do - table.insert(wallpaperPath, p) - end - end -end - +-- 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, - preview: string, + path: string, } -type killSystem = { - pid: number, - output: string, -} +local thumbsRequested = false -function tr(key, subst) +-- 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 -function takeWallpaper() - local wallpaper: { Wallpaper } = {} +-- ── Workshop discovery ─────────────────────────────────────────────────────── - for count = 1, #wallpaperPath, 1 do - if noctalia.listDir(wallpaperPath[count]) then - wallpaperPathSelected = wallpaperPath[count] +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 - local entries, err = noctalia.listDir(wallpaperPathSelected) - if not entries then - warn(err or "impossible de lister") +-- 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 wallpaper + 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 not tonumber(entryName) then -- test if is a id - continue - end - - local content, readErr = noctalia.readFile(wallpaperPathSelected .. entryName .. "/project.json") - if content then - local value = noctalia.json.decode(content) - local name = value.title or entryName - local preview = value.preview or "no preview" - table.insert(wallpaper, { - nb = entryName, - name = name, - preview = preview, - }) - else - noctalia.notify(tr("panel.title"), tr("panel.missing_project_json", { name = entryName })) - end - end - return wallpaper -end - -function trim(s) -- personnal trim - return (s:gsub("^%s+", ""):gsub("%s+$", "")) -end - -function isValidPid(screen, pid) - local path = "/tmp/w-engine-" .. screen .. ".pid" - if noctalia.fileExists(path) then - local fileTmp = noctalia.readFile(path) - if not fileTmp then - return false - end - local file = trim(fileTmp) - if file == pid then - - local check = "/proc/" .. pid .. "/cmdline" - local checkContentTmp = noctalia.readFile(check) - if not checkContentTmp then - return false - end - local checkContent = trim(checkContentTmp) - local match = "^linux%-wallpaperengine" - if checkContent:match(match) then - return true + 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 - return false + + if missing then + requestThumbnails(thumbs) + end + return items end -function getStoredPid(screen) - for _, entry in ipairs(pidList) do - if entry.output == screen then - if isValidPid(screen, entry.pid) then - return entry.pid - 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 -function setStoredPid(screen, pid) - for _, entry in ipairs(pidList) do - if entry.output == screen then - entry.pid = pid - return - end - end - table.insert(pidList, { pid = pid, output = screen }) -end - - -function launchWallpaper(screen, id, pidFile) - local cmd = "setsid linux-wallpaperengine --screen-root " .. screen .. " " .. id - .. " > /dev/null 2>&1 & echo $! > " .. pidFile - - noctalia.runAsync(cmd, function(result) - local pidContent = noctalia.readFile(pidFile) - if pidContent then - local pid = trim(pidContent) - setStoredPid(screen, pid) - end - end, 5000) -end - -function loadWallpaper(id) - lastOutputUse = outputSelectedName or takeOutput() - local pidFile = "/tmp/w-engine-" .. lastOutputUse .. ".pid" - local oldPid = getStoredPid(lastOutputUse) -- here - if oldPid then - noctalia.runAsync("kill " .. oldPid, function() -- here - launchWallpaper(lastOutputUse, id, pidFile) - end, 3000) +local function toggleSelection(id) + local index = selectionIndex(id) + if index then + table.remove(selected, index) else - launchWallpaper(lastOutputUse, id, pidFile) + table.insert(selected, id) end + send({ action = "select", ids = selected }) end -function onPickAt(slot) - local id = pickSlots[slot] - if id ~= nil then - loadWallpaper(id) - noctalia.notify(tr("panel.title"), tr("panel.apply", { id = id })) +-- ── 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 = { + [" "] = " ", + ["&"] = "&", + ["<"] = "<", + [">"] = ">", + ["""] = '"', + ["'"] = "'", + ["'"] = "'", +} + +-- Property labels are authored as HTML:
separates a translated label from +-- its English counterpart, and some carry , or
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 render() - pickSlots = {} - local tiles = {} - local list = takeWallpaper() - for count = 1, #list, 1 do - local data = list[count] - local slotIndex = count - 1 - pickSlots[slotIndex] = data.nb - table.insert(tiles, ui.column({ gap = 4, key = data.nb, align = "center" }, { - ui.image({ - path = wallpaperPathSelected .. data.nb .. "/" .. data.preview, - width = previewWidth, - height = previewHeight, - fit = "cover", - radius = 8, - onClick = function() - loadWallpaper(pickSlots[slotIndex]) - noctalia.notify(tr("panel.title"), tr("panel.apply", { id = pickSlots[slotIndex] })) - end, - }), - ui.label({ - text = data.name, - fontSize = 11, - maxLines = 1, - maxWidth = 120, - textAlign = "center", +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 i, tile in ipairs(tiles) do - table.insert(row, tile) - if #row == columns or i == #tiles then + 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 - panel.render(ui.column({ flexGrow = 1, gap = 16 }, { - ui.row({ align = "center", justify = "space_between", gap = 8 }, { - ui.label({ text = tr("panel.title"), align = "center", fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }), - ui.select({ options = getOutput(), onChange = "outputSelected" }), - ui.button({ glyph = "close", onClick = "onCloseClicked" }), - }), - ui.scroll({ gap = 12, align = "stretch", flexGrow = 1 }, rows), - })) -end - -function takeOutput() - local output = noctalia.outputs() - for _, outputF in ipairs(output) do - return outputF.name end -end -function outputSelected(index, label) + 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 - outputSelectedName = label - noctalia.notify("Output change :", outputSelectedName) + outputName = label + adoptStatus() + render() end end -function getOutput() - outputFinalList = {} - local outputsList = noctalia.outputs() - if not outputsList then - return - end - for _, output in ipairs(outputsList) do - noctalia.log(output.name) - table.insert(outputFinalList, output.name) - end - return outputFinalList +function onMinutesChange(value) + minutesText = tostring(value or "") end -function onOpen(_context) - outputSelectedName = takeOutput() - render() +function onOrderChange(index, _label) + order = ORDER_VALUES[(math.floor(tonumber(index) or 0)) + 1] or "sequential" end function onCloseClicked() panel.close() -end \ No newline at end of file +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) diff --git a/w-engine/w-engine.luau b/w-engine/w-engine.luau index d6f1b80..f9e1b2a 100644 --- a/w-engine/w-engine.luau +++ b/w-engine/w-engine.luau @@ -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 \ No newline at end of file + noctalia.togglePanel("tadomika_ari/w-engine:w-engine-panel") +end + +noctalia.state.watch(STATUS_KEY, refresh) +refresh(noctalia.state.get(STATUS_KEY))