From 3ee9dabf75781a7c11f64ab2e7f896f5a194f219 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Fri, 25 Sep 2026 12:00:30 -0400 Subject: [PATCH] feat(wallpaper): add favorites history and panel controls Persist bounded wallpaper history and favorites, and expose navigation, rotation, and favorite actions in the panel. Assisted-by: Codex:gpt-5 Assisted-by: Codex:gpt-5.6-sol AI-Scope: Rebased and integrated Phase 1 wallpaper favorites, history, panel controls, tests, and the generic rotation-state path. --- meson.build | 29 +++++++ src/components/panel/panel.vala | 113 +++++++++++++++++++++++++++- src/core/wallpaper_favorites.vala | 45 +++++++++++ src/core/wallpaper_history.vala | 102 +++++++++++++++++++++++++ src/core/wallpaper_manager.vala | 3 + tests/dev.sinty.desktop.gschema.xml | 9 +++ tests/wallpaper_favorites_test.vala | 45 +++++++++++ tests/wallpaper_history_test.vala | 57 ++++++++++++++ 8 files changed, 399 insertions(+), 4 deletions(-) create mode 100644 src/core/wallpaper_favorites.vala create mode 100644 src/core/wallpaper_history.vala create mode 100644 tests/dev.sinty.desktop.gschema.xml create mode 100644 tests/wallpaper_favorites_test.vala create mode 100644 tests/wallpaper_history_test.vala diff --git a/meson.build b/meson.build index fa6192b..f4cf6b8 100644 --- a/meson.build +++ b/meson.build @@ -301,6 +301,8 @@ singularity_core_sources = files( 'src/core/wallpaper_rotation_state.vala', 'src/core/wallpaper_rotator.vala', 'src/core/artist_pack_manager.vala', + 'src/core/wallpaper_history.vala', + 'src/core/wallpaper_favorites.vala', 'src/core/wallpaper_sidecar.vala', 'src/core/wallpaper_browse_cache.vala', 'src/core/wallpaper_thumbnail_cache.vala', @@ -528,6 +530,33 @@ wallpaper_rotation_state_test = executable('wallpaper-rotation-state-test', ) test('wallpaper-rotation-state', wallpaper_rotation_state_test) +glib_compile_schemas = find_program('glib-compile-schemas') +wallpaper_test_schema = custom_target('wallpaper-test-schema', + input: 'tests/dev.sinty.desktop.gschema.xml', + output: 'gschemas.compiled', + command: [glib_compile_schemas, '--strict', '--targetdir', meson.current_build_dir(), + join_paths(meson.current_source_dir(), 'tests')], + build_by_default: true, +) +wallpaper_test_env = environment() +wallpaper_test_env.set('GSETTINGS_SCHEMA_DIR', meson.current_build_dir()) + +wallpaper_history_test = executable('wallpaper-history-test', + sources: ['src/core/wallpaper_history.vala', 'tests/wallpaper_history_test.vala'], + dependencies: [dependency('gobject-2.0'), dependency('gio-2.0'), dependency('gio-unix-2.0'), gee_dep], +) +test('wallpaper-history', wallpaper_history_test, + depends: wallpaper_test_schema, + env: wallpaper_test_env) + +wallpaper_favorites_test = executable('wallpaper-favorites-test', + sources: ['src/core/wallpaper_favorites.vala', 'tests/wallpaper_favorites_test.vala'], + dependencies: [dependency('gobject-2.0'), dependency('gio-2.0'), dependency('gio-unix-2.0'), gee_dep], +) +test('wallpaper-favorites', wallpaper_favorites_test, + depends: wallpaper_test_schema, + env: wallpaper_test_env) + 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], diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index ae96bc5..4425b35 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -5,6 +5,109 @@ using Singularity.Widgets; namespace Singularity { + private class WallpaperIndicator : Gtk.Box { + private GLib.Settings settings; + private WallpaperHistory history; + private WallpaperFavorites favorites; + private WallpaperRotationState rotation_state; + private Button previous_button; + private Button next_button; + private Button pause_button; + private Button favorite_button; + private string? navigation_target = null; + + public WallpaperIndicator(GLib.Settings settings) { + Object(orientation: Orientation.HORIZONTAL, spacing: 0); + valign = Align.CENTER; + this.settings = settings; + history = new WallpaperHistory(settings); + favorites = new WallpaperFavorites(settings); + rotation_state = new WallpaperRotationState( + WallpaperRotationState.default_config_dir()); + + var button = new MenuButton(); + button.add_css_class("flat"); + button.tooltip_text = _("Wallpaper controls"); + var icon = new Image.from_icon_name("preferences-desktop-wallpaper-symbolic"); + icon.pixel_size = 16; + button.child = icon; + append(button); + + var controls = new Box(Orientation.HORIZONTAL, 6); + controls.margin_top = 10; + controls.margin_bottom = 10; + controls.margin_start = 12; + controls.margin_end = 12; + + previous_button = new Button.from_icon_name("go-previous-symbolic"); + previous_button.tooltip_text = _("Previous wallpaper"); + previous_button.clicked.connect(() => navigate(history.go_back())); + controls.append(previous_button); + + next_button = new Button.from_icon_name("go-next-symbolic"); + next_button.tooltip_text = _("Next wallpaper"); + next_button.clicked.connect(() => navigate(history.go_forward())); + controls.append(next_button); + + pause_button = new Button(); + pause_button.clicked.connect(() => { + rotation_state.set_rotate_enabled(!rotation_state.get_rotate_enabled()); + update_controls(); + }); + controls.append(pause_button); + + favorite_button = new Button(); + favorite_button.clicked.connect(() => { + string? path = WallpaperManager.get_default().wallpaper_path; + if (path != null) favorites.toggle_favorite(path); + }); + controls.append(favorite_button); + + var popover = new Popover(); + popover.child = controls; + button.popover = popover; + popover.notify["visible"].connect(() => { + if (popover.visible) update_controls(); + }); + + var manager = WallpaperManager.get_default(); + manager.wallpaper_path_changed.connect((path) => { + if (navigation_target == path) navigation_target = null; + else history.record(path); + update_controls(); + }); + favorites.favorites_changed.connect(update_controls); + if (manager.wallpaper_path != null && history.current_path != manager.wallpaper_path) + history.record(manager.wallpaper_path); + update_controls(); + } + + private void navigate(string? path) { + if (path == null) return; + navigation_target = path; + settings.delay(); + SettingsSafety.set_string(settings, "background-picture-uri", + File.new_for_path(path).get_uri()); + SettingsSafety.set_string(settings, "background-attribution-title", ""); + SettingsSafety.set_string(settings, "background-attribution-author", ""); + settings.apply(); + update_controls(); + } + + private void update_controls() { + previous_button.sensitive = history.can_go_back(); + next_button.sensitive = history.can_go_forward(); + bool rotating = rotation_state.get_rotate_enabled(); + pause_button.icon_name = rotating ? "media-playback-pause-symbolic" : "media-playback-start-symbolic"; + pause_button.tooltip_text = rotating ? _("Pause wallpaper rotation") : _("Resume wallpaper rotation"); + string? path = WallpaperManager.get_default().wallpaper_path; + bool is_favorite = path != null && favorites.is_favorite(path); + favorite_button.icon_name = is_favorite ? "starred-symbolic" : "non-starred-symbolic"; + favorite_button.tooltip_text = is_favorite ? _("Remove from favorites") : _("Add to favorites"); + favorite_button.sensitive = path != null; + } + } + private class TilingPositionIndicator : Gtk.Fixed { private const int TRACK_WIDTH = 58; private const int TRACK_HEIGHT = 18; @@ -638,6 +741,8 @@ namespace Singularity { clock_box.append(clock_btn); clock_box.append(clock_suffix_box); layout_items["clock"] = clock_box; + if (is_primary && !is_greeter_mode) + layout_items["wallpaper"] = new WallpaperIndicator(_settings); reload_bar_layout(); _settings.changed["panel-layout-left"].connect(() => { if (!saving_bar_layout) reload_bar_layout(); @@ -656,8 +761,8 @@ namespace Singularity { center_box, right_box, layout_items, - { "overview", "workspaces", "tiling-position", "app-title", "global-menu", "system", "notifications", "clock" }, - { _("Overview"), _("Workspaces"), _("Scrolling Position"), _("App Title"), _("Global Menu"), _("System Status"), _("Notifications"), _("Clock") } + { "overview", "workspaces", "tiling-position", "app-title", "global-menu", "system", "notifications", "clock", "wallpaper" }, + { _("Overview"), _("Workspaces"), _("Scrolling Position"), _("App Title"), _("Global Menu"), _("System Status"), _("Notifications"), _("Clock"), _("Wallpaper") } ); layout_editor.move_requested.connect((item_id, section, index) => { if (bar_layout != null && bar_layout.move(item_id, section, index)) save_bar_layout(); @@ -1000,13 +1105,13 @@ namespace Singularity { private void reload_bar_layout() { string[] item_ids = { "overview", "workspaces", "tiling-position", "app-title", "global-menu", - "system", "notifications", "clock" + "system", "notifications", "clock", "wallpaper" }; bar_layout = new BarLayout( item_ids, { "overview", "workspaces", "app-title", "global-menu" }, { "tiling-position" }, - { "system", "notifications", "clock" }, + { "system", "notifications", "clock", "wallpaper" }, _settings.get_strv("panel-layout-left"), _settings.get_strv("panel-layout-center"), _settings.get_strv("panel-layout-right") diff --git a/src/core/wallpaper_favorites.vala b/src/core/wallpaper_favorites.vala new file mode 100644 index 0000000..892fe3f --- /dev/null +++ b/src/core/wallpaper_favorites.vala @@ -0,0 +1,45 @@ +using GLib; +using Gee; + +namespace Singularity { + + public class WallpaperFavorites : Object { + private const string FAVORITES_KEY = "wallpaper-favorites"; + private GLib.Settings settings; + private ArrayList favorites = new ArrayList(); + + public signal void favorites_changed(); + + public WallpaperFavorites(GLib.Settings? settings = null) { + this.settings = settings ?? new GLib.Settings("dev.sinty.desktop"); + SettingsSchema? schema = this.settings.settings_schema; + if (schema == null || !schema.has_key(FAVORITES_KEY)) return; + foreach (string path in this.settings.get_strv(FAVORITES_KEY)) { + if (path != "" && !favorites.contains(path)) favorites.add(path); + } + } + + private void save() { + SettingsSchema? schema = settings.settings_schema; + if (schema == null || !schema.has_key(FAVORITES_KEY)) return; + settings.set_strv(FAVORITES_KEY, list_favorites()); + } + + public bool is_favorite(string path) { + return path != "" && favorites.contains(path); + } + + public void toggle_favorite(string path) { + if (path == "") return; + if (!favorites.remove(path)) favorites.add(path); + save(); + favorites_changed(); + } + + public string[] list_favorites() { + string[] result = {}; + foreach (string path in favorites) result += path; + return result; + } + } +} diff --git a/src/core/wallpaper_history.vala b/src/core/wallpaper_history.vala new file mode 100644 index 0000000..0b26b16 --- /dev/null +++ b/src/core/wallpaper_history.vala @@ -0,0 +1,102 @@ +using GLib; +using Gee; + +namespace Singularity { + + public class WallpaperHistory : Object { + public const int MAX_ENTRIES = 200; + private const string BACK_KEY = "wallpaper-history-back"; + private const string FORWARD_KEY = "wallpaper-history-forward"; + private const string CURRENT_KEY = "wallpaper-history-current"; + + private GLib.Settings settings; + private ArrayList back = new ArrayList(); + private ArrayList forward = new ArrayList(); + private int max_entries; + public string? current_path { get; private set; default = null; } + public int size { get { return back.size + forward.size + (current_path != null ? 1 : 0); } } + + public WallpaperHistory(GLib.Settings? settings = null, int max_entries = MAX_ENTRIES) { + this.settings = settings ?? new GLib.Settings("dev.sinty.desktop"); + this.max_entries = int.max(1, max_entries); + load(); + } + + private bool has_key(string key) { + SettingsSchema? schema = settings.settings_schema; + return schema != null && schema.has_key(key); + } + + private void load_stack(ArrayList destination, string key) { + if (!has_key(key)) return; + foreach (string path in settings.get_strv(key)) { + if (path != "") destination.add(path); + } + } + + private void load() { + load_stack(back, BACK_KEY); + load_stack(forward, FORWARD_KEY); + if (has_key(CURRENT_KEY)) { + string saved = settings.get_string(CURRENT_KEY); + if (saved != "") current_path = saved; + } + trim_to_limit(); + } + + private string[] stack_to_array(ArrayList stack) { + string[] result = {}; + foreach (string path in stack) result += path; + return result; + } + + private void save() { + settings.delay(); + if (has_key(BACK_KEY)) settings.set_strv(BACK_KEY, stack_to_array(back)); + if (has_key(FORWARD_KEY)) settings.set_strv(FORWARD_KEY, stack_to_array(forward)); + if (has_key(CURRENT_KEY)) settings.set_string(CURRENT_KEY, current_path ?? ""); + settings.apply(); + } + + private void trim_to_limit() { + while (size > max_entries) { + if (back.size > 0) back.remove_at(0); + else if (forward.size > 0) forward.remove_at(0); + else break; + } + } + + public void record(string path) { + if (path == "" || path == current_path) return; + if (current_path != null) back.add(current_path); + current_path = path; + forward.clear(); + trim_to_limit(); + save(); + } + + public bool can_go_back() { + return current_path != null && back.size > 0; + } + + public bool can_go_forward() { + return current_path != null && forward.size > 0; + } + + public string? go_back() { + if (!can_go_back()) return null; + forward.add(current_path); + current_path = back.remove_at(back.size - 1); + save(); + return current_path; + } + + public string? go_forward() { + if (!can_go_forward()) return null; + back.add(current_path); + current_path = forward.remove_at(forward.size - 1); + save(); + return current_path; + } + } +} diff --git a/src/core/wallpaper_manager.vala b/src/core/wallpaper_manager.vala index a364d80..2146328 100644 --- a/src/core/wallpaper_manager.vala +++ b/src/core/wallpaper_manager.vala @@ -31,6 +31,8 @@ namespace Singularity { public string attribution_author { get; private set; default = ""; } public signal void wallpaper_changed(); + // Emitted only after a different image becomes the displayed wallpaper. + public signal void wallpaper_path_changed(string new_path); public static WallpaperManager get_default() { if (_instance == null) { @@ -243,6 +245,7 @@ namespace Singularity { if (pb_small != null) preview_texture = Texture.for_pixbuf(pb_small); message("Wallpaper loaded: %s", load_path); wallpaper_changed(); + wallpaper_path_changed(load_path); return false; }); }); diff --git a/tests/dev.sinty.desktop.gschema.xml b/tests/dev.sinty.desktop.gschema.xml new file mode 100644 index 0000000..36cb48e --- /dev/null +++ b/tests/dev.sinty.desktop.gschema.xml @@ -0,0 +1,9 @@ + + + + [] + [] + '' + [] + + diff --git a/tests/wallpaper_favorites_test.vala b/tests/wallpaper_favorites_test.vala new file mode 100644 index 0000000..98d006d --- /dev/null +++ b/tests/wallpaper_favorites_test.vala @@ -0,0 +1,45 @@ +using GLib; +using Singularity; + +private GLib.Settings make_settings(GLib.SettingsBackend? backend = null) { + var schema = SettingsSchemaSource.get_default().lookup("dev.sinty.desktop", true); + assert(schema != null); + return new GLib.Settings.full(schema, + backend ?? SettingsBackend.memory_settings_backend_new(), null); +} + +private void test_toggle_and_list() { + var favorites = new WallpaperFavorites(make_settings()); + int changes = 0; + favorites.favorites_changed.connect(() => changes++); + favorites.toggle_favorite("/a"); + favorites.toggle_favorite("/b"); + assert(favorites.is_favorite("/a")); + assert(favorites.list_favorites().length == 2); + favorites.toggle_favorite("/a"); + assert(!favorites.is_favorite("/a")); + assert(favorites.list_favorites()[0] == "/b"); + assert(changes == 3); +} + +private void test_persistence_roundtrip() { + var backend = SettingsBackend.memory_settings_backend_new(); + var first = new WallpaperFavorites(make_settings(backend)); + first.toggle_favorite("/a"); + first.toggle_favorite("/b"); + + var restored = new WallpaperFavorites(make_settings(backend)); + assert(restored.is_favorite("/a")); + assert(restored.is_favorite("/b")); + restored.toggle_favorite("/a"); + var again = new WallpaperFavorites(make_settings(backend)); + assert(!again.is_favorite("/a")); + assert(again.is_favorite("/b")); +} + +public int main(string[] args) { + Test.init(ref args); + Test.add_func("/wallpaper-favorites/toggle-and-list", test_toggle_and_list); + Test.add_func("/wallpaper-favorites/persistence-roundtrip", test_persistence_roundtrip); + return Test.run(); +} diff --git a/tests/wallpaper_history_test.vala b/tests/wallpaper_history_test.vala new file mode 100644 index 0000000..7f313fa --- /dev/null +++ b/tests/wallpaper_history_test.vala @@ -0,0 +1,57 @@ +using GLib; +using Singularity; + +private GLib.Settings make_settings(GLib.SettingsBackend? backend = null) { + var schema = SettingsSchemaSource.get_default().lookup("dev.sinty.desktop", true); + assert(schema != null); + return new GLib.Settings.full(schema, + backend ?? SettingsBackend.memory_settings_backend_new(), null); +} + +private void test_back_forward_semantics() { + var history = new WallpaperHistory(make_settings()); + history.record("/a"); + history.record("/b"); + history.record("/c"); + assert(history.go_back() == "/b"); + assert(history.go_back() == "/a"); + assert(history.go_forward() == "/b"); + history.record("/d"); + assert(!history.can_go_forward()); + assert(history.go_back() == "/b"); +} + +private void test_bounded_size() { + var history = new WallpaperHistory(make_settings(), 3); + history.record("/a"); + history.record("/b"); + history.record("/c"); + history.record("/d"); + assert(history.size == 3); + assert(history.go_back() == "/c"); + assert(history.go_back() == "/b"); + assert(history.go_back() == null); +} + +private void test_persistence_roundtrip() { + var backend = SettingsBackend.memory_settings_backend_new(); + var first = new WallpaperHistory(make_settings(backend)); + first.record("/a"); + first.record("/b"); + first.record("/c"); + assert(first.go_back() == "/b"); + + var restored = new WallpaperHistory(make_settings(backend)); + assert(restored.current_path == "/b"); + assert(restored.go_back() == "/a"); + assert(restored.go_forward() == "/b"); + assert(restored.go_forward() == "/c"); +} + +public int main(string[] args) { + Test.init(ref args); + Test.add_func("/wallpaper-history/back-forward-semantics", test_back_forward_semantics); + Test.add_func("/wallpaper-history/bounded-size", test_bounded_size); + Test.add_func("/wallpaper-history/persistence-roundtrip", test_persistence_roundtrip); + return Test.run(); +}