From b0d3c102d9d39a57356a3f1b3c13082d69e6c499 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Fri, 18 Sep 2026 14:56:46 -0400 Subject: [PATCH 1/2] feat(wallpapers-bing): restore preferred-region picker Adds a Bing Preferred Region SelectionRow to get_settings_widget() -- 14 options (All Markets, No Preference, plus 13 individual markets labelled Region - Market), reading/writing ~/.config/ncz-wallpaper/bing-markets, the same file cix-installer's 45-wallpaper-rotator.sh ncz-wallpaper-bing helper already reads. Ports a complete, working implementation (fa5a38a on singularity-shell, zoder-reviewed, full ninja+meson test pass) that lived inline on the Desktop settings page before the plugin-based wallpaper manager (132efb7) deleted it as collateral damage of that rewrite -- it never had a new home in the plugin architecture. Every market is always fetched and combined by the helper regardless of this setting; the picker only chooses whose caption/credit wins when the same photo is shared across regions. Verified: full ninja -C builddir build succeeds (0 errors) against a fresh subproject checkout of libsingularity. plugin's settings widget, adjusted class/field placement for the plugin context. Assisted-by: Claude Code:claude-sonnet-5 AI-Scope: Recovered and ported the pre-deletion 320-line Bing preferred-market SelectionRow picker (from before upstream commit 132efb7 removed it) into the new wallpapers-bing plugin's settings widget. --- wallpapers-bing/wallpapers_bing.vala | 198 +++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/wallpapers-bing/wallpapers_bing.vala b/wallpapers-bing/wallpapers_bing.vala index dc75e00..08d127a 100644 --- a/wallpapers-bing/wallpapers_bing.vala +++ b/wallpapers-bing/wallpapers_bing.vala @@ -2,6 +2,7 @@ using GLib; using Gtk; using Gee; using Singularity; +using Singularity.Widgets; using Peas; [ModuleInit] @@ -61,9 +62,41 @@ namespace WallpapersBing { } } +// Bing market row: 2-letter market code, UI display label, and region +// bucket ("Americas" / "Europe" / "Asia-Pacific") used as the label +// prefix in the settings SelectionRow's option list. Plain GLib.Object +// rather than a struct so it can be stored in a Gee.ArrayList (Vala +// disallows array types as generic type arguments). +private class BingMarketEntry : GLib.Object { + public string code { get; set; } + public string label { get; set; } + public string region { get; set; } +} + public class WallpapersBingPlugin : Object, Singularity.Plugin { private PluginContext context; private WallpapersBing.Provider? provider; + private const string BING_MARKETS_ID_ALL = "all"; + // 13 markets, grouped by region. Order matches the comment block in + // cix-installer/post-install/45-wallpaper-rotator.sh's + // ncz-wallpaper-bing (Americas, Europe, Asia-Pacific). + // [0] = market code, [1] = display label, [2] = region header. + private const string BING_MARKETS_TABLE = "en-US\tUnited States\tAmericas" + + "|en-CA\tCanada English\tAmericas" + + "|fr-CA\tCanada French\tAmericas" + + "|pt-BR\tBrazil\tAmericas" + + "|en-GB\tUnited Kingdom\tEurope" + + "|fr-FR\tFrance\tEurope" + + "|de-DE\tGermany\tEurope" + + "|es-ES\tSpain\tEurope" + + "|it-IT\tItaly\tEurope" + + "|en-IN\tIndia\tAsia-Pacific" + + "|ja-JP\tJapan\tAsia-Pacific" + + "|zh-CN\tChina\tAsia-Pacific" + + "|ko-KR\tSouth Korea\tAsia-Pacific"; + private Gee.ArrayList bing_markets_rows = new Gee.ArrayList(); + private SelectionRow? bing_markets_row = null; + private bool bing_markets_updating = false; public void activate(PluginContext ctx) { this.context = ctx; @@ -84,6 +117,171 @@ public class WallpapersBingPlugin : Object, Singularity.Plugin { lbl.wrap = true; lbl.xalign = 0; box.append(lbl); + + // Preferred-region selector. Every market is always fetched and + // combined by the ncz-wallpaper-bing helper; this only sets which + // region's copy of a duplicate photo wins the caption/credit when + // the same photo is shared across regions -- see + // ~/.config/ncz-wallpaper/bing-markets, which + // 45-wallpaper-rotator.sh's ncz-wallpaper-bing reads verbatim. + // A single inline SelectionRow (not a popup dialog): "All + // Markets, No Preference" first, then all 13 markets from + // BING_MARKETS_TABLE labelled " - " so the region + // grouping survives as label text without section headers. + init_bing_markets_table(); + var bing_markets_options = new Gee.ArrayList(); + bing_markets_options.add(new Singularity.Core.AppSettingOption() { id = BING_MARKETS_ID_ALL, label = _("All Markets, No Preference") }); + foreach (var market in bing_markets_rows) { + bing_markets_options.add(new Singularity.Core.AppSettingOption() { + id = market.code, label = "%s - %s".printf(market.region, market.label) }); + } + // Initial selection reflects the file: "all" or absent = All + // Markets; otherwise the first configured market code (a + // preference is singular). Falls back to "all" if the file names + // a code that isn't in the current table, so the row always + // opens on a real entry. + string bing_markets_current = BING_MARKETS_ID_ALL; + if (!bing_markets_file_is_all()) { + string[] configured = bing_markets_read_codes(); + if (configured.length > 0) { + foreach (var opt in bing_markets_options) { + if (opt.id == configured[0]) { bing_markets_current = configured[0]; break; } + } + } + } + bing_markets_row = new SelectionRow.with_options(_("Bing Preferred Region"), bing_markets_options, + bing_markets_current); + bing_markets_row.subtitle = _("Bing always combines every region's photo of the day; this only picks whose caption and credit win when the same photo is shared"); + bing_markets_row.selected.connect((id) => { + if (bing_markets_updating || bing_markets_row == null) return; + if (id == BING_MARKETS_ID_ALL) { + write_bing_markets_all(); + } else { + write_bing_markets_codes({id}); + } + }); + box.append(bing_markets_row); return box; } + + // Lower-case an ASCII string. Vala's GLib string has no public + // lowercase() (only casefold(), which is Unicode-aware and therefore + // locale-sensitive -- the bing-market codes are all ISO 639-1 + + // ISO 3166-1 letters, so a literal ASCII fold is both correct and + // cheaper). + private static string ascii_lower(string s) { + string out = ""; + for (int i = 0; i < s.length; i++) { + char c = s[i]; + if (c >= 'A' && c <= 'Z') c = (char)(c + 32); + out += c.to_string(); + } + return out; + } + + // Parse BING_MARKETS_TABLE into bing_markets_rows ({code, label, + // region}), in table order. Called once per get_settings_widget() + // build, before the SelectionRow option list is built from it. + private void init_bing_markets_table() { + bing_markets_rows.clear(); + foreach (string entry in BING_MARKETS_TABLE.split("|")) { + string[] cols = entry.split("\t"); + if (cols.length != 3) continue; + var row = new BingMarketEntry() { code = cols[0], label = cols[1], region = cols[2] }; + bing_markets_rows.add(row); + } + } + + // Full path to the bing-markets file the cix-installer rotator + // already reads. Lives under XDG_CONFIG_HOME so it tracks the user + // even when $HOME is relocated for test sessions. + private string bing_markets_file_path() { + return GLib.Path.build_filename( + GLib.Environment.get_user_config_dir(), + "ncz-wallpaper", + "bing-markets"); + } + + // Read the bing-markets file and report whether its content + // (trimmed, lowercased) is the "all" sentinel -- i.e. "no preferred + // region". Absent file also returns true so the SelectionRow starts + // on "All Markets, No Preference" on a fresh install, matching the + // rotator's own default (preferred_market() in + // 45-wallpaper-rotator.sh returns None for an absent file too). + private bool bing_markets_file_is_all() { + string path = bing_markets_file_path(); + if (!FileUtils.test(path, FileTest.EXISTS)) return true; + string text; + try { + FileUtils.get_contents(path, out text); + } catch (Error e) { + return true; + } + return ascii_lower(text.strip()) == "all"; + } + + // Read the bing-markets file and return the configured codes as an + // array. "all" (any case) or absent -> empty list. Otherwise split + // on any of whitespace/comma and keep tokens matching the + // 2-letter-2-letter market pattern, preserving file order. Only the + // FIRST entry is ever honoured as the preference, but every matched + // token is returned so a legacy multi-market file degrades to "the + // first one wins" rather than silently losing the whole value. + private string[] bing_markets_read_codes() { + string path = bing_markets_file_path(); + if (!FileUtils.test(path, FileTest.EXISTS)) return {}; + string text; + try { + FileUtils.get_contents(path, out text); + } catch (Error e) { + return {}; + } + if (ascii_lower(text.strip()) == "all") return {}; + string[] codes = {}; + string[] seen = {}; + foreach (string tok in text.strip().split_set(" \t\n,")) { + if (tok.length == 0) continue; + if (tok.length != 5 || tok[2] != '-') continue; + bool dup = false; + foreach (string existing in seen) if (existing == tok) { dup = true; break; } + if (dup) continue; + seen += tok; + codes += tok; + } + return codes; + } + + // Atomic write of a single-line contents string to the bing-markets + // file, so the daemon (which polls the file) never reads a + // half-flushed value. Creates the directory if absent. Silent on + // failure -- the daemon's default kicks in if the file is missing. + private void write_bing_markets_contents(string contents) { + string path = bing_markets_file_path(); + string dir = GLib.Path.get_dirname(path); + try { + GLib.DirUtils.create_with_parents(dir, 0700); + string tmp = path + ".tmp"; + FileUtils.set_contents(tmp, contents); + if (FileUtils.rename(tmp, path) != 0) { + warning("bing markets: could not rename %s into place", path); + } + } catch (Error e) { + warning("bing markets: could not write %s: %s", path, e.message); + } + } + + private void write_bing_markets_all() { + write_bing_markets_contents("all\n"); + } + + // Write the chosen preferred market as a single line. Empty list -> + // fall back to "all" rather than an empty file, making the user's + // "no preferred region" intent explicit on disk. + private void write_bing_markets_codes(string[] codes) { + if (codes.length == 0) { + write_bing_markets_all(); + return; + } + write_bing_markets_contents(string.joinv(" ", codes) + "\n"); + } } From d1ef213e8716100512fd97660d0f1b9047f8486f Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sun, 27 Sep 2026 10:35:54 -0400 Subject: [PATCH 2/2] fix(wallpapers-bing): delegate market settings Assisted-by: Codex:gpt-5.6-sol AI-Scope: Updated the Bing market picker to load and persist settings through the wallpaper helper. --- wallpapers-bing/wallpapers_bing.vala | 214 +++++---------------------- 1 file changed, 34 insertions(+), 180 deletions(-) diff --git a/wallpapers-bing/wallpapers_bing.vala b/wallpapers-bing/wallpapers_bing.vala index 08d127a..1e005f5 100644 --- a/wallpapers-bing/wallpapers_bing.vala +++ b/wallpapers-bing/wallpapers_bing.vala @@ -34,6 +34,15 @@ namespace WallpapersBing { public bool requires_credentials { get { return false; } } public bool supports_search { get { return false; } } public Provider() { base("/usr/local/bin/ncz-wallpaper-bing"); } + public async string market_list(Cancellable? cancel) throws Error { + return yield command({helper, "markets"}, cancel, 30); + } + public async string preferred_market(Cancellable? cancel) throws Error { + return (yield command({helper, "market"}, cancel, 30)).strip(); + } + public async void set_preferred_market(string market, Cancellable? cancel) throws Error { + yield command({helper, "market", market}, cancel, 30); + } public async ArrayList choices(string index, Cancellable? cancel) throws Error { var loaded = WallpaperBing.markets(yield command({helper, "markets"}, cancel, 30)); // The helper is expected to always advertise the combined view @@ -62,41 +71,10 @@ namespace WallpapersBing { } } -// Bing market row: 2-letter market code, UI display label, and region -// bucket ("Americas" / "Europe" / "Asia-Pacific") used as the label -// prefix in the settings SelectionRow's option list. Plain GLib.Object -// rather than a struct so it can be stored in a Gee.ArrayList (Vala -// disallows array types as generic type arguments). -private class BingMarketEntry : GLib.Object { - public string code { get; set; } - public string label { get; set; } - public string region { get; set; } -} - public class WallpapersBingPlugin : Object, Singularity.Plugin { private PluginContext context; private WallpapersBing.Provider? provider; private const string BING_MARKETS_ID_ALL = "all"; - // 13 markets, grouped by region. Order matches the comment block in - // cix-installer/post-install/45-wallpaper-rotator.sh's - // ncz-wallpaper-bing (Americas, Europe, Asia-Pacific). - // [0] = market code, [1] = display label, [2] = region header. - private const string BING_MARKETS_TABLE = "en-US\tUnited States\tAmericas" - + "|en-CA\tCanada English\tAmericas" - + "|fr-CA\tCanada French\tAmericas" - + "|pt-BR\tBrazil\tAmericas" - + "|en-GB\tUnited Kingdom\tEurope" - + "|fr-FR\tFrance\tEurope" - + "|de-DE\tGermany\tEurope" - + "|es-ES\tSpain\tEurope" - + "|it-IT\tItaly\tEurope" - + "|en-IN\tIndia\tAsia-Pacific" - + "|ja-JP\tJapan\tAsia-Pacific" - + "|zh-CN\tChina\tAsia-Pacific" - + "|ko-KR\tSouth Korea\tAsia-Pacific"; - private Gee.ArrayList bing_markets_rows = new Gee.ArrayList(); - private SelectionRow? bing_markets_row = null; - private bool bing_markets_updating = false; public void activate(PluginContext ctx) { this.context = ctx; @@ -117,171 +95,47 @@ public class WallpapersBingPlugin : Object, Singularity.Plugin { lbl.wrap = true; lbl.xalign = 0; box.append(lbl); + load_bing_markets.begin(box); + return box; + } - // Preferred-region selector. Every market is always fetched and - // combined by the ncz-wallpaper-bing helper; this only sets which - // region's copy of a duplicate photo wins the caption/credit when - // the same photo is shared across regions -- see - // ~/.config/ncz-wallpaper/bing-markets, which - // 45-wallpaper-rotator.sh's ncz-wallpaper-bing reads verbatim. - // A single inline SelectionRow (not a popup dialog): "All - // Markets, No Preference" first, then all 13 markets from - // BING_MARKETS_TABLE labelled " - " so the region - // grouping survives as label text without section headers. - init_bing_markets_table(); + private async void load_bing_markets(Box box) { + if (provider == null) return; var bing_markets_options = new Gee.ArrayList(); bing_markets_options.add(new Singularity.Core.AppSettingOption() { id = BING_MARKETS_ID_ALL, label = _("All Markets, No Preference") }); - foreach (var market in bing_markets_rows) { - bing_markets_options.add(new Singularity.Core.AppSettingOption() { - id = market.code, label = "%s - %s".printf(market.region, market.label) }); - } - // Initial selection reflects the file: "all" or absent = All - // Markets; otherwise the first configured market code (a - // preference is singular). Falls back to "all" if the file names - // a code that isn't in the current table, so the row always - // opens on a real entry. string bing_markets_current = BING_MARKETS_ID_ALL; - if (!bing_markets_file_is_all()) { - string[] configured = bing_markets_read_codes(); - if (configured.length > 0) { - foreach (var opt in bing_markets_options) { - if (opt.id == configured[0]) { bing_markets_current = configured[0]; break; } + try { + foreach (string line in (yield provider.market_list(null)).split("\n")) { + string[] columns = line.split("\t"); + if (columns.length < 2 || columns[0].strip() == "") continue; + bing_markets_options.add(new Singularity.Core.AppSettingOption() { + id = columns[0].strip(), label = columns[1].strip() }); + } + string configured = yield provider.preferred_market(null); + if (configured.ascii_down() != BING_MARKETS_ID_ALL) { + foreach (var option in bing_markets_options) { + if (option.id == configured) bing_markets_current = configured; } } + } catch (Error e) { + warning("wallpapers-bing: could not load market settings: %s", e.message); + return; } - bing_markets_row = new SelectionRow.with_options(_("Bing Preferred Region"), bing_markets_options, + var bing_markets_row = new SelectionRow.with_options(_("Bing Preferred Region"), bing_markets_options, bing_markets_current); bing_markets_row.subtitle = _("Bing always combines every region's photo of the day; this only picks whose caption and credit win when the same photo is shared"); bing_markets_row.selected.connect((id) => { - if (bing_markets_updating || bing_markets_row == null) return; - if (id == BING_MARKETS_ID_ALL) { - write_bing_markets_all(); - } else { - write_bing_markets_codes({id}); - } + persist_bing_market.begin(id); }); box.append(bing_markets_row); - return box; - } - - // Lower-case an ASCII string. Vala's GLib string has no public - // lowercase() (only casefold(), which is Unicode-aware and therefore - // locale-sensitive -- the bing-market codes are all ISO 639-1 + - // ISO 3166-1 letters, so a literal ASCII fold is both correct and - // cheaper). - private static string ascii_lower(string s) { - string out = ""; - for (int i = 0; i < s.length; i++) { - char c = s[i]; - if (c >= 'A' && c <= 'Z') c = (char)(c + 32); - out += c.to_string(); - } - return out; - } - - // Parse BING_MARKETS_TABLE into bing_markets_rows ({code, label, - // region}), in table order. Called once per get_settings_widget() - // build, before the SelectionRow option list is built from it. - private void init_bing_markets_table() { - bing_markets_rows.clear(); - foreach (string entry in BING_MARKETS_TABLE.split("|")) { - string[] cols = entry.split("\t"); - if (cols.length != 3) continue; - var row = new BingMarketEntry() { code = cols[0], label = cols[1], region = cols[2] }; - bing_markets_rows.add(row); - } } - // Full path to the bing-markets file the cix-installer rotator - // already reads. Lives under XDG_CONFIG_HOME so it tracks the user - // even when $HOME is relocated for test sessions. - private string bing_markets_file_path() { - return GLib.Path.build_filename( - GLib.Environment.get_user_config_dir(), - "ncz-wallpaper", - "bing-markets"); - } - - // Read the bing-markets file and report whether its content - // (trimmed, lowercased) is the "all" sentinel -- i.e. "no preferred - // region". Absent file also returns true so the SelectionRow starts - // on "All Markets, No Preference" on a fresh install, matching the - // rotator's own default (preferred_market() in - // 45-wallpaper-rotator.sh returns None for an absent file too). - private bool bing_markets_file_is_all() { - string path = bing_markets_file_path(); - if (!FileUtils.test(path, FileTest.EXISTS)) return true; - string text; - try { - FileUtils.get_contents(path, out text); - } catch (Error e) { - return true; - } - return ascii_lower(text.strip()) == "all"; - } - - // Read the bing-markets file and return the configured codes as an - // array. "all" (any case) or absent -> empty list. Otherwise split - // on any of whitespace/comma and keep tokens matching the - // 2-letter-2-letter market pattern, preserving file order. Only the - // FIRST entry is ever honoured as the preference, but every matched - // token is returned so a legacy multi-market file degrades to "the - // first one wins" rather than silently losing the whole value. - private string[] bing_markets_read_codes() { - string path = bing_markets_file_path(); - if (!FileUtils.test(path, FileTest.EXISTS)) return {}; - string text; - try { - FileUtils.get_contents(path, out text); - } catch (Error e) { - return {}; - } - if (ascii_lower(text.strip()) == "all") return {}; - string[] codes = {}; - string[] seen = {}; - foreach (string tok in text.strip().split_set(" \t\n,")) { - if (tok.length == 0) continue; - if (tok.length != 5 || tok[2] != '-') continue; - bool dup = false; - foreach (string existing in seen) if (existing == tok) { dup = true; break; } - if (dup) continue; - seen += tok; - codes += tok; - } - return codes; - } - - // Atomic write of a single-line contents string to the bing-markets - // file, so the daemon (which polls the file) never reads a - // half-flushed value. Creates the directory if absent. Silent on - // failure -- the daemon's default kicks in if the file is missing. - private void write_bing_markets_contents(string contents) { - string path = bing_markets_file_path(); - string dir = GLib.Path.get_dirname(path); + private async void persist_bing_market(string market) { + if (provider == null) return; try { - GLib.DirUtils.create_with_parents(dir, 0700); - string tmp = path + ".tmp"; - FileUtils.set_contents(tmp, contents); - if (FileUtils.rename(tmp, path) != 0) { - warning("bing markets: could not rename %s into place", path); - } + yield provider.set_preferred_market(market, null); } catch (Error e) { - warning("bing markets: could not write %s: %s", path, e.message); - } - } - - private void write_bing_markets_all() { - write_bing_markets_contents("all\n"); - } - - // Write the chosen preferred market as a single line. Empty list -> - // fall back to "all" rather than an empty file, making the user's - // "no preferred region" intent explicit on disk. - private void write_bing_markets_codes(string[] codes) { - if (codes.length == 0) { - write_bing_markets_all(); - return; + warning("wallpapers-bing: could not save preferred market: %s", e.message); } - write_bing_markets_contents(string.joinv(" ", codes) + "\n"); } }