diff --git a/meson.build b/meson.build index 96ff7f1..6c63711 100644 --- a/meson.build +++ b/meson.build @@ -936,6 +936,7 @@ wallpaper_test_schema = custom_target('wallpaper-test-schema', ) wallpaper_test_env = environment() wallpaper_test_env.set('GSETTINGS_SCHEMA_DIR', meson.current_build_dir()) +wallpaper_test_env.set('GSETTINGS_BACKEND', 'memory') wallpaper_history_test = executable('wallpaper-history-test', sources: ['src/core/wallpaper_history.vala', 'tests/wallpaper_history_test.vala'], @@ -953,20 +954,24 @@ test('wallpaper-favorites', wallpaper_favorites_test, wallpaper_gallery_test = executable('wallpaper-gallery-test', sources: ['src/core/wallpaper_gallery.vala', 'src/core/dynamic_wallpaper.vala', 'tests/wallpaper_gallery_test.vala'], - dependencies: [dependency('gobject-2.0'), dependency('gio-2.0'), gee_dep, json_dep, cc.find_library('m')], + dependencies: [dependency('gobject-2.0'), dependency('gio-2.0'), gee_dep, json_dep, dependency('gdk-pixbuf-2.0'), cc.find_library('m')], ) 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/dynamic_wallpaper.vala', + 'src/core/wallpaper_favorites.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, json_dep, cc.find_library('m')], + dependencies: [dependency('gobject-2.0'), dependency('glib-2.0'), dependency('gio-2.0'), + dependency('gdk-pixbuf-2.0'), gee_dep, json_dep, cc.find_library('m')], ) -test('wallpaper-rotator', wallpaper_rotator_test) +test('wallpaper-rotator', wallpaper_rotator_test, + depends: wallpaper_test_schema, + env: wallpaper_test_env) wallpaper_ocs_test = executable('wallpaper-ocs-test', sources: ['src/core/wallpaper_ocs.vala', 'src/core/wallpaper_browse_cache.vala', 'src/core/wallpaper_thumbnail_cache.vala', 'src/core/wallpaper_provider.vala', 'src/core/wallpaper_collections.vala', 'src/core/wallpaper_gallery.vala', 'src/core/dynamic_wallpaper.vala', 'tests/wallpaper_ocs_test.vala'], - dependencies: [dependency('gobject-2.0'), dependency('gio-2.0'), gee_dep, json_dep, libsingularity_dep], + dependencies: [dependency('gobject-2.0'), dependency('gio-2.0'), gee_dep, json_dep, libsingularity_dep, dependency('gdk-pixbuf-2.0')], ) test('wallpaper-ocs', wallpaper_ocs_test) diff --git a/src/components/sidebar/pages/desktop_page.vala b/src/components/sidebar/pages/desktop_page.vala index 5af822b..e8e6553 100644 --- a/src/components/sidebar/pages/desktop_page.vala +++ b/src/components/sidebar/pages/desktop_page.vala @@ -296,6 +296,16 @@ namespace Singularity { rotation_state.get_rotate_enabled()); grid_group.add_row(rotate_row); + var next_wallpaper_row = new PreferencesRow(); + var next_wallpaper_button = new Button.with_label(_("Next Wallpaper Now")); + next_wallpaper_button.margin_start = next_wallpaper_button.margin_end = 10; + next_wallpaper_button.margin_top = next_wallpaper_button.margin_bottom = 8; + next_wallpaper_button.clicked.connect(() => { + WallpaperManager.get_default().rotate_wallpaper_now(); + }); + next_wallpaper_row.set_child(next_wallpaper_button); + grid_group.add_row(next_wallpaper_row); + // Wallpaper attribution overlay toggle. Background.vala listens // for settings.changed["show-wallpaper-attribution"] and hides // the overlay live; the gsettings key also gates the live diff --git a/src/core/wallpaper_gallery.vala b/src/core/wallpaper_gallery.vala index 7fc5e32..11d30a5 100644 --- a/src/core/wallpaper_gallery.vala +++ b/src/core/wallpaper_gallery.vala @@ -5,10 +5,14 @@ namespace Singularity { internal class WallpaperCandidate : Object { public string uri { get; private set; } public bool is_recent { get; private set; } + public int width { get; private set; } + public int height { get; private set; } - public WallpaperCandidate(string uri, bool is_recent) { + public WallpaperCandidate(string uri, bool is_recent, int width = 0, int height = 0) { this.uri = uri; this.is_recent = is_recent; + this.width = width; + this.height = height; } } @@ -100,7 +104,10 @@ namespace Singularity { string uri = child.get_uri(); if (thread_seen.contains(uri)) continue; thread_seen.add(uri); - candidates.add(new WallpaperCandidate(uri, false)); + int width = 0; + int height = 0; + Gdk.Pixbuf.get_file_info(child.get_path(), out width, out height); + candidates.add(new WallpaperCandidate(uri, false, width, height)); } } catch (Error e) { } diff --git a/src/core/wallpaper_manager.vala b/src/core/wallpaper_manager.vala index ee444bb..411bd30 100644 --- a/src/core/wallpaper_manager.vala +++ b/src/core/wallpaper_manager.vala @@ -106,6 +106,15 @@ namespace Singularity { public void start_rotation() { if (rotator != null) return; rotator = WallpaperRotator.get_default(); + var display = Gdk.Display.get_default(); + if (display != null && display.get_monitors().get_n_items() > 0) { + var monitor = display.get_monitors().get_item(0) as Gdk.Monitor; + if (monitor != null) { + var geometry = monitor.get_geometry(); + if (geometry.height > 0) + rotator.target_aspect_ratio = (double) geometry.width / (double) geometry.height; + } + } rotator.current_uri = settings.get_string("background-picture-uri"); rotator.wallpaper_selected.connect((uri) => { settings.set_string("background-picture-uri", uri); @@ -117,6 +126,11 @@ namespace Singularity { rotator.start(); } + public void rotate_wallpaper_now() { + start_rotation(); + rotator.rotate_async(); + } + public void reload() { // Read attribution keys defensively: a schema that doesn't // have them yet (older deploy, ad-hoc bisect, dev mode) must diff --git a/src/core/wallpaper_rotator.vala b/src/core/wallpaper_rotator.vala index 6b010f6..2db33b6 100644 --- a/src/core/wallpaper_rotator.vala +++ b/src/core/wallpaper_rotator.vala @@ -6,6 +6,8 @@ namespace Singularity { // Selects rotation timing and images; WallpaperManager applies the signal. public class WallpaperRotator : Object { private static WallpaperRotator? _instance = null; + private const int FAVORITE_WEIGHT = 3; + private const double ASPECT_TOLERANCE = 0.15; public signal void wallpaper_selected(string uri); @@ -19,12 +21,21 @@ namespace Singularity { private uint tick_id = 0; private uint restart_id = 0; private FileMonitor? state_monitor = null; + private GLib.Settings? selection_settings = null; + private WallpaperFavorites? favorites = null; + private int default_min_width = 1280; + private int default_min_height = 720; + private bool default_match_aspect = false; + private bool default_favor_favorites = true; + public double target_aspect_ratio { get; set; default = 0.0; } public static WallpaperRotator get_default() { if (_instance == null) { _instance = new WallpaperRotator( WallpaperRotationState.default_config_dir(), WallpaperCollections.default_search_roots()); + _instance.selection_settings = new GLib.Settings("dev.sinty.desktop"); + _instance.favorites = new WallpaperFavorites(_instance.selection_settings); } return _instance; } @@ -35,6 +46,18 @@ namespace Singularity { this.state = new WallpaperRotationState(config_dir); } + public void configure_selection(int min_width, int min_height, + bool match_aspect, bool favor_favorites, + double target_aspect = 0.0, + WallpaperFavorites? favorites = null) { + default_min_width = min_width; + default_min_height = min_height; + default_match_aspect = match_aspect; + default_favor_favorites = favor_favorites; + target_aspect_ratio = target_aspect; + this.favorites = favorites; + } + public void start() { watch_state_dir(); reschedule(); @@ -121,12 +144,83 @@ namespace Singularity { 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()); + int min_width = setting_int("wallpaper-rotation-min-width", default_min_width); + int min_height = setting_int("wallpaper-rotation-min-height", default_min_height); + bool match_aspect = setting_bool("wallpaper-rotation-match-aspect", default_match_aspect); + bool favor_favorites = setting_bool("wallpaper-rotation-favor-favorites", default_favor_favorites); + var regarded = regard(candidates, min_width, min_height, + match_aspect, target_aspect_ratio); + return pick_candidates(regarded, current, Random.next_int(), + favor_favorites, favorites); + } + + private int setting_int(string key, int fallback) { + if (selection_settings == null || !selection_settings.settings_schema.has_key(key)) return fallback; + return selection_settings.get_int(key); + } + + private bool setting_bool(string key, bool fallback) { + if (selection_settings == null || !selection_settings.settings_schema.has_key(key)) return fallback; + return selection_settings.get_boolean(key); + } + + internal static ArrayList regard( + Gee.List candidates, int min_width, int min_height, + bool match_aspect, double target_aspect) { + var sized = new ArrayList(); + foreach (var candidate in candidates) { + if (candidate.width >= min_width && candidate.height >= min_height) + sized.add(candidate); + } + if (sized.size == 0 && candidates.size > 0) { + debug("wallpaper rotator: minimum resolution excluded every candidate; using unfiltered collection"); + foreach (var candidate in candidates) sized.add(candidate); + } + if (!match_aspect || target_aspect <= 0.0) return sized; + + var matched = new ArrayList(); + foreach (var candidate in sized) { + if (candidate.width <= 0 || candidate.height <= 0) continue; + double aspect = (double) candidate.width / (double) candidate.height; + if (Math.fabs(aspect - target_aspect) / target_aspect <= ASPECT_TOLERANCE) + matched.add(candidate); + } + if (matched.size == 0) { + debug("wallpaper rotator: aspect preference matched no candidates; using resolution-filtered collection"); + return sized; + } + return matched; + } + + internal static string? pick_candidates(Gee.List candidates, + string? current_uri, uint32 roll, + bool favor_favorites, + WallpaperFavorites? favorites) { + var choices = new ArrayList(); + foreach (var candidate in candidates) { + if (candidates.size == 1 || candidate.uri != current_uri) choices.add(candidate); + } + if (choices.size == 0) return candidates.size > 0 ? candidates[0].uri : null; + + int total_weight = 0; + foreach (var candidate in choices) { + string? path = File.new_for_uri(candidate.uri).get_path(); + bool favorite = favor_favorites && favorites != null && path != null + && favorites.is_favorite(path); + total_weight += favorite ? FAVORITE_WEIGHT : 1; + } + int selected = (int) (roll % total_weight); + foreach (var candidate in choices) { + string? path = File.new_for_uri(candidate.uri).get_path(); + bool favorite = favor_favorites && favorites != null && path != null + && favorites.is_favorite(path); + int weight = favorite ? FAVORITE_WEIGHT : 1; + if (selected < weight) return candidate.uri; + selected -= weight; + } + return choices[0].uri; } - // 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]; diff --git a/tests/dev.sinty.desktop.gschema.xml b/tests/dev.sinty.desktop.gschema.xml index 8da7160..39fa515 100644 --- a/tests/dev.sinty.desktop.gschema.xml +++ b/tests/dev.sinty.desktop.gschema.xml @@ -14,5 +14,9 @@ 8 + 1280 + 720 + false + true diff --git a/tests/wallpaper_rotator_test.vala b/tests/wallpaper_rotator_test.vala index a96ac4b..1948732 100644 --- a/tests/wallpaper_rotator_test.vala +++ b/tests/wallpaper_rotator_test.vala @@ -10,12 +10,12 @@ private string make_dir(string name) { return path; } -private string image_file(string dir, string name) { +private string image_file(string dir, string name, int width = 1920, int height = 1080) { string path = Path.build_filename(dir, name + ".svg"); try { DirUtils.create_with_parents(dir, 0700); FileUtils.set_contents(path, - ""); + "".printf(width, height)); } catch (Error e) { error("fixture: %s", e.message); } return File.new_for_path(path).get_uri(); } @@ -58,6 +58,49 @@ private void test_pick_on_empty_list() { assert(WallpaperRotator.pick(new ArrayList(), null, 0) == null); } +private void test_minimum_resolution_excludes_small_candidates() { + var candidates = new ArrayList(); + candidates.add(new WallpaperCandidate("file:///small.jpg", false, 640, 480)); + candidates.add(new WallpaperCandidate("file:///large.jpg", false, 1920, 1080)); + var regarded = WallpaperRotator.regard(candidates, 1280, 720, false, 0.0); + assert(regarded.size == 1); + assert(regarded[0].uri == "file:///large.jpg"); +} + +private void test_empty_resolution_pool_falls_back() { + var candidates = new ArrayList(); + candidates.add(new WallpaperCandidate("file:///small-a.jpg", false, 640, 480)); + candidates.add(new WallpaperCandidate("file:///small-b.jpg", false, 800, 600)); + var regarded = WallpaperRotator.regard(candidates, 1280, 720, false, 0.0); + assert(regarded.size == 2); +} + +private void test_aspect_preference_uses_display_ratio() { + var candidates = new ArrayList(); + candidates.add(new WallpaperCandidate("file:///portrait.jpg", false, 1080, 1920)); + candidates.add(new WallpaperCandidate("file:///wide.jpg", false, 1920, 1080)); + var regarded = WallpaperRotator.regard(candidates, 1, 1, true, 16.0 / 9.0); + assert(regarded.size == 1); + assert(regarded[0].uri == "file:///wide.jpg"); +} + +private void test_favorites_receive_three_times_the_weight() { + var settings = new GLib.Settings("dev.sinty.desktop"); + var favorites = new WallpaperFavorites(settings); + string favorite_path = Path.build_filename(fixture_root, "favorite.jpg"); + favorites.toggle_favorite(favorite_path); + var candidates = new ArrayList(); + candidates.add(new WallpaperCandidate(File.new_for_path(favorite_path).get_uri(), false, 1920, 1080)); + candidates.add(new WallpaperCandidate("file:///ordinary.jpg", false, 1920, 1080)); + int favorite_picks = 0; + for (uint32 roll = 0; roll < 400; roll++) { + string? chosen = WallpaperRotator.pick_candidates(candidates, null, roll, true, favorites); + if (chosen == candidates[0].uri) favorite_picks++; + } + assert(favorite_picks == 300); + favorites.toggle_favorite(favorite_path); +} + private void test_rotates_within_the_selected_collection_only() { string registry = make_dir("registry-scoped"); string alpha = make_dir("alpha"); @@ -137,6 +180,24 @@ private void test_rotate_now_announces_nothing_when_there_is_nothing() { assert(!announced); } +private void test_manual_trigger_changes_immediately() { + string registry = make_dir("registry-manual"); + string pack = make_dir("manual-pack"); + string current = image_file(pack, "current"); + string next = image_file(pack, "next"); + write_collection(registry, "manual", pack); + string config = make_dir("config-manual"); + new WallpaperRotationState(config).set_selected_collection("manual"); + + var rotator = new WallpaperRotator(config, { registry }); + rotator.current_uri = current; + string? announced = null; + rotator.wallpaper_selected.connect((uri) => { announced = uri; }); + rotator.rotate_now(); + assert(announced == next); + assert(rotator.current_uri == next); +} + private void test_choose_next_for_excludes_the_supplied_wallpaper() { string registry = make_dir("registry-exclude"); string pack = make_dir("exclude-pack"); @@ -232,12 +293,17 @@ public int main(string[] args) { 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/regard-minimum-resolution", test_minimum_resolution_excludes_small_candidates); + Test.add_func("/wallpaper-rotator/regard-empty-fallback", test_empty_resolution_pool_falls_back); + Test.add_func("/wallpaper-rotator/regard-aspect", test_aspect_preference_uses_display_ratio); + Test.add_func("/wallpaper-rotator/favorites-weighting", test_favorites_receive_three_times_the_weight); 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/manual-trigger-changes", test_manual_trigger_changes_immediately); 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);