diff --git a/meson.build b/meson.build index 13374ff..19510fd 100644 --- a/meson.build +++ b/meson.build @@ -282,6 +282,10 @@ singularity_core_sources = files( 'src/core/preview_cache.vala', 'src/core/extreme_mode_manager.vala', 'src/core/wallpaper_manager.vala', + 'src/core/wallpaper_collections.vala', + 'src/core/wallpaper_gallery.vala', + 'src/core/wallpaper_rotation_state.vala', + 'src/core/wallpaper_rotator.vala', 'src/core/wayland_gamma_backend.vala', 'src/core/shortcut_manager.vala', 'src/core/ush_portal.vala', @@ -442,3 +446,29 @@ safe_mode_test = executable('safe-mode-test', dependencies: [dependency('gobject-2.0')], ) test('safe-mode', safe_mode_test) + +wallpaper_collections_test = executable('wallpaper-collections-test', + sources: ['src/core/wallpaper_collections.vala', 'tests/wallpaper_collections_test.vala'], + dependencies: [dependency('gobject-2.0'), dependency('glib-2.0'), dependency('gio-2.0'), gee_dep], +) +test('wallpaper-collections', wallpaper_collections_test) + +wallpaper_rotation_state_test = executable('wallpaper-rotation-state-test', + sources: ['src/core/wallpaper_rotation_state.vala', 'tests/wallpaper_rotation_state_test.vala'], + dependencies: [dependency('gobject-2.0'), dependency('glib-2.0'), dependency('gio-2.0')], +) +test('wallpaper-rotation-state', wallpaper_rotation_state_test) + +wallpaper_gallery_test = executable('wallpaper-gallery-test', + sources: ['src/core/wallpaper_gallery.vala', 'tests/wallpaper_gallery_test.vala'], + dependencies: [dependency('gobject-2.0'), dependency('gio-2.0'), gee_dep], +) +test('wallpaper-gallery', wallpaper_gallery_test) + +wallpaper_rotator_test = executable('wallpaper-rotator-test', + sources: ['src/core/wallpaper_collections.vala', 'src/core/wallpaper_gallery.vala', + 'src/core/wallpaper_rotation_state.vala', 'src/core/wallpaper_rotator.vala', + 'tests/wallpaper_rotator_test.vala'], + dependencies: [dependency('gobject-2.0'), dependency('glib-2.0'), dependency('gio-2.0'), gee_dep], +) +test('wallpaper-rotator', wallpaper_rotator_test) diff --git a/src/components/sidebar/pages/desktop_page.vala b/src/components/sidebar/pages/desktop_page.vala index 52576af..6127510 100644 --- a/src/components/sidebar/pages/desktop_page.vala +++ b/src/components/sidebar/pages/desktop_page.vala @@ -5,16 +5,6 @@ using Singularity.Widgets; namespace Singularity { - internal class WallpaperCandidate : Object { - public string uri { get; private set; } - public bool is_recent { get; private set; } - - public WallpaperCandidate(string uri, bool is_recent) { - this.uri = uri; - this.is_recent = is_recent; - } - } - public class DesktopPage : SettingsPage { private GLib.Settings settings; private GLib.Settings? wm_settings; @@ -31,11 +21,29 @@ namespace Singularity { private SelectionRow? decorations_side_row; private WallpaperPreviewWidget preview_widget; private FlowBox wallpaper_grid; + private Gee.ArrayList wallpaper_collections = new Gee.ArrayList(); + private WallpaperRotationState rotation_state = new WallpaperRotationState( + WallpaperRotationState.default_config_dir()); private int wallpaper_grid_generation = 0; private int wallpaper_accent_generation = 0; private string cached_wallpaper_accent = "#3584e4"; private static bool wallpaper_css_loaded = false; + // Label for a stored rotate-interval that doesn't match a fixed + // preset. Hours when it divides evenly, else minutes, else seconds + // (WallpaperRotationState.MIN_INTERVAL_SECONDS is 30, below a minute). + private static string format_custom_interval_label(int seconds) { + if (seconds % 3600 == 0) { + int hours = seconds / 3600; + return ngettext("Every %d hour (custom)", "Every %d hours (custom)", hours).printf(hours); + } + if (seconds >= 60) { + int minutes = seconds / 60; + return ngettext("Every %d minute (custom)", "Every %d minutes (custom)", minutes).printf(minutes); + } + return ngettext("Every %d second (custom)", "Every %d seconds (custom)", seconds).printf(seconds); + } + // Appends a rounded-rectangle sub-path to the Cairo context. private static void round_rect(Cairo.Context ctx, double x, double y, double w, double h, double r) { double PI = Math.PI; @@ -178,6 +186,33 @@ namespace Singularity { preview_group.add_row(preview_row); add_group(preview_group); var grid_group = new PreferencesGroup(_("Wallpapers")); + + wallpaper_collections = WallpaperCollections.parse( + WallpaperCollections.default_search_roots()); + + var source_options = new Gee.ArrayList(); + foreach (var collection in wallpaper_collections) { + string label = (collection.artist != null && collection.artist != "" && collection.artist != collection.name) + ? "%s - %s".printf(collection.name, collection.artist) + : collection.name; + source_options.add(new Singularity.Core.AppSettingOption() { + id = collection.id, label = label + }); + } + string initial_collection_id = rotation_state.get_selected_collection(""); + bool have_initial = false; + foreach (var opt in source_options) if (opt.id == initial_collection_id) have_initial = true; + if (!have_initial && source_options.size > 0) initial_collection_id = source_options[0].id; + + var source_row = new SelectionRow.with_options( + _("Wallpaper Source"), source_options, initial_collection_id); + source_row.subtitle = _("Which installed collection the gallery below shows"); + source_row.selected.connect((id) => { + rotation_state.set_selected_collection(id); + populate_grid(); + }); + grid_group.add_row(source_row); + wallpaper_grid = new FlowBox(); wallpaper_grid.add_css_class("wallpaper-gallery"); wallpaper_grid.valign = Align.START; @@ -195,6 +230,60 @@ namespace Singularity { var grid_row = new PreferencesRow(); grid_row.set_child(wallpaper_grid); grid_group.add_row(grid_row); + + var rotate_row = new SwitchRow(_("Rotate Wallpapers"), + _("Automatically change the wallpaper on a timer"), + rotation_state.get_rotate_enabled()); + grid_group.add_row(rotate_row); + + var interval_options = new Gee.ArrayList(); + interval_options.add(new Singularity.Core.AppSettingOption() { id = "600", label = _("Every 10 minutes") }); + interval_options.add(new Singularity.Core.AppSettingOption() { id = "1800", label = _("Every 30 minutes") }); + interval_options.add(new Singularity.Core.AppSettingOption() { id = "3600", label = _("Every hour") }); + interval_options.add(new Singularity.Core.AppSettingOption() { id = "14400", label = _("Every 4 hours") }); + interval_options.add(new Singularity.Core.AppSettingOption() { id = "86400", label = _("Every day") }); + + int current_interval = rotation_state.get_rotate_interval_seconds(); + string current_interval_id = current_interval.to_string(); + bool have_interval_match = false; + foreach (var opt in interval_options) if (opt.id == current_interval_id) have_interval_match = true; + // A stored value outside the fixed presets (set by an older + // build, or a future settings surface) gets its own entry + // showing the real value, inserted in chronological order, + // instead of silently displaying the nearest preset while + // WallpaperRotator keeps using the actual stored interval. + if (!have_interval_match) { + var custom_option = new Singularity.Core.AppSettingOption() { + id = current_interval_id, + label = format_custom_interval_label(current_interval) + }; + int insert_at = interval_options.size; + for (int i = 0; i < interval_options.size; i++) { + int preset_seconds; + if (int.try_parse(interval_options[i].id, out preset_seconds) + && current_interval < preset_seconds) { + insert_at = i; + break; + } + } + interval_options.insert(insert_at, custom_option); + } + + var interval_row = new SelectionRow.with_options( + _("Rotation Interval"), interval_options, current_interval_id); + interval_row.visible = rotation_state.get_rotate_enabled(); + interval_row.selected.connect((id) => { + int seconds; + if (int.try_parse(id, out seconds)) rotation_state.set_rotate_interval_seconds(seconds); + }); + grid_group.add_row(interval_row); + + rotate_row.switch_btn.notify["active"].connect(() => { + bool enabled = rotate_row.switch_btn.active; + rotation_state.set_rotate_enabled(enabled); + interval_row.visible = enabled; + }); + add_group(grid_group); GLib.Idle.add(() => { populate_grid(); return GLib.Source.REMOVE; }); refresh_wallpaper_accent_async(); @@ -1828,170 +1917,32 @@ namespace Singularity { }); } - // How deep to walk below a scan root. /usr/share/backgrounds holds - // ncz/, and a pack sits one further down (ncz/brandon-perlow), so two - // levels is what the shipped layout needs. The bound exists because - // $XDG_DATA_HOME/backgrounds is user-writable: someone who points it at - // a deep tree should not stall the picker. - private const int WALLPAPER_SCAN_MAX_DEPTH = 3; - - // Directories declared by installed wallpaper packs. - // - // Reading the registry rather than guessing paths is what surfaces the - // Bing provider at all: its Dir= is /var/cache/ncz-wallpapers/bing, - // which is not under any backgrounds path and is unreachable by - // directory walking alone. - // - // .collection is the current on-disk format (KeyFile). The design in - // docs/WALLPAPER-PACKS.md moves to .pack.json and accepts both for one - // release; when that lands, parse *.pack.json here too rather than - // replacing this, or packs installed by the older deb disappear from - // the picker on upgrade. - private static Gee.ArrayList collection_dirs() { - var dirs = new ArrayList(); - var roots = new ArrayList(); - foreach (unowned string d in GLib.Environment.get_system_data_dirs()) - roots.add(GLib.Path.build_filename(d, "ncz-wallpapers", "collections")); - roots.add(GLib.Path.build_filename(GLib.Environment.get_user_data_dir(), - "ncz-wallpapers", "collections")); - - foreach (string root in roots) { - try { - var dir = File.new_for_path(root); - if (!dir.query_exists()) continue; - var en = dir.enumerate_children("standard::name", FileQueryInfoFlags.NONE, null); - FileInfo info; - while ((info = en.next_file(null)) != null) { - if (!info.get_name().has_suffix(".collection")) continue; - var kf = new GLib.KeyFile(); - try { - kf.load_from_file(GLib.Path.build_filename(root, info.get_name()), - GLib.KeyFileFlags.NONE); - string d = kf.get_string("Collection", "Dir"); - if (d != null && d != "" && !dirs.contains(d)) dirs.add(d); - } catch (Error e) { - // A malformed or Dir-less collection is skipped, not - // fatal: one bad pack must not empty the picker. - } - } - } catch (Error e) { - } - } - return dirs; - } - - // Walk one scan root, collecting images. - // - // The previous implementation enumerated a single level and kept only - // entries whose content-type began with image/. /usr/share/backgrounds - // contains no images at all -- only ncz/ and singularity/ -- and a - // directory's content-type is inode/directory, so every shipped - // wallpaper was silently skipped. The picker had never displayed them. - private static void scan_wallpaper_dir(string path, - ArrayList candidates, - HashSet thread_seen, - HashSet visited_dirs, - int depth) { - if (depth > WALLPAPER_SCAN_MAX_DEPTH) return; - // The scan roots overlap by construction (/usr/share/backgrounds and - // /usr/share/backgrounds/singularity are both roots) and a pack may - // declare a Dir already reachable from one of them. Without this, - // those directories are walked more than once. - if (visited_dirs.contains(path)) return; - visited_dirs.add(path); - - try { - var dir = File.new_for_path(path); - if (!dir.query_exists()) return; - var enumerator = dir.enumerate_children( - "standard::name,standard::content-type,standard::type,standard::is-symlink,standard::symlink-target", - FileQueryInfoFlags.NONE, null); - FileInfo info; - while ((info = enumerator.next_file(null)) != null) { - var child = dir.get_child(info.get_name()); - - if (info.get_file_type() == FileType.DIRECTORY) { - // Not followed as a directory either: a symlinked - // directory is the easy way to walk in a circle. - if (info.get_is_symlink()) continue; - scan_wallpaper_dir(child.get_path(), candidates, thread_seen, - visited_dirs, depth + 1); - continue; - } - - // default.jpg is a symlink the rotator repoints at whichever - // wallpaper is current, at a target enumerated in this same - // directory -- following it would list one image twice, once - // under its own name and once as "default". Only elide a - // same-directory pointer like that one: a pack that ships an - // image as a symlink to a shared asset OUTSIDE this directory - // is real content, and the previous scanner listed it fine - // (content-type resolves through the link either way, since - // enumerate_children above passes no NOFOLLOW flag). - if (info.get_is_symlink()) { - string? target = info.get_symlink_target(); - if (target != null) { - string resolved = Path.is_absolute(target) - ? target - : Path.build_filename(path, target); - if (Path.get_dirname(resolved) == path) continue; - } - } - - string mime = info.get_content_type(); - if (mime == null || !mime.has_prefix("image/")) continue; - - string uri = child.get_uri(); - if (thread_seen.contains(uri)) continue; - thread_seen.add(uri); - candidates.add(new WallpaperCandidate(uri, false)); - } - } catch (Error e) { - } - } - private void populate_grid() { int gen = ++wallpaper_grid_generation; wallpaper_grid.remove_all(); - var uris = new ArrayList(); string[] recent = settings.get_strv("recent-wallpapers"); - foreach (string uri in recent) { - if (!uris.contains(uri)) { - uris.add(uri); - add_wallpaper_card(uri, true); - } - } - var seen = new HashSet(); - foreach (string uri in recent) seen.add(uri); - - var path_list = new ArrayList(); - foreach (unowned string d in GLib.Environment.get_system_data_dirs()) - path_list.add(GLib.Path.build_filename(d, "backgrounds", "singularity")); - path_list.add(GLib.Path.build_filename(GLib.Environment.get_user_data_dir(), "backgrounds", "singularity")); - foreach (unowned string d in GLib.Environment.get_system_data_dirs()) - path_list.add(GLib.Path.build_filename(d, "backgrounds")); - path_list.add(GLib.Path.build_filename(GLib.Environment.get_user_data_dir(), "backgrounds")); - - // Packs declare their own directory, and it need not live under any - // backgrounds path. The Bing provider caches into - // /var/cache/ncz-wallpapers/bing, which nothing above would ever - // reach, so the registry is the only way those images are found. - foreach (string dir in collection_dirs()) - path_list.add(dir); + string selected_id = rotation_state.get_selected_collection(""); + string? scan_dir = null; + foreach (var collection in wallpaper_collections) { + if (collection.id == selected_id) { scan_dir = collection.dir; break; } + } + // Persist stale-selection fallback so the UI and saved state agree. + if (scan_dir == null && wallpaper_collections.size > 0) { + selected_id = wallpaper_collections[0].id; + scan_dir = wallpaper_collections[0].dir; + rotation_state.set_selected_collection(selected_id); + } - string[] scan_paths = path_list.to_array(); + var collection_dirs = new ArrayList(); + foreach (var collection in wallpaper_collections) collection_dirs.add(collection.dir); new GLib.Thread("wallpaper-scan", () => { - var candidates = new ArrayList(); - var thread_seen = new HashSet(); - foreach (string uri in seen) thread_seen.add(uri); - - var visited_dirs = new HashSet(); - foreach (string path in scan_paths) - scan_wallpaper_dir(path, candidates, thread_seen, visited_dirs, 0); + var candidates = WallpaperGallery.scan(scan_dir, collection_dirs.to_array(), recent); GLib.Idle.add(() => { if (gen != wallpaper_grid_generation) return GLib.Source.REMOVE; + debug("Wallpaper gallery: source=%s root=%s images=%d", + selected_id, scan_dir ?? "(none)", candidates.size); append_wallpaper_candidates(candidates, gen, 0); return GLib.Source.REMOVE; }); diff --git a/src/core/main.vala b/src/core/main.vala index e206cb9..a525a44 100644 --- a/src/core/main.vala +++ b/src/core/main.vala @@ -314,6 +314,10 @@ public class SingularityApp : Singularity.ShellApplication, Singularity.Shell.Sh // connection is established at login, not on the first search. Singularity.SearchManager.get_default(); Singularity.NowPlayingCache.get_default(); + // Do not change persisted wallpaper state during safe-mode recovery. + if (Singularity.SafeMode.get_default().allows( + Singularity.SafeFeature.AUTOSTART)) + Singularity.WallpaperManager.get_default().start_rotation(); // Once the startup allocation storm (GL shader compile, icon and // theme loading) has settled, hand the freed pages back to the OS. GLib.Timeout.add_seconds(10, () => { diff --git a/src/core/wallpaper_collections.vala b/src/core/wallpaper_collections.vala new file mode 100644 index 0000000..50d5151 --- /dev/null +++ b/src/core/wallpaper_collections.vala @@ -0,0 +1,97 @@ +using GLib; +using Gee; + +namespace Singularity { + + public class WallpaperCollectionInfo : Object { + // Vala rejects a GObject property named "type"; keep these as fields. + public string id; + public string name; + public string artist; + public string dir; + public string type; + + public WallpaperCollectionInfo(string id, string name, string artist, string dir, string type) { + this.id = id; + this.name = name; + this.artist = artist; + this.dir = dir; + this.type = type; + } + } + + public class WallpaperCollections : Object { + // parse() is first-root-wins, so system collections take precedence. + public static string[] default_search_roots() { + string[] roots = {}; + foreach (unowned string d in GLib.Environment.get_system_data_dirs()) + roots += GLib.Path.build_filename(d, "singularity", "wallpaper-collections"); + roots += GLib.Path.build_filename( + GLib.Environment.get_user_data_dir(), "singularity", "wallpaper-collections"); + return roots; + } + + public static Gee.ArrayList parse(string[] search_roots) { + var results = new Gee.ArrayList(); + var seen_ids = new Gee.HashSet(); + + foreach (string root in search_roots) { + try { + var dir = File.new_for_path(root); + if (!dir.query_exists()) continue; + var en = dir.enumerate_children("standard::name", FileQueryInfoFlags.NONE, null); + FileInfo info; + while ((info = en.next_file(null)) != null) { + string filename = info.get_name(); + if (!filename.has_suffix(".collection")) continue; + + var kf = new GLib.KeyFile(); + try { + kf.load_from_file(GLib.Path.build_filename(root, filename), GLib.KeyFileFlags.NONE); + } catch (Error e) { + continue; // malformed file, skip it + } + + string collection_dir; + try { + collection_dir = kf.get_string("Collection", "Dir").strip(); + } catch (Error e) { + continue; // Dir-less collection, skip it + } + if (collection_dir == "") continue; + + string id; + try { + id = kf.get_string("Collection", "Id").strip(); + } catch (Error e) { + id = ""; + } + if (id == "") { + id = filename.substring(0, filename.length - ".collection".length); + } + if (!seen_ids.add(id)) continue; // first root wins + + string name; + try { name = kf.get_string("Collection", "Name").strip(); } + catch (Error e) { name = ""; } + if (name == "") name = id; + + string artist; + try { artist = kf.get_string("Collection", "Artist").strip(); } + catch (Error e) { artist = ""; } + + string type; + try { type = kf.get_string("Collection", "Type").strip(); } + catch (Error e) { type = ""; } + if (type == "") type = "static"; + + results.add(new WallpaperCollectionInfo(id, name, artist, collection_dir, type)); + } + } catch (Error e) { + continue; + } + } + return results; + } + } +} diff --git a/src/core/wallpaper_gallery.vala b/src/core/wallpaper_gallery.vala new file mode 100644 index 0000000..0d5d8bc --- /dev/null +++ b/src/core/wallpaper_gallery.vala @@ -0,0 +1,105 @@ +using GLib; +using Gee; + +namespace Singularity { + internal class WallpaperCandidate : Object { + public string uri { get; private set; } + public bool is_recent { get; private set; } + + public WallpaperCandidate(string uri, bool is_recent) { + this.uri = uri; + this.is_recent = is_recent; + } + } + + internal class WallpaperGallery : Object { + // History may reorder results, but cannot add another source's images. + public static ArrayList scan(string? selected_dir, + string[] collection_dirs, + string[] recent) { + var scanned = new ArrayList(); + var members = new HashSet(); + var excluded = new HashSet(); + var result = new ArrayList(); + if (selected_dir == null) return result; + string root = canonical_path(selected_dir); + foreach (string dir in collection_dirs) { + string other = canonical_path(dir); + if (other != root) excluded.add(other); + } + scan_wallpaper_dir(root, scanned, members, new HashSet(), excluded, 0); + var added = new HashSet(); + foreach (string uri in recent) { + if (members.contains(uri) && added.add(uri)) + result.add(new WallpaperCandidate(uri, true)); + } + foreach (var candidate in scanned) { + if (added.add(candidate.uri)) result.add(candidate); + } + return result; + } + + // Bound traversal of user-controlled collection directories. + private const int WALLPAPER_SCAN_MAX_DEPTH = 3; + + // Resolve symlinks before enforcing collection boundaries. + private static string canonical_path(string path) { + string? real = Posix.realpath(path, null); + return real ?? File.new_for_path(path).get_path(); + } + + // Other registered roots are separate sources, even when nested. + private static void scan_wallpaper_dir(string path, + ArrayList candidates, + HashSet thread_seen, + HashSet visited_dirs, + HashSet excluded_dirs, + int depth) { + if (depth > WALLPAPER_SCAN_MAX_DEPTH || excluded_dirs.contains(path)) return; + if (visited_dirs.contains(path)) return; + visited_dirs.add(path); + + try { + var dir = File.new_for_path(path); + if (!dir.query_exists()) return; + var enumerator = dir.enumerate_children( + "standard::name,standard::content-type,standard::type,standard::is-symlink,standard::symlink-target", + FileQueryInfoFlags.NONE, null); + FileInfo info; + while ((info = enumerator.next_file(null)) != null) { + var child = dir.get_child(info.get_name()); + + if (info.get_file_type() == FileType.DIRECTORY) { + // Do not follow symlinked directories; they may form cycles. + if (info.get_is_symlink()) continue; + scan_wallpaper_dir(child.get_path(), candidates, thread_seen, + visited_dirs, excluded_dirs, depth + 1); + continue; + } + + // Ignore same-directory aliases such as the mutable default.jpg, + // but retain symlinks to shared assets outside the collection. + if (info.get_is_symlink()) { + string? target = info.get_symlink_target(); + if (target != null) { + string resolved = Path.is_absolute(target) + ? target + : Path.build_filename(path, target); + if (Path.get_dirname(resolved) == path) continue; + } + } + + string mime = info.get_content_type(); + if (mime == null || !mime.has_prefix("image/")) continue; + + string uri = child.get_uri(); + if (thread_seen.contains(uri)) continue; + thread_seen.add(uri); + candidates.add(new WallpaperCandidate(uri, false)); + } + } catch (Error e) { + } + } + + } +} diff --git a/src/core/wallpaper_manager.vala b/src/core/wallpaper_manager.vala index 5579b2b..e147f67 100644 --- a/src/core/wallpaper_manager.vala +++ b/src/core/wallpaper_manager.vala @@ -15,6 +15,7 @@ namespace Singularity { private string? _cached_path = null; private int _load_serial = 0; private Mutex _mutex = Mutex (); + private WallpaperRotator? rotator = null; public signal void wallpaper_changed(); @@ -33,6 +34,20 @@ namespace Singularity { reload(); } + public void start_rotation() { + if (rotator != null) return; + rotator = WallpaperRotator.get_default(); + rotator.current_uri = settings.get_string("background-picture-uri"); + rotator.wallpaper_selected.connect((uri) => { + settings.set_string("background-picture-uri", uri); + }); + // Track external changes so rotation does not reselect the current image. + settings.changed["background-picture-uri"].connect(() => { + rotator.current_uri = settings.get_string("background-picture-uri"); + }); + rotator.start(); + } + public void reload() { string custom_uri = settings.get_string("background-picture-uri"); string? path = resolve_path(custom_uri); diff --git a/src/core/wallpaper_rotation_state.vala b/src/core/wallpaper_rotation_state.vala new file mode 100644 index 0000000..bb3b958 --- /dev/null +++ b/src/core/wallpaper_rotation_state.vala @@ -0,0 +1,86 @@ +using GLib; + +namespace Singularity { + + public class WallpaperRotationState : Object { + private const int DEFAULT_INTERVAL_SECONDS = 600; + private const int MIN_INTERVAL_SECONDS = 30; + + private string config_dir; + + public WallpaperRotationState(string config_dir) { + this.config_dir = config_dir; + } + + public static string default_config_dir() { + return GLib.Path.build_filename( + GLib.Environment.get_user_config_dir(), "singularity", "wallpaper-rotation"); + } + + private string path_for(string filename) { + return GLib.Path.build_filename(config_dir, filename); + } + + private string? read_trimmed(string filename) { + string path = path_for(filename); + if (!FileUtils.test(path, FileTest.EXISTS)) return null; + string contents; + try { + FileUtils.get_contents(path, out contents); + } catch (Error e) { + return null; + } + return contents.strip(); + } + + private void write(string filename, string contents) { + GLib.DirUtils.create_with_parents(config_dir, 0700); + string dest = path_for(filename); + string tmp = dest + ".tmp"; + try { + // Same-directory rename keeps state updates atomic for readers. + FileUtils.set_contents(tmp, contents); + if (FileUtils.rename(tmp, dest) != 0) { + warning("wallpaper rotation state: could not rename %s into place", filename); + } + } catch (Error e) { + warning("wallpaper rotation state: could not write %s: %s", filename, e.message); + } + } + + public string get_selected_collection(string default_id) { + string? value = read_trimmed("collection"); + return (value == null || value == "") ? default_id : value; + } + + public void set_selected_collection(string id) { + write("collection", id); + } + + // Rotation is opt-in; an absent state file must remain off. + public bool get_rotate_enabled() { + string? value = read_trimmed("rotate-enabled"); + if (value == null) return false; + string lowered = value.down(); + return lowered != "0" && lowered != "false" && lowered != "off"; + } + + public void set_rotate_enabled(bool enabled) { + write("rotate-enabled", enabled ? "1" : "0"); + } + + public int get_rotate_interval_seconds() { + string? value = read_trimmed("rotate-interval"); + if (value == null) return DEFAULT_INTERVAL_SECONDS; + int64 parsed; + if (!int64.try_parse(value, out parsed)) return DEFAULT_INTERVAL_SECONDS; + int seconds = (int) parsed; + return seconds < MIN_INTERVAL_SECONDS ? MIN_INTERVAL_SECONDS : seconds; + } + + public void set_rotate_interval_seconds(int seconds) { + int clamped = seconds < MIN_INTERVAL_SECONDS ? MIN_INTERVAL_SECONDS : seconds; + write("rotate-interval", clamped.to_string()); + } + } +} diff --git a/src/core/wallpaper_rotator.vala b/src/core/wallpaper_rotator.vala new file mode 100644 index 0000000..6b010f6 --- /dev/null +++ b/src/core/wallpaper_rotator.vala @@ -0,0 +1,166 @@ +using GLib; +using Gee; + +namespace Singularity { + + // Selects rotation timing and images; WallpaperManager applies the signal. + public class WallpaperRotator : Object { + private static WallpaperRotator? _instance = null; + + public signal void wallpaper_selected(string uri); + + public string? current_uri { get; set; default = null; } + + public int armed_interval_seconds { get; private set; default = 0; } + + private WallpaperRotationState state; + private string[] collection_roots; + private string config_dir; + private uint tick_id = 0; + private uint restart_id = 0; + private FileMonitor? state_monitor = null; + + public static WallpaperRotator get_default() { + if (_instance == null) { + _instance = new WallpaperRotator( + WallpaperRotationState.default_config_dir(), + WallpaperCollections.default_search_roots()); + } + return _instance; + } + + public WallpaperRotator(string config_dir, string[] collection_roots) { + this.config_dir = config_dir; + this.collection_roots = collection_roots; + this.state = new WallpaperRotationState(config_dir); + } + + public void start() { + watch_state_dir(); + reschedule(); + } + + public void stop() { + cancel_tick(); + if (restart_id != 0) { + Source.remove(restart_id); + restart_id = 0; + } + if (state_monitor != null) { + // The signal handler retains this object until the monitor is cancelled. + state_monitor.cancel(); + state_monitor = null; + } + } + + private void cancel_tick() { + if (tick_id != 0) { + Source.remove(tick_id); + tick_id = 0; + } + armed_interval_seconds = 0; + } + + public void reschedule() { + cancel_tick(); + if (!state.get_rotate_enabled()) return; + int interval = state.get_rotate_interval_seconds(); + armed_interval_seconds = interval; + tick_id = Timeout.add_seconds(interval, () => { + tick_id = 0; + armed_interval_seconds = 0; + // Another process may disable rotation after this timer is armed. + if (state.get_rotate_enabled()) rotate_async(); + reschedule(); + return Source.REMOVE; + }); + } + + // Keep collection scanning off the compositor's main loop. + public void rotate_async() { + // Snapshot before entering the worker to avoid a concurrent property read. + string? current = current_uri; + new Thread("wallpaper-rotate", () => { + string? uri = choose_next_for(current); + if (uri == null) return; + Idle.add(() => { + current_uri = uri; + wallpaper_selected(uri); + return Source.REMOVE; + }); + }); + } + + public void rotate_now() { + string? uri = choose_next(); + if (uri == null) return; + current_uri = uri; + wallpaper_selected(uri); + } + + public string? choose_next() { + return choose_next_for(current_uri); + } + + public string? choose_next_for(string? current) { + var collections = WallpaperCollections.parse(collection_roots); + if (collections.size == 0) return null; + + string selected_id = state.get_selected_collection(""); + string? scan_dir = null; + foreach (var collection in collections) { + if (collection.id == selected_id) { scan_dir = collection.dir; break; } + } + // Do not let the background timer rewrite a stale user selection. + if (scan_dir == null) scan_dir = collections[0].dir; + + var all_dirs = new ArrayList(); + foreach (var collection in collections) all_dirs.add(collection.dir); + + // Recency is gallery presentation state, not rotation weighting. + var candidates = WallpaperGallery.scan(scan_dir, all_dirs.to_array(), {}); + if (candidates.size == 0) return null; + + var uris = new ArrayList(); + foreach (var candidate in candidates) uris.add(candidate.uri); + return pick(uris, current, Random.next_int()); + } + + // Inject the random roll so selection remains deterministic in tests. + public static string? pick(Gee.List uris, string? current_uri, uint32 roll) { + if (uris.size == 0) return null; + if (uris.size == 1) return uris[0]; + + var choices = new ArrayList(); + foreach (string uri in uris) { + if (uri != current_uri) choices.add(uri); + } + // A collection may contain duplicate URIs for the current image. + if (choices.size == 0) return uris[0]; + return choices[(int) (roll % choices.size)]; + } + + // State may be written outside the settings page, so watch the directory. + private void watch_state_dir() { + if (state_monitor != null) return; + DirUtils.create_with_parents(config_dir, 0700); + try { + var dir = File.new_for_path(config_dir); + state_monitor = dir.monitor_directory(FileMonitorFlags.NONE, null); + } catch (Error e) { + // The timer still re-reads state if monitoring is unavailable. + warning("wallpaper rotator: cannot watch %s: %s", config_dir, e.message); + return; + } + state_monitor.changed.connect((file, other, event) => { + // Atomic replacement emits multiple events; coalesce them. + if (restart_id != 0) return; + restart_id = Timeout.add(250, () => { + restart_id = 0; + reschedule(); + return Source.REMOVE; + }); + }); + } + } +} diff --git a/tests/wallpaper_collections_test.vala b/tests/wallpaper_collections_test.vala new file mode 100644 index 0000000..8c65a69 --- /dev/null +++ b/tests/wallpaper_collections_test.vala @@ -0,0 +1,101 @@ +using GLib; +using Gee; +using Singularity; + +private string make_tmp_dir() { + string path = GLib.DirUtils.make_tmp("wpcollections-XXXXXX"); + return path; +} + +private void write_collection(string dir, string filename, string contents) { + string path = GLib.Path.build_filename(dir, filename); + try { + FileUtils.set_contents(path, contents); + } catch (Error e) { + error("test setup failed: %s", e.message); + } +} + +private void test_parses_id_name_artist_dir() { + string root = make_tmp_dir(); + write_collection(root, "brandon.collection", + "[Collection]\n" + + "Id=brandon-perlow\n" + + "Name=Brandon Perlow\n" + + "Artist=Brandon Perlow\n" + + "Type=static\n" + + "Dir=/usr/share/backgrounds/vendor/brandon-perlow\n"); + + var result = WallpaperCollections.parse({ root }); + + assert(result.size == 1); + assert(result[0].id == "brandon-perlow"); + assert(result[0].name == "Brandon Perlow"); + assert(result[0].artist == "Brandon Perlow"); + assert(result[0].dir == "/usr/share/backgrounds/vendor/brandon-perlow"); + assert(result[0].type == "static"); +} + +private void test_id_falls_back_to_filename_stem() { + string root = make_tmp_dir(); + write_collection(root, "vendor.collection", + "[Collection]\n" + + "Name=Vendor OS\n" + + "Dir=/usr/share/backgrounds/vendor\n"); + + var result = WallpaperCollections.parse({ root }); + + assert(result.size == 1); + assert(result[0].id == "vendor"); +} + +private void test_skips_dir_less_collection() { + string root = make_tmp_dir(); + write_collection(root, "broken.collection", + "[Collection]\n" + + "Id=broken\n" + + "Name=Broken\n"); + write_collection(root, "good.collection", + "[Collection]\n" + + "Id=good\n" + + "Name=Good\n" + + "Dir=/some/dir\n"); + + var result = WallpaperCollections.parse({ root }); + + assert(result.size == 1); + assert(result[0].id == "good"); +} + +private void test_ignores_non_collection_files_and_missing_dirs() { + string root = make_tmp_dir(); + write_collection(root, "notes.txt", "not a collection\n"); + + var result = WallpaperCollections.parse({ root, "/definitely/does/not/exist" }); + + assert(result.size == 0); +} + +private void test_dedupes_by_id_first_root_wins() { + string root_a = make_tmp_dir(); + string root_b = make_tmp_dir(); + write_collection(root_a, "vendor.collection", + "[Collection]\nId=vendor\nName=System\nDir=/system/vendor\n"); + write_collection(root_b, "vendor.collection", + "[Collection]\nId=vendor\nName=User Override\nDir=/user/vendor\n"); + + var result = WallpaperCollections.parse({ root_a, root_b }); + + assert(result.size == 1); + assert(result[0].name == "System"); +} + +public int main(string[] args) { + Test.init(ref args); + Test.add_func("/wallpaper-collections/parses-id-name-artist-dir", test_parses_id_name_artist_dir); + Test.add_func("/wallpaper-collections/id-falls-back-to-filename-stem", test_id_falls_back_to_filename_stem); + Test.add_func("/wallpaper-collections/skips-dir-less-collection", test_skips_dir_less_collection); + Test.add_func("/wallpaper-collections/ignores-non-collection-files-and-missing-dirs", test_ignores_non_collection_files_and_missing_dirs); + Test.add_func("/wallpaper-collections/dedupes-by-id-first-root-wins", test_dedupes_by_id_first_root_wins); + return Test.run(); +} diff --git a/tests/wallpaper_gallery_test.vala b/tests/wallpaper_gallery_test.vala new file mode 100644 index 0000000..98fa47d --- /dev/null +++ b/tests/wallpaper_gallery_test.vala @@ -0,0 +1,81 @@ +using GLib; +using Gee; +using Singularity; + +private string fixture_root; +private string system_dir; +private string artist; +private string bing; + +private string image_file(string dir, string name) { + string path = Path.build_filename(dir, name + ".svg"); + try { + DirUtils.create_with_parents(dir, 0700); + FileUtils.set_contents(path, ""); + } catch (Error e) { error("fixture: %s", e.message); } + return File.new_for_path(path).get_uri(); +} + +private void test_recent_membership() { + string base_uri = image_file(system_dir, "base"); + string art_uri = image_file(artist, "art"); + string bing_uri = image_file(bing, "daily"); + var result = WallpaperGallery.scan(artist, {system_dir, artist, bing}, + {bing_uri, base_uri, art_uri, art_uri}); + assert(result.size == 1); + assert(result[0].uri == art_uri); + assert(result[0].is_recent); +} + +private void test_nested_collection_boundary() { + string base_uri = image_file(system_dir, "base"); + string art_uri = image_file(artist, "art"); + var result = WallpaperGallery.scan(system_dir + "/./", {system_dir, artist + "/", bing}, {art_uri}); + assert(result.size == 1); + assert(result[0].uri == base_uri); + assert(!result[0].is_recent); +} + +private void test_provider_subdirectories() { + string daily = image_file(Path.build_filename(bing, "en-US"), "nested"); + var result = WallpaperGallery.scan(bing, {system_dir, artist, bing}, {}); + assert(result.size == 2); + bool found = false; + foreach (var candidate in result) if (candidate.uri == daily) found = true; + assert(found); +} + +private void test_missing_collection() { + string uri = image_file(system_dir, "base"); + assert(WallpaperGallery.scan(null, {system_dir}, {uri}).size == 0); + assert(WallpaperGallery.scan(fixture_root + "/missing", {system_dir}, {uri}).size == 0); +} + +private void test_default_alias_and_directory_loop() { + try { + File.new_for_path(system_dir + "/default.svg").make_symbolic_link("base.svg"); + File.new_for_path(system_dir + "/loop").make_symbolic_link(system_dir); + } catch (Error e) { error("fixture: %s", e.message); } + string alias = File.new_for_path(system_dir + "/default.svg").get_uri(); + var result = WallpaperGallery.scan(system_dir, {system_dir, artist, bing}, {alias}); + assert(result.size == 1); + assert(!result[0].is_recent); +} + +public int main(string[] args) { + Test.init(ref args); + try { fixture_root = DirUtils.make_tmp("wallpaper-gallery-XXXXXX"); } + catch (Error e) { error("fixture: %s", e.message); } + system_dir = fixture_root + "/system"; + artist = system_dir + "/artist"; + bing = fixture_root + "/bing"; + image_file(system_dir, "base"); + image_file(artist, "art"); + image_file(bing, "daily"); + Test.add_func("/wallpaper-gallery/recent-membership", test_recent_membership); + Test.add_func("/wallpaper-gallery/nested-boundary", test_nested_collection_boundary); + Test.add_func("/wallpaper-gallery/provider-subdirectories", test_provider_subdirectories); + Test.add_func("/wallpaper-gallery/missing-collection", test_missing_collection); + Test.add_func("/wallpaper-gallery/default-alias-loop", test_default_alias_and_directory_loop); + return Test.run(); +} diff --git a/tests/wallpaper_rotation_state_test.vala b/tests/wallpaper_rotation_state_test.vala new file mode 100644 index 0000000..0842072 --- /dev/null +++ b/tests/wallpaper_rotation_state_test.vala @@ -0,0 +1,79 @@ +using GLib; +using Singularity; + +private string make_tmp_dir() { + return GLib.DirUtils.make_tmp("wprotation-XXXXXX"); +} + +private void test_selected_collection_defaults_when_unset() { + var state = new WallpaperRotationState(make_tmp_dir()); + assert(state.get_selected_collection("default") == "default"); +} + +private void test_selected_collection_roundtrips() { + var state = new WallpaperRotationState(make_tmp_dir()); + state.set_selected_collection("brandon-perlow"); + assert(state.get_selected_collection("default") == "brandon-perlow"); +} + +private void test_selected_collection_strips_whitespace() { + string dir = make_tmp_dir(); + try { + FileUtils.set_contents(GLib.Path.build_filename(dir, "collection"), " bing \n"); + } catch (Error e) { error("test setup failed: %s", e.message); } + var state = new WallpaperRotationState(dir); + assert(state.get_selected_collection("default") == "bing"); +} + +private void test_rotate_enabled_defaults_false() { + var state = new WallpaperRotationState(make_tmp_dir()); + assert(state.get_rotate_enabled() == false); +} + +private void test_rotate_enabled_roundtrips_false() { + var state = new WallpaperRotationState(make_tmp_dir()); + state.set_rotate_enabled(false); + assert(state.get_rotate_enabled() == false); + state.set_rotate_enabled(true); + assert(state.get_rotate_enabled() == true); +} + +private void test_rotate_interval_defaults_to_600() { + var state = new WallpaperRotationState(make_tmp_dir()); + assert(state.get_rotate_interval_seconds() == 600); +} + +private void test_rotate_interval_roundtrips() { + var state = new WallpaperRotationState(make_tmp_dir()); + state.set_rotate_interval_seconds(1800); + assert(state.get_rotate_interval_seconds() == 1800); +} + +private void test_rotate_interval_clamps_to_30_minimum() { + var state = new WallpaperRotationState(make_tmp_dir()); + state.set_rotate_interval_seconds(5); + assert(state.get_rotate_interval_seconds() == 30); +} + +private void test_rotate_interval_garbage_on_disk_reads_as_default() { + string dir = make_tmp_dir(); + try { + FileUtils.set_contents(GLib.Path.build_filename(dir, "rotate-interval"), "not-a-number\n"); + } catch (Error e) { error("test setup failed: %s", e.message); } + var state = new WallpaperRotationState(dir); + assert(state.get_rotate_interval_seconds() == 600); +} + +public int main(string[] args) { + Test.init(ref args); + Test.add_func("/wallpaper-rotation-state/selected-collection-defaults-when-unset", test_selected_collection_defaults_when_unset); + Test.add_func("/wallpaper-rotation-state/selected-collection-roundtrips", test_selected_collection_roundtrips); + Test.add_func("/wallpaper-rotation-state/selected-collection-strips-whitespace", test_selected_collection_strips_whitespace); + Test.add_func("/wallpaper-rotation-state/rotate-enabled-defaults-false", test_rotate_enabled_defaults_false); + Test.add_func("/wallpaper-rotation-state/rotate-enabled-roundtrips-false", test_rotate_enabled_roundtrips_false); + Test.add_func("/wallpaper-rotation-state/rotate-interval-defaults-to-600", test_rotate_interval_defaults_to_600); + Test.add_func("/wallpaper-rotation-state/rotate-interval-roundtrips", test_rotate_interval_roundtrips); + Test.add_func("/wallpaper-rotation-state/rotate-interval-clamps-to-30-minimum", test_rotate_interval_clamps_to_30_minimum); + Test.add_func("/wallpaper-rotation-state/rotate-interval-garbage-on-disk-reads-as-default", test_rotate_interval_garbage_on_disk_reads_as_default); + return Test.run(); +} diff --git a/tests/wallpaper_rotator_test.vala b/tests/wallpaper_rotator_test.vala new file mode 100644 index 0000000..a96ac4b --- /dev/null +++ b/tests/wallpaper_rotator_test.vala @@ -0,0 +1,246 @@ +using GLib; +using Gee; +using Singularity; + +private string fixture_root; + +private string make_dir(string name) { + string path = Path.build_filename(fixture_root, name); + DirUtils.create_with_parents(path, 0700); + return path; +} + +private string image_file(string dir, string name) { + string path = Path.build_filename(dir, name + ".svg"); + try { + DirUtils.create_with_parents(dir, 0700); + FileUtils.set_contents(path, + ""); + } catch (Error e) { error("fixture: %s", e.message); } + return File.new_for_path(path).get_uri(); +} + +private void write_collection(string registry, string id, string dir) { + try { + DirUtils.create_with_parents(registry, 0700); + FileUtils.set_contents(Path.build_filename(registry, id + ".collection"), + "[Collection]\nId=%s\nName=%s\nDir=%s\n".printf(id, id, dir)); + } catch (Error e) { error("fixture: %s", e.message); } +} + +private void test_pick_single_candidate() { + var uris = new ArrayList(); + uris.add("file:///a.png"); + assert(WallpaperRotator.pick(uris, "file:///a.png", 0) == "file:///a.png"); +} + +private void test_pick_is_deterministic_for_a_given_roll() { + var uris = new ArrayList(); + uris.add("file:///a.png"); + uris.add("file:///b.png"); + uris.add("file:///c.png"); + assert(WallpaperRotator.pick(uris, null, 0) == "file:///a.png"); + assert(WallpaperRotator.pick(uris, null, 1) == "file:///b.png"); + assert(WallpaperRotator.pick(uris, null, 5) == "file:///c.png"); +} + +private void test_pick_never_returns_the_current_wallpaper() { + var uris = new ArrayList(); + uris.add("file:///a.png"); + uris.add("file:///b.png"); + uris.add("file:///c.png"); + for (uint32 roll = 0; roll < 12; roll++) { + assert(WallpaperRotator.pick(uris, "file:///b.png", roll) != "file:///b.png"); + } +} + +private void test_pick_on_empty_list() { + assert(WallpaperRotator.pick(new ArrayList(), null, 0) == null); +} + +private void test_rotates_within_the_selected_collection_only() { + string registry = make_dir("registry-scoped"); + string alpha = make_dir("alpha"); + string beta = make_dir("beta"); + string a1 = image_file(alpha, "a1"); + string a2 = image_file(alpha, "a2"); + image_file(beta, "b1"); + write_collection(registry, "alpha", alpha); + write_collection(registry, "beta", beta); + + string config = make_dir("config-scoped"); + new WallpaperRotationState(config).set_selected_collection("alpha"); + + var rotator = new WallpaperRotator(config, { registry }); + for (int i = 0; i < 8; i++) { + string? chosen = rotator.choose_next(); + assert(chosen == a1 || chosen == a2); + } +} + +private void test_stale_collection_id_falls_back_to_the_first() { + string registry = make_dir("registry-stale"); + string alpha = make_dir("alpha-stale"); + string only = image_file(alpha, "only"); + write_collection(registry, "alpha", alpha); + + string config = make_dir("config-stale"); + new WallpaperRotationState(config).set_selected_collection("uninstalled-pack"); + + var rotator = new WallpaperRotator(config, { registry }); + assert(rotator.choose_next() == only); +} + +private void test_empty_collection_leaves_the_wallpaper_alone() { + string registry = make_dir("registry-empty"); + string empty = make_dir("empty-pack"); + write_collection(registry, "empty", empty); + + string config = make_dir("config-empty"); + new WallpaperRotationState(config).set_selected_collection("empty"); + + var rotator = new WallpaperRotator(config, { registry }); + assert(rotator.choose_next() == null); +} + +private void test_no_registry_at_all() { + var rotator = new WallpaperRotator(make_dir("config-none"), + { Path.build_filename(fixture_root, "no-such-registry") }); + assert(rotator.choose_next() == null); +} + +private void test_rotate_now_announces_and_records_the_choice() { + string registry = make_dir("registry-emit"); + string pack = make_dir("emit-pack"); + string only = image_file(pack, "only"); + write_collection(registry, "emit", pack); + + string config = make_dir("config-emit"); + new WallpaperRotationState(config).set_selected_collection("emit"); + + var rotator = new WallpaperRotator(config, { registry }); + string? announced = null; + rotator.wallpaper_selected.connect((uri) => { announced = uri; }); + rotator.rotate_now(); + + assert(announced == only); + assert(rotator.current_uri == only); +} + +private void test_rotate_now_announces_nothing_when_there_is_nothing() { + string config = make_dir("config-silent"); + var rotator = new WallpaperRotator(config, + { Path.build_filename(fixture_root, "no-such-registry") }); + bool announced = false; + rotator.wallpaper_selected.connect((uri) => { announced = true; }); + rotator.rotate_now(); + assert(!announced); +} + +private void test_choose_next_for_excludes_the_supplied_wallpaper() { + string registry = make_dir("registry-exclude"); + string pack = make_dir("exclude-pack"); + string a = image_file(pack, "a"); + string b = image_file(pack, "b"); + write_collection(registry, "exclude", pack); + + string config = make_dir("config-exclude"); + new WallpaperRotationState(config).set_selected_collection("exclude"); + + var rotator = new WallpaperRotator(config, { registry }); + for (int i = 0; i < 8; i++) { + assert(rotator.choose_next_for(a) == b); + assert(rotator.choose_next_for(b) == a); + } +} + +private void test_rotate_async_announces_on_the_main_loop() { + string registry = make_dir("registry-async"); + string pack = make_dir("async-pack"); + string only = image_file(pack, "only"); + write_collection(registry, "async", pack); + + string config = make_dir("config-async"); + new WallpaperRotationState(config).set_selected_collection("async"); + + var rotator = new WallpaperRotator(config, { registry }); + var loop = new MainLoop(); + string? announced = null; + rotator.wallpaper_selected.connect((uri) => { + announced = uri; + loop.quit(); + }); + // Avoid removing a timeout source after its callback already removed it. + bool timed_out = false; + uint bail = Timeout.add_seconds(10, () => { + timed_out = true; + loop.quit(); + return Source.REMOVE; + }); + + rotator.rotate_async(); + loop.run(); + if (!timed_out) Source.remove(bail); + + assert(!timed_out); + assert(announced == only); + assert(rotator.current_uri == only); +} + +private void test_untouched_install_does_not_rotate() { + string config = make_dir("config-untouched"); + var rotator = new WallpaperRotator(config, { make_dir("registry-untouched") }); + rotator.start(); + assert(rotator.armed_interval_seconds == 0); + rotator.stop(); +} + +private void test_timer_follows_the_rotation_state() { + string config = make_dir("config-timer"); + var state = new WallpaperRotationState(config); + var rotator = new WallpaperRotator(config, { make_dir("registry-timer") }); + + rotator.reschedule(); + assert(rotator.armed_interval_seconds == 0); + + state.set_rotate_enabled(true); + rotator.reschedule(); + assert(rotator.armed_interval_seconds == 600); + + state.set_rotate_interval_seconds(3600); + rotator.reschedule(); + assert(rotator.armed_interval_seconds == 3600); + + state.set_rotate_enabled(false); + rotator.reschedule(); + assert(rotator.armed_interval_seconds == 0); + + state.set_rotate_enabled(true); + rotator.reschedule(); + assert(rotator.armed_interval_seconds == 3600); + + rotator.stop(); + assert(rotator.armed_interval_seconds == 0); +} + +public int main(string[] args) { + Test.init(ref args); + try { fixture_root = DirUtils.make_tmp("wallpaper-rotator-XXXXXX"); } + catch (Error e) { error("fixture: %s", e.message); } + + Test.add_func("/wallpaper-rotator/pick-single-candidate", test_pick_single_candidate); + Test.add_func("/wallpaper-rotator/pick-deterministic-roll", test_pick_is_deterministic_for_a_given_roll); + Test.add_func("/wallpaper-rotator/pick-skips-current", test_pick_never_returns_the_current_wallpaper); + Test.add_func("/wallpaper-rotator/pick-empty", test_pick_on_empty_list); + Test.add_func("/wallpaper-rotator/scoped-to-selected-collection", test_rotates_within_the_selected_collection_only); + Test.add_func("/wallpaper-rotator/stale-id-falls-back", test_stale_collection_id_falls_back_to_the_first); + Test.add_func("/wallpaper-rotator/empty-collection-is-a-no-op", test_empty_collection_leaves_the_wallpaper_alone); + Test.add_func("/wallpaper-rotator/no-registry", test_no_registry_at_all); + Test.add_func("/wallpaper-rotator/rotate-now-announces", test_rotate_now_announces_and_records_the_choice); + Test.add_func("/wallpaper-rotator/rotate-now-silent-when-empty", test_rotate_now_announces_nothing_when_there_is_nothing); + Test.add_func("/wallpaper-rotator/choose-next-for-excludes", test_choose_next_for_excludes_the_supplied_wallpaper); + Test.add_func("/wallpaper-rotator/rotate-async-announces", test_rotate_async_announces_on_the_main_loop); + Test.add_func("/wallpaper-rotator/untouched-install-does-not-rotate", test_untouched_install_does_not_rotate); + Test.add_func("/wallpaper-rotator/timer-follows-state", test_timer_follows_the_rotation_state); + return Test.run(); +}