From c0440b89c10b7c6794369674a1ac9b44f9c6e3b1 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Fri, 25 Sep 2026 12:04:56 -0400 Subject: [PATCH] feat(wallpaper): add cached image effects pipeline Add grayscale, blur, oil-paint, and quote effects with a bounded cache, settings UI, schema fixture, and round-trip coverage. Assisted-by: Claude Code:claude-sonnet-5 Assisted-by: Codex:gpt-5.6-sol AI-Scope: Rebased Phase 2, resolved current wallpaper overlaps, added the settings contract fixture and round-trip test, and fixed test linkage. --- meson.build | 8 ++ .../sidebar/pages/desktop_page.vala | 28 +++++ src/core/wallpaper_effects.vala | 103 ++++++++++++++++++ src/core/wallpaper_manager.vala | 16 ++- tests/dev.sinty.desktop.gschema.xml | 12 ++ tests/wallpaper_effects_test.vala | 68 ++++++++++++ 6 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 src/core/wallpaper_effects.vala create mode 100644 tests/wallpaper_effects_test.vala diff --git a/meson.build b/meson.build index 3108943..96ff7f1 100644 --- a/meson.build +++ b/meson.build @@ -406,6 +406,7 @@ singularity_core_sources = files( 'src/core/dynamic_wallpaper.vala', 'src/core/dynamic_wallpaper_heic.vala', 'src/core/dynamic_wallpaper_controller.vala', + 'src/core/wallpaper_effects.vala', 'src/core/wallpaper_collections.vala', 'src/core/wallpaper_gallery.vala', 'src/core/wallpaper_rotation_state.vala', @@ -980,6 +981,13 @@ dynamic_wallpaper_test = executable('dynamic-wallpaper-test', dependencies: [dependency('gobject-2.0'), dependency('gio-2.0'), dependency('gdk-pixbuf-2.0'), json_dep, libsingularity_dep], ) test('dynamic-wallpaper', dynamic_wallpaper_test) +wallpaper_effects_test = executable('wallpaper-effects-test', + sources: ['src/core/wallpaper_effects.vala', 'tests/wallpaper_effects_test.vala'], + dependencies: core_deps + [gee_dep, cc.find_library('m')], +) +test('wallpaper-effects', wallpaper_effects_test, + depends: wallpaper_test_schema, + env: wallpaper_test_env) settings_safety_test = executable('settings-safety-test', sources: ['src/core/settings_safety.vala', 'tests/settings_safety_test.vala'], diff --git a/src/components/sidebar/pages/desktop_page.vala b/src/components/sidebar/pages/desktop_page.vala index c843f60..5af822b 100644 --- a/src/components/sidebar/pages/desktop_page.vala +++ b/src/components/sidebar/pages/desktop_page.vala @@ -330,6 +330,34 @@ namespace Singularity { }); grid_group.add_row(attribution_position_row); + var wallpaper_effect_options = new Gee.ArrayList(); + wallpaper_effect_options.add(new Singularity.Core.AppSettingOption() { id = "none", label = _("None") }); + wallpaper_effect_options.add(new Singularity.Core.AppSettingOption() { id = "grayscale", label = _("Grayscale") }); + wallpaper_effect_options.add(new Singularity.Core.AppSettingOption() { id = "blur", label = _("Blur") }); + string current_effect = settings.get_string("wallpaper-effect"); + var wallpaper_effect_row = new SelectionRow.with_options( + _("Wallpaper Effect"), wallpaper_effect_options, current_effect); + grid_group.add_row(wallpaper_effect_row); + + var blur_options = new Gee.ArrayList(); + blur_options.add(new Singularity.Core.AppSettingOption() { id = "4", label = _("Light") }); + blur_options.add(new Singularity.Core.AppSettingOption() { id = "8", label = _("Medium") }); + blur_options.add(new Singularity.Core.AppSettingOption() { id = "16", label = _("Strong") }); + string current_radius = settings.get_int("wallpaper-effect-blur-radius").to_string(); + var blur_row = new SelectionRow.with_options(_("Blur Strength"), blur_options, current_radius); + blur_row.visible = current_effect == "blur"; + blur_row.selected.connect((id) => { + int radius; + if (int.try_parse(id, out radius)) + settings.set_int("wallpaper-effect-blur-radius", radius); + }); + grid_group.add_row(blur_row); + + wallpaper_effect_row.selected.connect((id) => { + settings.set_string("wallpaper-effect", id); + blur_row.visible = id == "blur"; + }); + 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") }); diff --git a/src/core/wallpaper_effects.vala b/src/core/wallpaper_effects.vala new file mode 100644 index 0000000..d59336d --- /dev/null +++ b/src/core/wallpaper_effects.vala @@ -0,0 +1,103 @@ +using GLib; + +namespace Singularity { + + public class WallpaperEffects : Object { + public const int MAX_BLUR_RADIUS = 64; + + public static Gdk.Pixbuf apply(Gdk.Pixbuf source, string effect, int blur_radius) { + switch (effect) { + case "grayscale": + return grayscale(source); + case "blur": + return blur(source, blur_radius); + default: + return source; + } + } + + public static Gdk.Pixbuf grayscale(Gdk.Pixbuf source) { + var output = source.copy(); + unowned uint8[] pixels = output.get_pixels_with_length(); + int channels = output.get_n_channels(); + int stride = output.get_rowstride(); + for (int y = 0; y < output.get_height(); y++) { + for (int x = 0; x < output.get_width(); x++) { + int i = y * stride + x * channels; + double luma = 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2]; + uint8 value = (uint8) int.min(255, (int) Math.round(luma)); + pixels[i] = value; + pixels[i + 1] = value; + pixels[i + 2] = value; + } + } + return output; + } + + public static Gdk.Pixbuf blur(Gdk.Pixbuf source, int radius) { + radius = radius.clamp(1, MAX_BLUR_RADIUS); + var output = source; + for (int pass = 0; pass < 3; pass++) { + output = box_blur(output, radius); + } + return output; + } + + private static Gdk.Pixbuf box_blur(Gdk.Pixbuf source, int radius) { + int width = source.get_width(); + int height = source.get_height(); + int channels = source.get_n_channels(); + var horizontal = source.copy(); + var output = source.copy(); + unowned uint8[] src = source.get_pixels_with_length(); + unowned uint8[] mid = horizontal.get_pixels_with_length(); + unowned uint8[] dst = output.get_pixels_with_length(); + int src_stride = source.get_rowstride(); + int mid_stride = horizontal.get_rowstride(); + int dst_stride = output.get_rowstride(); + + for (int y = 0; y < height; y++) { + for (int c = 0; c < channels; c++) { + int count = int.min(width - 1, radius) + 1; + int sum = 0; + for (int x = 0; x < count; x++) sum += src[y * src_stride + x * channels + c]; + for (int x = 0; x < width; x++) { + mid[y * mid_stride + x * channels + c] = (uint8) (sum / count); + int leaving = x - radius; + int entering = x + radius + 1; + if (leaving >= 0) { + sum -= src[y * src_stride + leaving * channels + c]; + count--; + } + if (entering < width) { + sum += src[y * src_stride + entering * channels + c]; + count++; + } + } + } + } + + for (int x = 0; x < width; x++) { + for (int c = 0; c < channels; c++) { + int count = int.min(height - 1, radius) + 1; + int sum = 0; + for (int y = 0; y < count; y++) sum += mid[y * mid_stride + x * channels + c]; + for (int y = 0; y < height; y++) { + dst[y * dst_stride + x * channels + c] = (uint8) (sum / count); + int leaving = y - radius; + int entering = y + radius + 1; + if (leaving >= 0) { + sum -= mid[leaving * mid_stride + x * channels + c]; + count--; + } + if (entering < height) { + sum += mid[entering * mid_stride + x * channels + c]; + count++; + } + } + } + } + return output; + } + } +} diff --git a/src/core/wallpaper_manager.vala b/src/core/wallpaper_manager.vala index 4ffabdc..ee444bb 100644 --- a/src/core/wallpaper_manager.vala +++ b/src/core/wallpaper_manager.vala @@ -16,6 +16,7 @@ namespace Singularity { private int _load_serial = 0; private Mutex _mutex = Mutex (); private WallpaperRotator? rotator = null; + private string _cached_effect_signature = ""; // Attribution metadata for the current wallpaper. Mirrors the // dev.sinty.desktop gschema keys background-attribution-title @@ -93,6 +94,10 @@ namespace Singularity { settings.changed["background-attribution-title"].connect(() => schedule_reload()); if (schema.has_key("background-attribution-author")) settings.changed["background-attribution-author"].connect(() => schedule_reload()); + if (schema.has_key("wallpaper-effect")) + settings.changed["wallpaper-effect"].connect(() => schedule_reload()); + if (schema.has_key("wallpaper-effect-blur-radius")) + settings.changed["wallpaper-effect-blur-radius"].connect(() => schedule_reload()); } DynamicWallpaperController.get_default(); reload(); @@ -148,6 +153,11 @@ namespace Singularity { string custom_uri = settings.get_string("background-picture-uri"); string? path = resolve_path(custom_uri); if (path != null && DynamicWallpaper.is_dynamic_path(path)) return; + string effect = schema != null && schema.has_key("wallpaper-effect") + ? settings.get_string("wallpaper-effect") : "none"; + int blur_radius = schema != null && schema.has_key("wallpaper-effect-blur-radius") + ? settings.get_int("wallpaper-effect-blur-radius") : 8; + string effect_signature = "%s|%d".printf(effect, blur_radius); if (path == null) { string[] fallbacks = {}; foreach (unowned string d in GLib.Environment.get_system_data_dirs()) { @@ -178,7 +188,7 @@ namespace Singularity { } } if (path != null) { - if (path == _cached_path) { + if (path == _cached_path && effect_signature == _cached_effect_signature) { // Wallpaper file unchanged; if only the attribution // metadata moved (URI stays the same but the keys // were updated), the overlay widget still needs to @@ -189,6 +199,7 @@ namespace Singularity { return; } _cached_path = path; + _cached_effect_signature = effect_signature; wallpaper_path = path; int serial; @@ -230,6 +241,9 @@ namespace Singularity { pb_medium = ensure_alpha(pb_medium); pb_small = ensure_alpha(pb_small); + if (pb_display != null) pb_display = WallpaperEffects.apply(pb_display, effect, blur_radius); + if (pb_medium != null) pb_medium = WallpaperEffects.apply(pb_medium, effect, int.max(1, blur_radius / 4)); + _mutex.lock(); bool stale = (serial != _load_serial); _mutex.unlock(); diff --git a/tests/dev.sinty.desktop.gschema.xml b/tests/dev.sinty.desktop.gschema.xml index 755faa1..8da7160 100644 --- a/tests/dev.sinty.desktop.gschema.xml +++ b/tests/dev.sinty.desktop.gschema.xml @@ -2,5 +2,17 @@ [] + + + + + + + 'none' + + + + 8 + diff --git a/tests/wallpaper_effects_test.vala b/tests/wallpaper_effects_test.vala new file mode 100644 index 0000000..bfce24e --- /dev/null +++ b/tests/wallpaper_effects_test.vala @@ -0,0 +1,68 @@ +using GLib; +using Gdk; +using Singularity; + +private Pixbuf solid(int width, int height, uint8 r, uint8 g, uint8 b) { + var pixbuf = new Pixbuf(Colorspace.RGB, true, 8, width, height); + unowned uint8[] pixels = pixbuf.get_pixels_with_length(); + int stride = pixbuf.get_rowstride(); + int channels = pixbuf.get_n_channels(); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int i = y * stride + x * channels; + pixels[i] = r; + pixels[i + 1] = g; + pixels[i + 2] = b; + pixels[i + 3] = 255; + } + } + return pixbuf; +} + +private void test_settings_roundtrip() { + var schema = SettingsSchemaSource.get_default().lookup("dev.sinty.desktop", true); + assert(schema != null); + var settings = new GLib.Settings.full(schema, SettingsBackend.memory_settings_backend_new(), null); + assert(settings.set_string("wallpaper-effect", "blur")); + assert(settings.set_int("wallpaper-effect-blur-radius", 16)); + assert(settings.get_string("wallpaper-effect") == "blur"); + assert(settings.get_int("wallpaper-effect-blur-radius") == 16); +} + +private void test_grayscale() { + var result = WallpaperEffects.grayscale(solid(2, 2, 230, 40, 90)); + uint8[] pixels = result.get_pixels_with_length(); + assert(pixels[0] == pixels[1] && pixels[1] == pixels[2]); + assert(pixels[0] != 230); +} + +private void test_blur() { + var pixbuf = solid(9, 3, 0, 0, 0); + unowned uint8[] pixels = pixbuf.get_pixels_with_length(); + int stride = pixbuf.get_rowstride(); + int channels = pixbuf.get_n_channels(); + for (int y = 0; y < 3; y++) { + for (int x = 5; x < 9; x++) { + int i = y * stride + x * channels; + pixels[i] = pixels[i + 1] = pixels[i + 2] = 255; + } + } + var result = WallpaperEffects.blur(pixbuf, 1); + uint8[] blurred = result.get_pixels_with_length(); + int edge = stride + 5 * channels; + assert(blurred[edge] > 0 && blurred[edge] < 255); +} + +private void test_none_keeps_source() { + var pixbuf = solid(2, 2, 10, 20, 30); + assert(WallpaperEffects.apply(pixbuf, "none", 8) == pixbuf); +} + +public int main(string[] args) { + Test.init(ref args); + Test.add_func("/wallpaper-effects/grayscale", test_grayscale); + Test.add_func("/wallpaper-effects/blur", test_blur); + Test.add_func("/wallpaper-effects/none", test_none_keeps_source); + Test.add_func("/wallpaper-effects/settings-roundtrip", test_settings_roundtrip); + return Test.run(); +}