From 3a5a744a9a9e13441657a9fb5248c7abc84d0cf4 Mon Sep 17 00:00:00 2001 From: Jack Stehn Date: Wed, 19 Aug 2026 02:48:50 -0700 Subject: [PATCH] daily-wallpaper: add bar widget, panel, and desktop widget for the caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bing and NASA both ship a caption with their photo of the day, but the service only ever used it to build the wallpaper filename before now, discarding the actual text. Captures it at the point each source resolves and publishes it as plugin state, then adds three pure-view surfaces to show it: a bar glyph (click opens the panel), a panel with the full caption/credit/"learn more" link, and a matching desktop widget card. - Bing's `copyright` field is split into a title and a "(© ...)" credit. - NASA's gallery-item caption has no separate credit line; its own article link is used as "learn more" instead of the Bing quiz link. - Metadata is fetched even when the day's image is already cached (e.g. after a process restart), independent of the download step. --- daily-wallpaper/README.md | 34 +++++++++++-- daily-wallpaper/daily_wallpaper.luau | 74 +++++++++++++++++++++++++-- daily-wallpaper/desktop_widget.luau | 37 ++++++++++++++ daily-wallpaper/panel.luau | 75 ++++++++++++++++++++++++++++ daily-wallpaper/plugin.toml | 44 ++++++++++++++-- daily-wallpaper/translations/en.json | 25 ++++++++++ daily-wallpaper/widget.luau | 52 +++++++++++++++++++ 7 files changed, 329 insertions(+), 12 deletions(-) create mode 100644 daily-wallpaper/desktop_widget.luau create mode 100644 daily-wallpaper/panel.luau create mode 100644 daily-wallpaper/widget.luau diff --git a/daily-wallpaper/README.md b/daily-wallpaper/README.md index d8097eda..53060306 100644 --- a/daily-wallpaper/README.md +++ b/daily-wallpaper/README.md @@ -1,14 +1,19 @@ # Daily Wallpaper -Daily Wallpaper fetches Bing's image of the day or NASA's image of the day and -applies it through Noctalia's wallpaper API. +Daily Wallpaper fetches Bing's image of the day or NASA's image of the day, +applies it through Noctalia's wallpaper API, and shows the caption/credit that +came with it — on the bar, in a panel, and on the desktop. ## Plugin | Field | Value | | --- | --- | | ID | `nzlov/daily-wallpaper` | -| Entry | Service: `service` | +| Entries | Service: `service`; bar widget: `widget`; panel: `panel`; desktop widget: `desktop` | + +## Requirements + +Install `xdg-utils` on `PATH` (for the panel's "Learn more" button, via `xdg-open`). ## Usage @@ -19,12 +24,27 @@ per source and Bing locale each day. Choose Bing or NASA under the plugin's settings. Bing accepts a market locale such as `en-US`, `de-DE`, or `fr-FR`; NASA ignores the locale setting. +Add the `widget` bar entry and/or the `desktop` desktop widget from Settings +to see today's caption. Clicking the bar glyph (or running +`noctalia msg panel-toggle nzlov/daily-wallpaper:panel`) opens the full panel +with the caption, credit (Bing only), and a "Learn more" link — Bing's quiz +page, or NASA's full article about the image. + ## Settings | Setting | Type | Default | Description | | --- | --- | --- | --- | | `source` | `select` | `bing` | Selects Bing or NASA as the daily image source. | | `locale` | `string` | *(automatic)* | Bing market locale; an empty value uses the service default. | +| `widget.glyph` | `glyph` | `photo` | Bar icon. | +| `widget.show_text` | `bool` | `true` | Show a truncated caption next to the bar glyph. | +| `widget.max_chars` | `int` | `28` | Max caption length shown on the bar before truncating with an ellipsis. | + +## IPC + +```sh +noctalia msg plugin nzlov/daily-wallpaper:service all refresh +``` ## Notes @@ -32,3 +52,11 @@ The service contacts the selected provider and downloads images into a dedicated `daily-wallpaper` cache directory. It removes cached images older than five days. Repeated failures are logged, but error notifications are limited to once per day. + +Neither source gives a wallpaper-application step any reason to keep the +caption text that comes with the image, so it is captured separately at the +point each source resolves and published as plugin state for the bar widget, +panel, and desktop widget to display. Bing's `copyright` field is one caption +line, e.g. "Runners at the base of Victoria Falls, Zimbabwe (© Byron.../Getty +Images)", which is split into a title and a credit; NASA's gallery-item +caption is a single sentence with no separate credit line. diff --git a/daily-wallpaper/daily_wallpaper.luau b/daily-wallpaper/daily_wallpaper.luau index dce80ede..cb8c426e 100644 --- a/daily-wallpaper/daily_wallpaper.luau +++ b/daily-wallpaper/daily_wallpaper.luau @@ -1,7 +1,12 @@ --!nonstrict -- Daily Wallpaper service for Noctalia 5. -- Fetches one Bing or NASA image per day, caches it locally, and applies it via --- the native Noctalia wallpaper API. +-- the native Noctalia wallpaper API. Also captures each source's caption text +-- (Bing's `copyright` field; NASA's gallery-item caption) and publishes it as +-- "today" state for widget.luau/panel.luau/desktop_widget.luau to display — +-- neither source's raw API gives a wallpaper-application step any reason to +-- keep this text, so it has to be captured here, at the point each source is +-- resolved, or it is lost. local CHECK_INTERVAL_MS = 10 * 60 * 1000 local RETENTION_SECONDS = 5 * 24 * 60 * 60 @@ -49,10 +54,43 @@ end local function decodeHtml(value) local decoded = value:gsub("&", "&") decoded = decoded:gsub(""", '"') - decoded = decoded:gsub("'", "'") + -- Numeric entities generally (covers both ' and ', which NASA's + -- markup uses for apostrophes in captions). + decoded = decoded:gsub("&#(%d+);", function(n) + local code = tonumber(n) + return (code and code < 256) and string.char(code) or "" + end) return decoded end +-- Bing's `copyright` field reads like "Caption text (© Photographer/Source)" — +-- split the trailing parenthesized attribution off into its own field so +-- title and credit can be shown separately. +local function splitCopyright(copyright) + local body, credit = copyright:match("^(.-)%s*%(([^()]*)%)%s*$") + if body and credit then + return decodeHtml(body), decodeHtml(credit) + end + return decodeHtml(copyright), "" +end + +-- Publishes today's caption/credit/"learn more" link for the bar widget, +-- panel, and desktop widget. Independent of whether the image itself still +-- needs downloading — metadata is available as soon as a source resolves. +local function publishToday(selectedSource, date, info) + if type(info) ~= "table" then + return + end + noctalia.state.set("today", { + date = date, + source = selectedSource, + title = info.title or "", + credit = info.credit or "", + link = info.link or "", + }) + noctalia.log(`Daily Wallpaper caption: {info.title or ""}`) +end + local function absoluteNasaUrl(url) if url == nil or url == "" then return "" @@ -170,7 +208,12 @@ local function resolveBing(locale, done) return end - done(`bing-{locale}`, `https://www.bing.com{urlBase}_UHD.jpg`, `https://www.bing.com{urlBase}_1920x1080.jpg`) + local title, credit = splitCopyright(first.copyright or "") + local link = (type(first.copyrightlink) == "string" and first.copyrightlink ~= "") + and first.copyrightlink or "" + + done(`bing-{locale}`, `https://www.bing.com{urlBase}_UHD.jpg`, `https://www.bing.com{urlBase}_1920x1080.jpg`, + { title = title, credit = credit, link = link }) end) if not started then @@ -199,7 +242,16 @@ local function resolveNasa(done) return end - done("nasa", primary, fallback) + -- NASA gives one caption sentence per gallery item and no separate + -- credit line (unlike Bing's "(© ...)" suffix), so title is the whole + -- caption and credit stays empty. The gallery item's own link goes to + -- NASA's full article about the image — a much richer "learn more" + -- target than Bing's quiz page. + local caption = page:match('class="hds%-gallery%-item%-caption[^"]*">([^<]*)') + local link = page:match('class="hds%-gallery%-item%-link[^"]*"%s+href="([^"]+)"') + + done("nasa", primary, fallback, + { title = decodeHtml(caption or ""), credit = "", link = link or "" }) end) if not started then @@ -244,10 +296,22 @@ local function fetchAndApply(force) checking = false cleanupOldWallpapers(dir) applyWallpaper(dest, false) + -- The image was already cached (e.g. from before a restart), but this + -- process may not have captured its caption yet — re-resolve for + -- metadata only, without re-downloading the (already-cached) image. + local function onInfoOnly(_prefix, _primaryUrl, _fallbackUrl, info) + publishToday(selectedSource, date, info) + end + if selectedSource == "nasa" then + resolveNasa(onInfoOnly) + else + resolveBing(locale, onInfoOnly) + end return end - local function onResolved(prefix, primaryUrl, fallbackUrl) + local function onResolved(prefix, primaryUrl, fallbackUrl, info) + publishToday(selectedSource, date, info) local resolvedDest = cachePath(prefix, date) downloadAndApply(primaryUrl, fallbackUrl, resolvedDest) end diff --git a/daily-wallpaper/desktop_widget.luau b/daily-wallpaper/desktop_widget.luau new file mode 100644 index 00000000..fc9a51f7 --- /dev/null +++ b/daily-wallpaper/desktop_widget.luau @@ -0,0 +1,37 @@ +--!nonstrict +-- Desktop widget: a small always-visible card showing today's wallpaper +-- caption and credit. Pure view over daily_wallpaper.luau's "today"/"status" +-- state — static, so no onFrameTick is needed. + +local function tr(key, args) return noctalia.tr(key, args) end + +local WRAP = 220 + +local function render() + local today = noctalia.state.get("today") + local status = noctalia.state.get("status") + + local rows = { + ui.row({ gap = 6, align = "center" }, { + ui.glyph({ name = "photo", size = 16, color = "secondary" }), + ui.label({ text = tr("widget.title"), fontSize = 12, fontWeight = "bold", color = "on_surface_variant" }), + }), + } + + if type(today) == "table" and type(today.title) == "string" and today.title ~= "" then + rows[#rows + 1] = ui.label({ text = today.title, fontSize = 14, fontWeight = "medium", maxWidth = WRAP, maxLines = 4 }) + if today.credit and today.credit ~= "" then + rows[#rows + 1] = ui.label({ text = today.credit, fontSize = 11, opacity = 0.7, maxWidth = WRAP, maxLines = 2 }) + end + else + local msg = (type(status) == "table" and status.state == "error") + and tr("widget.tooltip_error") or tr("widget.tooltip_loading") + rows[#rows + 1] = ui.label({ text = msg, fontSize = 12, opacity = 0.7, maxWidth = WRAP }) + end + + desktopWidget.render(ui.column({ gap = 4, padding = 10 }, rows)) +end + +noctalia.state.watch("today", render) +noctalia.state.watch("status", render) +render() diff --git a/daily-wallpaper/panel.luau b/daily-wallpaper/panel.luau new file mode 100644 index 00000000..24e46b38 --- /dev/null +++ b/daily-wallpaper/panel.luau @@ -0,0 +1,75 @@ +--!nonstrict +-- Daily Wallpaper panel — today's full caption, credit, and a "learn more" +-- link out to the source's own page for it (Bing's quiz page, or NASA's full +-- article). Opens on a bar-widget click, or via +-- `noctalia msg panel-toggle nzlov/daily-wallpaper:panel`. Pure subscriber of +-- daily_wallpaper.luau's "today"/"status" state; the refresh button just asks +-- the service to re-check now (onIpc "refresh"). + +local function tr(key, args) return noctalia.tr(key, args) end + +local WRAP = 380 + +local SOURCE_LABEL = { bing = "Bing", nasa = "NASA" } + +local function shellQuote(s) + return "'" .. s:gsub("'", "'\\''") .. "'" +end + +local function render() + local today = noctalia.state.get("today") + local status = noctalia.state.get("status") + + local rows = { + ui.row({ align = "center", justify = "space_between", gap = 8 }, { + ui.label({ text = tr("panel.title"), fontSize = 16, fontWeight = "bold", flexGrow = 1 }), + ui.button({ glyph = "refresh", variant = "ghost", onClick = "onRefresh" }), + ui.button({ glyph = "close", onClick = "onCloseClicked" }), + }), + ui.separator({}), + } + + if type(today) ~= "table" or type(today.title) ~= "string" or today.title == "" then + local msg = (type(status) == "table" and status.state == "error") + and (status.message or tr("panel.error")) + or tr("panel.loading") + rows[#rows + 1] = ui.label({ text = msg, maxWidth = WRAP, opacity = 0.7 }) + else + rows[#rows + 1] = ui.label({ text = today.title, fontSize = 15, fontWeight = "medium", maxWidth = WRAP }) + if today.credit and today.credit ~= "" then + rows[#rows + 1] = ui.label({ text = today.credit, maxWidth = WRAP, opacity = 0.7 }) + end + local sourceLabel = SOURCE_LABEL[today.source] or today.source or "?" + rows[#rows + 1] = ui.label({ + text = tr("panel.date", { source = sourceLabel, date = today.date }), + maxWidth = WRAP, fontSize = 12, opacity = 0.6, + }) + if today.link and today.link ~= "" then + rows[#rows + 1] = ui.button({ text = tr("panel.learn_more"), variant = "ghost", onClick = "onOpenLink" }) + end + end + + panel.render(ui.column({ padding = 14, gap = 10, flexGrow = 1 }, rows)) +end + +function onOpen(_context) + render() +end + +function onCloseClicked() + panel.close() +end + +function onRefresh() + noctalia.runAsync("noctalia msg plugin 'nzlov/daily-wallpaper:service' all refresh") +end + +function onOpenLink() + local today = noctalia.state.get("today") + if type(today) == "table" and type(today.link) == "string" and today.link ~= "" then + noctalia.runAsync("xdg-open " .. shellQuote(today.link) .. " >/dev/null 2>&1") + end +end + +noctalia.state.watch("today", render) +noctalia.state.watch("status", render) diff --git a/daily-wallpaper/plugin.toml b/daily-wallpaper/plugin.toml index 56ddde89..58174021 100644 --- a/daily-wallpaper/plugin.toml +++ b/daily-wallpaper/plugin.toml @@ -1,13 +1,13 @@ id = "nzlov/daily-wallpaper" name = "Daily Wallpaper" -version = "1.0.0" +version = "1.1.0" plugin_api = 3 author = "nzlov" license = "MIT" -dependencies = [] -tags = ["wallpaper", "desktop"] +dependencies = ["xdg-open"] +tags = ["wallpaper", "desktop", "bar", "panel", "service"] icon = "photo" -description = "Fetches a new daily wallpaper from Bing or NASA." +description = "Fetches a new daily wallpaper from Bing or NASA, and shows its caption on the bar, in a panel, and on the desktop." [[setting]] key = "source" @@ -29,3 +29,39 @@ default = "" [[service]] id = "service" entry = "daily_wallpaper.luau" + +[[widget]] +id = "widget" +entry = "widget.luau" + + [[widget.setting]] + key = "glyph" + type = "glyph" + label_key = "settings.widget.glyph.label" + default = "photo" + + [[widget.setting]] + key = "show_text" + type = "bool" + label_key = "settings.widget.show_text.label" + default = true + + [[widget.setting]] + key = "max_chars" + type = "int" + label_key = "settings.widget.max_chars.label" + default = 28 + min = 8 + max = 80 + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 420 +height = 320 +placement = "attached" +open_near_click = true + +[[desktop_widget]] +id = "desktop" +entry = "desktop_widget.luau" diff --git a/daily-wallpaper/translations/en.json b/daily-wallpaper/translations/en.json index b6e215f2..92ea385e 100644 --- a/daily-wallpaper/translations/en.json +++ b/daily-wallpaper/translations/en.json @@ -14,6 +14,31 @@ "bing": "Bing", "nasa": "NASA" } + }, + "widget": { + "glyph": { + "label": "Glyph" + }, + "show_text": { + "label": "Show caption text next to glyph" + }, + "max_chars": { + "label": "Max caption characters on the bar" + } } + }, + "widget": { + "title": "Daily Wallpaper", + "loading_short": "Loading…", + "tooltip_loading": "Fetching today's wallpaper caption…", + "tooltip_error": "Could not fetch today's wallpaper caption.", + "panel_launch_failed": "Could not open the Daily Wallpaper panel." + }, + "panel": { + "title": "Daily Wallpaper", + "loading": "Fetching today's wallpaper caption…", + "error": "Could not fetch today's wallpaper caption.", + "date": "{source} · {date}", + "learn_more": "Learn more" } } diff --git a/daily-wallpaper/widget.luau b/daily-wallpaper/widget.luau new file mode 100644 index 00000000..c236dc02 --- /dev/null +++ b/daily-wallpaper/widget.luau @@ -0,0 +1,52 @@ +--!nonstrict +-- Bar widget: a glyph (+ optional short caption text) reflecting today's +-- wallpaper. Pure view over daily_wallpaper.luau's "today"/"status" state. +-- Click opens the panel with the full caption, credit, and "learn more" link. + +local function tr(key, args) return noctalia.tr(key, args) end + +local glyph = noctalia.getConfig("glyph") or "photo" +local showText = noctalia.getConfig("show_text") +local maxChars = tonumber(noctalia.getConfig("max_chars")) or 28 + +local today = noctalia.state.get("today") +local status = noctalia.state.get("status") + +local function truncate(text) + if #text <= maxChars then + return text + end + return text:sub(1, maxChars - 1) .. "…" +end + +local function render() + barWidget.setGlyph(glyph) + + if type(today) == "table" and type(today.title) == "string" and today.title ~= "" then + barWidget.setText(showText and truncate(today.title) or "") + barWidget.setTooltip(today.title) + else + barWidget.setText(showText and tr("widget.loading_short") or "") + local msg = (type(status) == "table" and status.state == "error") + and (status.message or tr("widget.tooltip_error")) + or tr("widget.tooltip_loading") + barWidget.setTooltip(msg) + end +end + +noctalia.state.watch("today", function(value) + today = value + render() +end) +noctalia.state.watch("status", function(value) + status = value + render() +end) + +function onClick() + if not noctalia.runAsync("noctalia msg panel-toggle 'nzlov/daily-wallpaper:panel'") then + noctalia.notifyError(tr("widget.title"), tr("widget.panel_launch_failed")) + end +end + +render()