diff --git a/meson.build b/meson.build index fa6192b..0980eec 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,6 +531,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], @@ -553,6 +583,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..cd43cf3 100644 --- a/src/components/sidebar/pages/desktop_page.vala +++ b/src/components/sidebar/pages/desktop_page.vala @@ -266,6 +266,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_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..35ec849 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(); } @@ -145,6 +155,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 +192,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 +203,7 @@ namespace Singularity { return; } _cached_path = path; + _cached_effect_signature = effect_signature; wallpaper_path = path; int serial; @@ -227,6 +245,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 +267,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 +287,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/tests/dev.sinty.desktop.gschema.xml b/tests/dev.sinty.desktop.gschema.xml new file mode 100644 index 0000000..c5df09b --- /dev/null +++ b/tests/dev.sinty.desktop.gschema.xml @@ -0,0 +1,24 @@ + + + + [] + [] + '' + [] + + + + + + + + + 'none' + + + + 8 + + '' + + 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(); +}