diff --git a/meson.build b/meson.build index fa6192b..52ec38e 100644 --- a/meson.build +++ b/meson.build @@ -296,11 +296,14 @@ singularity_core_sources = files( 'src/core/preview_cache.vala', 'src/core/extreme_mode_manager.vala', 'src/core/wallpaper_manager.vala', + 'src/core/wallpaper_effects.vala', 'src/core/wallpaper_collections.vala', 'src/core/wallpaper_gallery.vala', '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,22 +531,54 @@ 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_test_env.set('GSETTINGS_BACKEND', 'memory') + +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], + dependencies: [dependency('gobject-2.0'), dependency('gio-2.0'), gee_dep, dependency('gdk-pixbuf-2.0')], ) 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_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], + 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', '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) @@ -553,6 +588,14 @@ wallpaper_sidecar_test = executable('wallpaper-sidecar-test', ) test('wallpaper-sidecar', wallpaper_sidecar_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'], dependencies: [dependency('gobject-2.0'), dependency('gio-2.0')], diff --git a/src/components/background/background.vala b/src/components/background/background.vala index 70caf21..d8cc946 100644 --- a/src/components/background/background.vala +++ b/src/components/background/background.vala @@ -42,10 +42,16 @@ namespace Singularity { // but the manager owns that decision). Bottom-left, 40% width x // 30% height, with a small margin from the very edge so the // sample doesn't include the empty space around the label. - private const double CORNER_SAMPLE_X_FRAC = 0.0; - private const double CORNER_SAMPLE_Y_FRAC = 0.65; + // Sample rect fraction per quadrant, mirrored top<->bottom and + // left<->right from the original bottom-left values (x=0, y=0.65, + // w=0.40, h=0.30) so every quadrant samples the same fraction of + // wallpaper area the label actually sits over. private const double CORNER_SAMPLE_W_FRAC = 0.40; private const double CORNER_SAMPLE_H_FRAC = 0.30; + private const double CORNER_SAMPLE_X_LEFT = 0.0; + private const double CORNER_SAMPLE_X_RIGHT = 1.0 - CORNER_SAMPLE_W_FRAC; + private const double CORNER_SAMPLE_Y_TOP = 1.0 - 0.65 - CORNER_SAMPLE_H_FRAC; + private const double CORNER_SAMPLE_Y_BOTTOM = 0.65; // Pixel margin from the screen edge to the attribution label. // Bottom-left is clear of the dock (which is bottom-anchored // and horizontally centered) at any reasonable screen width, @@ -92,9 +98,10 @@ namespace Singularity { wp_stack.add_named(picture_a, "a"); wp_stack.add_named(picture_b, "b"); - // Attribution overlay. The label is bottom-left-anchored - // (halign=START, valign=END, ATTRIBUTION_MARGIN gutter) - // and click-through so it never intercepts desktop mouse + // Attribution overlay. The label sits in a user-selected + // screen quadrant (halign/valign driven by GSettings, + // ATTRIBUTION_MARGIN gutter) and click-through so it never + // intercepts desktop mouse // events. can_target=false is GTK4's correct way to make a // widget hit-test-transparent; setting can_focus=false // prevents the label from grabbing Tab focus out of the @@ -107,10 +114,6 @@ namespace Singularity { // the contrast rule is consistent across panel + overlay. attribution_label = new Label(""); attribution_label.add_css_class("attribution-label"); - attribution_label.halign = Align.START; - attribution_label.valign = Align.END; - attribution_label.xalign = 0.0f; - attribution_label.yalign = 1.0f; attribution_label.margin_start = ATTRIBUTION_MARGIN; attribution_label.margin_end = ATTRIBUTION_MARGIN; attribution_label.margin_bottom = ATTRIBUTION_MARGIN; @@ -130,9 +133,14 @@ namespace Singularity { // the toggle in Settings immediately hides or re-shows the // overlay for the wallpaper that's currently displayed. settings = new GLib.Settings("dev.sinty.desktop"); - settings.changed["show-wallpaper-attribution"].connect(() => { - update_attribution(WallpaperManager.get_default()); + settings.changed.connect((key) => { + if (key == "show-wallpaper-attribution") { + update_attribution(WallpaperManager.get_default()); + } else if (key == "wallpaper-attribution-position") { + update_attribution_position(); + } }); + update_attribution_position(); // First load: set both pictures to avoid flash, no animation needed if (manager.display_texture != null) { picture_a.set_paintable(manager.display_texture); @@ -196,7 +204,7 @@ namespace Singularity { // Wallpaper attribution overlay (Background.vala). // - // Sits in the bottom-left corner of the live desktop background + // Sits in a user-selected screen quadrant of the live desktop background // as a single Gtk.Label over the wallpaper cross-fade. The scrim // is a semi-transparent rounded rectangle so the text reads // against both bright and dark wallpapers without an aggressive @@ -309,13 +317,19 @@ namespace Singularity { attribution_label.set_markup(markup); attribution_label.visible = true; - // Sample the corner. The pixbuf aspect matches the screen - // aspect so a fractional bottom-left corner maps 1:1 to a - // fractional bottom-left corner of the screen at the same - // proportional position. + // Sample the active quadrant. The pixbuf aspect matches the + // screen aspect so a fractional corner maps 1:1 to a fractional + // corner of the screen at the same proportional position -- + // must track wallpaper-attribution-position or a right/top + // placed label reads contrast sampled from the opposite corner. + string sample_position = settings.get_string("wallpaper-attribution-position"); + double sample_x = (sample_position == "top-right" || sample_position == "bottom-right") + ? CORNER_SAMPLE_X_RIGHT : CORNER_SAMPLE_X_LEFT; + double sample_y = (sample_position == "top-left" || sample_position == "top-right") + ? CORNER_SAMPLE_Y_TOP : CORNER_SAMPLE_Y_BOTTOM; double lum = manager.corner_luminance_frac( - CORNER_SAMPLE_X_FRAC, - CORNER_SAMPLE_Y_FRAC, + sample_x, + sample_y, CORNER_SAMPLE_W_FRAC, CORNER_SAMPLE_H_FRAC); if (lum >= 0.0) { @@ -325,6 +339,46 @@ namespace Singularity { } } + // Valid values: "top-left", "top-right", "bottom-left" (default), + // "bottom-right" -- an unrecognized value safely falls back to + // bottom-left, matching this overlay's original fixed corner. + private void update_attribution_position() { + string position = settings.get_string("wallpaper-attribution-position"); + bool at_top = position == "top-left" || position == "top-right"; + attribution_label.margin_top = ATTRIBUTION_MARGIN + + (at_top ? AppSystem.get_default().shell_panel_height : 0); + switch (position) { + case "top-left": + attribution_label.halign = Align.START; + attribution_label.valign = Align.START; + attribution_label.xalign = 0.0f; + attribution_label.yalign = 0.0f; + break; + case "top-right": + attribution_label.halign = Align.END; + attribution_label.valign = Align.START; + attribution_label.xalign = 1.0f; + attribution_label.yalign = 0.0f; + break; + case "bottom-right": + attribution_label.halign = Align.END; + attribution_label.valign = Align.END; + attribution_label.xalign = 1.0f; + attribution_label.yalign = 1.0f; + break; + default: + attribution_label.halign = Align.START; + attribution_label.valign = Align.END; + attribution_label.xalign = 0.0f; + attribution_label.yalign = 1.0f; + break; + } + // Gtk.Overlay keeps its current child allocation when only the + // alignment changes, so explicitly request a fresh allocation + // for live GSettings changes to move the label immediately. + attribution_label.queue_allocate(); + } + private void update_wallpaper(WallpaperManager manager) { if (manager.display_texture == null) return; // Write to the off-screen picture, then crossfade to it 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/components/sidebar/pages/desktop_page.vala b/src/components/sidebar/pages/desktop_page.vala index ab92ca7..f5426c5 100644 --- a/src/components/sidebar/pages/desktop_page.vala +++ b/src/components/sidebar/pages/desktop_page.vala @@ -252,6 +252,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 @@ -266,6 +276,65 @@ namespace Singularity { settings.set_boolean("show-wallpaper-attribution", attribution_row.switch_btn.active); }); + var attribution_position_options = new Gee.ArrayList(); + attribution_position_options.add(new Singularity.Core.AppSettingOption() { id = "top-left", label = _("Top Left") }); + attribution_position_options.add(new Singularity.Core.AppSettingOption() { id = "top-right", label = _("Top Right") }); + attribution_position_options.add(new Singularity.Core.AppSettingOption() { id = "bottom-left", label = _("Bottom Left") }); + attribution_position_options.add(new Singularity.Core.AppSettingOption() { id = "bottom-right", label = _("Bottom Right") }); + string attribution_position = settings.get_string("wallpaper-attribution-position"); + bool valid_position = false; + foreach (var opt in attribution_position_options) { + if (opt.id == attribution_position) { valid_position = true; break; } + } + if (!valid_position) attribution_position = "bottom-left"; + var attribution_position_row = new SelectionRow.with_options( + _("Wallpaper Info Position"), attribution_position_options, attribution_position); + settings.bind("show-wallpaper-attribution", attribution_position_row, + "sensitive", SettingsBindFlags.GET); + attribution_position_row.selected.connect((id) => { + settings.set_string("wallpaper-attribution-position", id); + }); + 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") }); + wallpaper_effect_options.add(new Singularity.Core.AppSettingOption() { id = "oil-paint", label = _("Oil Painting") }); + wallpaper_effect_options.add(new Singularity.Core.AppSettingOption() { id = "quote", label = _("Quote Overlay") }); + 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); + + var quote_row = new EntryRow(_("Quote Text")); + quote_row.text = settings.get_string("wallpaper-effect-quote-text"); + quote_row.visible = current_effect == "quote"; + quote_row.entry_changed.connect(() => { + settings.set_string("wallpaper-effect-quote-text", quote_row.text); + }); + grid_group.add_row(quote_row); + + wallpaper_effect_row.selected.connect((id) => { + settings.set_string("wallpaper-effect", id); + blur_row.visible = id == "blur"; + quote_row.visible = id == "quote"; + }); + 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..74d9ef2 --- /dev/null +++ b/src/core/wallpaper_effects.vala @@ -0,0 +1,236 @@ +using GLib; +using Gee; + +namespace Singularity { + public class WallpaperEffects : Object { + public const uint64 BYTE_BUDGET = 500 * 1024 * 1024; + private string cache_directory; + private bool size_known = false; + private uint64 known_size = 0; + + private class CacheFile : Object { + public File file; + public uint64 size; + public uint64 modified; + public CacheFile(File file, uint64 size, uint64 modified) { + this.file = file; this.size = size; this.modified = modified; + } + } + + public WallpaperEffects(string? directory = null) { + cache_directory = directory ?? Path.build_filename( + Environment.get_user_cache_dir(), "singularity", "wallpaper-effects"); + } + + public Gdk.Pixbuf apply_grayscale(Gdk.Pixbuf source, string source_cache_key) { + string key = make_key(source, source_cache_key, "grayscale:v1"); + var cached = get(key); if (cached != null) return cached; + 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; + uint8 luma = (uint8) int.min(255, (int) Math.round( + 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2])); + pixels[i] = pixels[i + 1] = pixels[i + 2] = luma; + } + put(key, output); return output; + } + + public Gdk.Pixbuf apply_blur(Gdk.Pixbuf source, string source_cache_key, int radius) { + radius = int.max(1, int.min(radius, 64)); + string key = make_key(source, source_cache_key, "blur:v1:r=%d".printf(radius)); + var cached = get(key); if (cached != null) return cached; + var output = source.copy(); + for (int pass = 0; pass < 3; pass++) output = box_blur(output, radius); + put(key, output); return output; + } + + public Gdk.Pixbuf apply_oil_paint(Gdk.Pixbuf source, string source_cache_key, + int radius, int intensity_levels = 24) { + radius = int.max(1, int.min(radius, 12)); + intensity_levels = int.max(2, int.min(intensity_levels, 64)); + string key = make_key(source, source_cache_key, + "oil:v1:r=%d:l=%d".printf(radius, intensity_levels)); + var cached = get(key); if (cached != null) return cached; + var output = source.copy(); + unowned uint8[] src = source.get_pixels_with_length(); + unowned uint8[] dst = output.get_pixels_with_length(); + int ch = source.get_n_channels(), ss = source.get_rowstride(), ds = output.get_rowstride(); + int w = source.get_width(), h = source.get_height(); + int[] count = new int[intensity_levels]; + int[] red = new int[intensity_levels], green = new int[intensity_levels], blue = new int[intensity_levels]; + for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) { + for (int b = 0; b < intensity_levels; b++) count[b] = red[b] = green[b] = blue[b] = 0; + for (int yy = int.max(0, y - radius); yy <= int.min(h - 1, y + radius); yy++) + for (int xx = int.max(0, x - radius); xx <= int.min(w - 1, x + radius); xx++) { + int si = yy * ss + xx * ch; + int luma = (int) (0.2126 * src[si] + 0.7152 * src[si + 1] + 0.0722 * src[si + 2]); + int bin = int.min(intensity_levels - 1, luma * intensity_levels / 256); + count[bin]++; red[bin] += src[si]; green[bin] += src[si + 1]; blue[bin] += src[si + 2]; + } + int best = 0; + for (int b = 1; b < intensity_levels; b++) if (count[b] > count[best]) best = b; + int di = y * ds + x * ch; + dst[di] = (uint8) (red[best] / count[best]); + dst[di + 1] = (uint8) (green[best] / count[best]); + dst[di + 2] = (uint8) (blue[best] / count[best]); + } + put(key, output); return output; + } + + public Gdk.Pixbuf apply_quote_overlay(Gdk.Pixbuf source, string source_cache_key, + string text, string font_description = "Sans Bold 42") { + string key = make_key(source, source_cache_key, + "quote:v1:text=%s:font=%s".printf(text, font_description)); + var cached = get(key); if (cached != null) return cached; + if (text.strip() == "") return source.copy(); + int w = source.get_width(), h = source.get_height(); + var surface = new Cairo.ImageSurface(Cairo.Format.ARGB32, w, h); + var cr = new Cairo.Context(surface); + Gdk.cairo_set_source_pixbuf(cr, source, 0, 0); cr.paint(); + var layout = Pango.cairo_create_layout(cr); + layout.set_text(text, -1); + layout.set_font_description(Pango.FontDescription.from_string(font_description)); + layout.set_width((int) (w * 0.82 * Pango.SCALE)); + layout.set_alignment(Pango.Alignment.CENTER); + layout.set_wrap(Pango.WrapMode.WORD_CHAR); + int tw, th; layout.get_pixel_size(out tw, out th); + double x = (w - tw) / 2.0, y = h - th - int.max(24, h / 14); + double pad = int.max(12, h / 60); + rounded_rect(cr, x - pad, y - pad, tw + pad * 2, th + pad * 2, pad); + cr.set_source_rgba(0, 0, 0, 0.58); cr.fill(); + cr.move_to(x + 2, y + 3); cr.set_source_rgba(0, 0, 0, 0.8); + Pango.cairo_show_layout(cr, layout); + cr.move_to(x, y); cr.set_source_rgba(1, 1, 1, 1); + Pango.cairo_show_layout(cr, layout); + surface.flush(); + var output = pixbuf_from_surface(surface, source.get_has_alpha()); + put(key, output); return output; + } + + private static Gdk.Pixbuf box_blur(Gdk.Pixbuf source, int radius) { + int w = source.get_width(), h = source.get_height(), ch = 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 ss = source.get_rowstride(), ms = horizontal.get_rowstride(), ds = output.get_rowstride(); + for (int y = 0; y < h; y++) for (int c = 0; c < ch; c++) { + int sum = 0, n = int.min(w - 1, radius) + 1; + for (int xx = 0; xx < n; xx++) sum += src[y*ss+xx*ch+c]; + for (int x = 0; x < w; x++) { + mid[y*ms+x*ch+c] = (uint8) (sum/n); + int remove = x-radius, add = x+radius+1; + if (remove >= 0) { sum -= src[y*ss+remove*ch+c]; n--; } + if (add < w) { sum += src[y*ss+add*ch+c]; n++; } + } + } + for (int x = 0; x < w; x++) for (int c = 0; c < ch; c++) { + int sum = 0, n = int.min(h - 1, radius) + 1; + for (int yy = 0; yy < n; yy++) sum += mid[yy*ms+x*ch+c]; + for (int y = 0; y < h; y++) { + dst[y*ds+x*ch+c] = (uint8) (sum/n); + int remove = y-radius, add = y+radius+1; + if (remove >= 0) { sum -= mid[remove*ms+x*ch+c]; n--; } + if (add < h) { sum += mid[add*ms+x*ch+c]; n++; } + } + } + return output; + } + + private static void rounded_rect(Cairo.Context cr, double x, double y, double w, double h, double r) { + cr.new_sub_path(); cr.arc(x+w-r, y+r, r, -Math.PI/2, 0); cr.arc(x+w-r, y+h-r, r, 0, Math.PI/2); + cr.arc(x+r, y+h-r, r, Math.PI/2, Math.PI); cr.arc(x+r, y+r, r, Math.PI, 3*Math.PI/2); cr.close_path(); + } + + private static Gdk.Pixbuf pixbuf_from_surface(Cairo.ImageSurface surface, bool alpha) { + int w = surface.get_width(), h = surface.get_height(), stride = surface.get_stride(); + unowned uchar[] data = surface.get_data(); + var output = new Gdk.Pixbuf(Gdk.Colorspace.RGB, alpha, 8, w, h); + unowned uint8[] dst = output.get_pixels_with_length(); int ds = output.get_rowstride(), ch = output.get_n_channels(); + for (int y=0; y= old ? known_size-old+fresh : fresh; + if (known_size > BYTE_BUDGET) evict(); + } catch (Error e) { + FileUtils.unlink(temp); + message("Could not write wallpaper effect cache %s: %s", path, e.message); + } + } + private static int read_u32(uint8[] data, int offset) { + return (int) ((uint32)data[offset] | ((uint32)data[offset+1]<<8) | + ((uint32)data[offset+2]<<16) | ((uint32)data[offset+3]<<24)); + } + private static void write_u32(uint8[] data, int offset, int value) { + uint32 v=(uint32)value; data[offset]=(uint8)v; data[offset+1]=(uint8)(v>>8); + data[offset+2]=(uint8)(v>>16); data[offset+3]=(uint8)(v>>24); + } + private static uint64 file_size(string path) { + try { return File.new_for_path(path).query_info(FileAttribute.STANDARD_SIZE, FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null).get_size(); } + catch (Error e) { return 0; } + } + private void refresh_size() { + known_size=0; + try { var d=File.new_for_path(cache_directory); var en=d.enumerate_children("standard::type,standard::size", FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); FileInfo i; + while ((i=en.next_file(null)) != null) if (i.get_file_type()==FileType.REGULAR) known_size += i.get_size(); + } catch (Error e) {} size_known=true; + } + private void evict() { + var entries=new ArrayList(); uint64 total=0; + try { var d=File.new_for_path(cache_directory); var en=d.enumerate_children("standard::name,standard::type,standard::size,time::modified", FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); FileInfo i; + while ((i=en.next_file(null)) != null) if (i.get_file_type()==FileType.REGULAR) { var e=new CacheFile(d.get_child(i.get_name()),i.get_size(),i.get_attribute_uint64(FileAttribute.TIME_MODIFIED)); entries.add(e); total+=e.size; } + entries.sort((a,b)=>a.modifiedb.modified ? 1 : 0)); + foreach (var e in entries) { if (total<=BYTE_BUDGET) break; try { e.file.delete(null); total-=e.size; } catch (Error err) {} } + } catch (Error e) {} known_size=total; + } + } +} 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_gallery.vala b/src/core/wallpaper_gallery.vala index 0d5d8bc..a50b09b 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; } } @@ -95,7 +99,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_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..7287484 100644 --- a/src/core/wallpaper_manager.vala +++ b/src/core/wallpaper_manager.vala @@ -16,6 +16,8 @@ namespace Singularity { private int _load_serial = 0; private Mutex _mutex = Mutex (); private WallpaperRotator? rotator = null; + private WallpaperEffects effects = new WallpaperEffects(); + private string _cached_effect_signature = ""; // Attribution metadata for the current wallpaper. Mirrors the // dev.sinty.desktop gschema keys background-attribution-title @@ -31,6 +33,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) { @@ -92,6 +96,12 @@ 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()); + if (schema.has_key("wallpaper-effect-quote-text")) + settings.changed["wallpaper-effect-quote-text"].connect(() => schedule_reload()); } reload(); } @@ -99,6 +109,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); @@ -110,6 +129,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 @@ -145,6 +169,13 @@ namespace Singularity { string custom_uri = settings.get_string("background-picture-uri"); string? path = resolve_path(custom_uri); + 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 quote_text = schema != null && schema.has_key("wallpaper-effect-quote-text") + ? settings.get_string("wallpaper-effect-quote-text") : ""; + string effect_signature = "%s|%d|%s".printf(effect, blur_radius, quote_text); if (path == null) { string[] fallbacks = {}; foreach (unowned string d in GLib.Environment.get_system_data_dirs()) { @@ -175,7 +206,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 @@ -186,6 +217,7 @@ namespace Singularity { return; } _cached_path = path; + _cached_effect_signature = effect_signature; wallpaper_path = path; int serial; @@ -227,6 +259,12 @@ namespace Singularity { pb_medium = ensure_alpha(pb_medium); pb_small = ensure_alpha(pb_small); + string source_key = source_cache_key(load_path); + if (pb_display != null) pb_display = apply_effect(pb_display, source_key, + effect, blur_radius, quote_text); + if (pb_medium != null) pb_medium = apply_effect(pb_medium, source_key, + effect, int.max(1, blur_radius / 4), quote_text, "Sans Bold 18"); + _mutex.lock(); bool stale = (serial != _load_serial); _mutex.unlock(); @@ -243,6 +281,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; }); }); @@ -262,6 +301,27 @@ namespace Singularity { return null; } + private static string source_cache_key(string path) { + try { + var info = File.new_for_path(path).query_info("time::modified,time::modified-usec,standard::size", + FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); + return "%s|%llu|%u|%llu".printf(path, + info.get_attribute_uint64(FileAttribute.TIME_MODIFIED), + info.get_attribute_uint32(FileAttribute.TIME_MODIFIED_USEC), info.get_size()); + } catch (Error e) { return path; } + } + + private Gdk.Pixbuf apply_effect(Gdk.Pixbuf source, string key, string effect, + int radius, string quote, string font = "Sans Bold 42") { + switch (effect) { + case "grayscale": return effects.apply_grayscale(source, key); + case "blur": return effects.apply_blur(source, key, radius); + case "oil-paint": return effects.apply_oil_paint(source, key, 4); + case "quote": return effects.apply_quote_overlay(source, key, quote, font); + default: return source; + } + } + public bool top_band_rect(double frac, out int x, out int y, out int w, out int h) { x = 0; y = 0; w = 0; h = 0; var pb = _display_pixbuf; diff --git a/src/core/wallpaper_rotator.vala b/src/core/wallpaper_rotator.vala index 6b010f6..01704c4 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 test_min_width = 1280; + private int test_min_height = 720; + private bool test_match_aspect = false; + private bool test_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) { + test_min_width = min_width; + test_min_height = min_height; + test_match_aspect = match_aspect; + test_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", test_min_width); + int min_height = setting_int("wallpaper-rotation-min-height", test_min_height); + bool match_aspect = setting_bool("wallpaper-rotation-match-aspect", test_match_aspect); + bool favor_favorites = setting_bool("wallpaper-rotation-favor-favorites", test_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 new file mode 100644 index 0000000..8f689d6 --- /dev/null +++ b/tests/dev.sinty.desktop.gschema.xml @@ -0,0 +1,28 @@ + + + + [] + [] + '' + [] + + + + + + + + + 'none' + + + + 8 + + '' + 1280 + 720 + false + true + + diff --git a/tests/wallpaper_effects_test.vala b/tests/wallpaper_effects_test.vala new file mode 100644 index 0000000..ee5ed53 --- /dev/null +++ b/tests/wallpaper_effects_test.vala @@ -0,0 +1,76 @@ +using GLib; +using Gdk; +using Singularity; + +private Pixbuf solid(int width, int height, uint8 r, uint8 g, uint8 b) { + var pb = new Pixbuf(Colorspace.RGB, true, 8, width, height); + unowned uint8[] p = pb.get_pixels_with_length(); int s = pb.get_rowstride(), c = pb.get_n_channels(); + for (int y=0; y0 && q[right]<255); +} + +private void test_cache_hit() { + string root; var fx=fixture(out root); var pb=solid(2,2,255,0,0); + var first=fx.apply_grayscale(pb,"stable"); uint8 expected=first.get_pixels_with_length()[0]; + unowned uint8[] source=pb.get_pixels_with_length(); source[0]=source[1]=source[2]=255; + var second=fx.apply_grayscale(pb,"stable"); uint8[] cached=second.get_pixels_with_length(); + assert(cached[0]==expected && cached[0] < 255); +} + +private void test_oil_paint() { + string root; var fx=fixture(out root); var pb=solid(7,5,20,90,180); + var result=fx.apply_oil_paint(pb,"oil",2); assert(result.width==7 && result.height==5); +} + +private void test_quote_overlay() { + string root; var fx=fixture(out root); var pb=solid(320,180,30,60,90); + var result=fx.apply_quote_overlay(pb,"quote","Hello world","Sans Bold 24"); + uint8[] p=result.get_pixels_with_length(); int s=result.rowstride, c=result.n_channels; bool changed=false; + for(int y=90;y<180 && !changed;y++) for(int x=0;x<320;x++) { int i=y*s+x*c; if(p[i]!=30 || p[i+1]!=60 || p[i+2]!=90) { changed=true; break; } } + assert(changed); assert(result.width==320 && result.height==180); +} + +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/cache-hit", test_cache_hit); + Test.add_func("/wallpaper-effects/oil-paint", test_oil_paint); + Test.add_func("/wallpaper-effects/quote-overlay", test_quote_overlay); + Test.add_func("/wallpaper-effects/settings-roundtrip", test_settings_roundtrip); + return Test.run(); +} 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(); +} 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);