From 72d5347ba0115e5277faa93be5f1f705e5d9fa79 Mon Sep 17 00:00:00 2001 From: Sheikh Muneeb Ahmed Date: Sun, 8 Feb 2026 12:48:05 +0500 Subject: [PATCH 1/9] image tile size increased --- libdiodon/clipboard-menu-item.vala | 33 ++++++++++++++++++--- libdiodon/image-clipboard-item.vala | 45 ++++++++++++++++++++++------- 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/libdiodon/clipboard-menu-item.vala b/libdiodon/clipboard-menu-item.vala index 0552137..b790b68 100644 --- a/libdiodon/clipboard-menu-item.vala +++ b/libdiodon/clipboard-menu-item.vala @@ -37,13 +37,38 @@ namespace Diodon public ClipboardMenuItem(IClipboardItem item) { _checksum = item.get_checksum(); - set_label(item.get_label()); - // check if image needs to be shown Gtk.Image? image = item.get_image(); if(image != null) { - set_image(image); - set_always_show_image(true); + // For image items: display a large centered thumbnail + // spanning the full tile, instead of a small icon on the left + + // Remove any default child widget from the menu item + var existing_child = get_child(); + if (existing_child != null) { + remove(existing_child); + } + + // Vertical box: thumbnail on top, dimension label below + var box = new Gtk.Box(Gtk.Orientation.VERTICAL, 2); + box.set_halign(Gtk.Align.CENTER); + box.set_valign(Gtk.Align.CENTER); + box.margin_top = 4; + box.margin_bottom = 4; + box.margin_start = 8; + box.margin_end = 8; + + // Center the thumbnail within the full tile width + image.set_halign(Gtk.Align.CENTER); + image.set_valign(Gtk.Align.CENTER); + box.pack_start(image, true, true, 0); + + add(box); + + // Show dimensions on hover via tooltip + set_tooltip_text(item.get_label()); + } else { + set_label(item.get_label()); } } diff --git a/libdiodon/image-clipboard-item.vala b/libdiodon/image-clipboard-item.vala index 0faccbd..b4c0ee1 100644 --- a/libdiodon/image-clipboard-item.vala +++ b/libdiodon/image-clipboard-item.vala @@ -220,24 +220,47 @@ namespace Diodon } /** - * Create a menu icon size scaled pix buf + * Create a thumbnail-sized scaled pixbuf that fits within the + * preview area while maintaining aspect ratio (contain fit). + * The thumbnail is sized at 3x the normal menu item height + * for clearly visible image previews. * - * @param pixbuf scaled pixbuf + * @param pixbuf source pixbuf to scale + * @return scaled pixbuf preserving aspect ratio */ private static Gdk.Pixbuf create_scaled_pixbuf(Gdk.Pixbuf pixbuf) { - // get menu icon size + // Use menu icon size as baseline reference Gtk.IconSize size = Gtk.IconSize.MENU; - int width, height; - if(!Gtk.icon_size_lookup(size, out width, out height)) { - // set default when icon size lookup fails - width = 16; - height = 16; + int icon_width, icon_height; + if(!Gtk.icon_size_lookup(size, out icon_width, out icon_height)) { + icon_width = 16; + icon_height = 16; } - // scale pixbuf to menu icon size - Gdk.Pixbuf scaled = pixbuf.scale_simple(width, height, Gdk.InterpType.BILINEAR); - return scaled; + // 3x the normal menu item height (~6x icon size, since a + // menu item is roughly 2x the icon height with padding) + int max_height = icon_height * 6; + int max_width = icon_width * 12; + + int src_width = pixbuf.width; + int src_height = pixbuf.height; + + // Object-fit contain: scale to fill as much of the bounding + // box as possible while preserving the original aspect ratio + double scale_x = (double) max_width / src_width; + double scale_y = (double) max_height / src_height; + double scale = double.min(scale_x, scale_y); + + // Never upscale beyond original resolution + if (scale > 1.0) { + scale = 1.0; + } + + int dest_width = int.max((int)(src_width * scale), 1); + int dest_height = int.max((int)(src_height * scale), 1); + + return pixbuf.scale_simple(dest_width, dest_height, Gdk.InterpType.BILINEAR); } /** From 5c30b95f647a1dce1a897cb7add04632411f482e Mon Sep 17 00:00:00 2001 From: Sheikh Muneeb Ahmed Date: Sun, 8 Feb 2026 12:53:00 +0500 Subject: [PATCH 2/9] tet tile size increased to see more text for better understanding --- libdiodon/clipboard-menu-item.vala | 15 ++++++++++++++- libdiodon/file-clipboard-item.vala | 6 +++--- libdiodon/text-clipboard-item.vala | 6 +++--- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/libdiodon/clipboard-menu-item.vala b/libdiodon/clipboard-menu-item.vala index b790b68..fe21dab 100644 --- a/libdiodon/clipboard-menu-item.vala +++ b/libdiodon/clipboard-menu-item.vala @@ -68,7 +68,20 @@ namespace Diodon // Show dimensions on hover via tooltip set_tooltip_text(item.get_label()); } else { - set_label(item.get_label()); + // Remove default child to replace with a wrapping label + var existing_child = get_child(); + if (existing_child != null) { + remove(existing_child); + } + + var label = new Gtk.Label(item.get_label()); + label.set_xalign(0); + label.set_line_wrap(true); + label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR); + label.set_max_width_chars(50); + label.set_lines(4); + label.set_ellipsize(Pango.EllipsizeMode.END); + add(label); } } diff --git a/libdiodon/file-clipboard-item.vala b/libdiodon/file-clipboard-item.vala index 53f41c8..de705bd 100644 --- a/libdiodon/file-clipboard-item.vala +++ b/libdiodon/file-clipboard-item.vala @@ -102,14 +102,14 @@ namespace Diodon { string home = Environment.get_home_dir(); - // label should not be longer than 50 letters + // label should not be longer than 100 letters string label = _paths.replace("\n", " "); // replacing home dir with common known tilde label = label.replace(home, "~"); - if (label.char_count() > 50) { - long index_char = label.index_of_nth_char(50); + if (label.char_count() > 100) { + long index_char = label.index_of_nth_char(100); label = label.substring(0, index_char) + "..."; } diff --git a/libdiodon/text-clipboard-item.vala b/libdiodon/text-clipboard-item.vala index 800df2b..48bf481 100644 --- a/libdiodon/text-clipboard-item.vala +++ b/libdiodon/text-clipboard-item.vala @@ -83,10 +83,10 @@ namespace Diodon */ public string get_label() { - // label should not be longer than 50 letters + // label should not be longer than 100 letters string label = _text.replace("\n", " "); - if (label.char_count() > 50) { - long index_char = label.index_of_nth_char(50); + if (label.char_count() > 100) { + long index_char = label.index_of_nth_char(100); label = label.substring(0, index_char) + "..."; } From 6bce62f1c41b18e7f996e23eaf3689440e0d2630 Mon Sep 17 00:00:00 2001 From: Sheikh Muneeb Ahmed Date: Sun, 8 Feb 2026 12:58:05 +0500 Subject: [PATCH 3/9] further increase image size t 360 by 480 for more better readability --- libdiodon/image-clipboard-item.vala | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/libdiodon/image-clipboard-item.vala b/libdiodon/image-clipboard-item.vala index b4c0ee1..d78da95 100644 --- a/libdiodon/image-clipboard-item.vala +++ b/libdiodon/image-clipboard-item.vala @@ -230,18 +230,9 @@ namespace Diodon */ private static Gdk.Pixbuf create_scaled_pixbuf(Gdk.Pixbuf pixbuf) { - // Use menu icon size as baseline reference - Gtk.IconSize size = Gtk.IconSize.MENU; - int icon_width, icon_height; - if(!Gtk.icon_size_lookup(size, out icon_width, out icon_height)) { - icon_width = 16; - icon_height = 16; - } - - // 3x the normal menu item height (~6x icon size, since a - // menu item is roughly 2x the icon height with padding) - int max_height = icon_height * 6; - int max_width = icon_width * 12; + // Large preview area for crisp, clear thumbnails + int max_height = 360; + int max_width = 480; int src_width = pixbuf.width; int src_height = pixbuf.height; @@ -260,7 +251,8 @@ namespace Diodon int dest_width = int.max((int)(src_width * scale), 1); int dest_height = int.max((int)(src_height * scale), 1); - return pixbuf.scale_simple(dest_width, dest_height, Gdk.InterpType.BILINEAR); + // HYPER provides the highest quality downscaling + return pixbuf.scale_simple(dest_width, dest_height, Gdk.InterpType.HYPER); } /** From 81dfed08c21c686e79dfef2b6605e0b4b679780c Mon Sep 17 00:00:00 2001 From: Sheikh Muneeb Ahmed Date: Sun, 8 Feb 2026 15:11:40 +0500 Subject: [PATCH 4/9] Enhance image handling and caching for improved performance - Changed ClipboardMenuItem to inherit from Gtk.MenuItem for better functionality. - Added focus management and explicit size requests for images in the clipboard menu. - Implemented caching mechanisms in ImageClipboardItem to speed up retrieval of image data. - Optimized clipboard data serving by using cached PNG bytes to reduce processing time. --- libdiodon/clipboard-menu-item.vala | 15 +- libdiodon/clipboard-menu.vala | 7 +- libdiodon/image-clipboard-item.vala | 169 ++++++++++++++++++++- libdiodon/zeitgeist-clipboard-storage.vala | 10 ++ 4 files changed, 190 insertions(+), 11 deletions(-) diff --git a/libdiodon/clipboard-menu-item.vala b/libdiodon/clipboard-menu-item.vala index fe21dab..098a3bb 100644 --- a/libdiodon/clipboard-menu-item.vala +++ b/libdiodon/clipboard-menu-item.vala @@ -25,7 +25,7 @@ namespace Diodon * A gtk menu item holding a checksum of a clipboard item. It only keeps * the checksum as it would waste memory to keep the hole item available. */ - class ClipboardMenuItem : Gtk.ImageMenuItem + class ClipboardMenuItem : Gtk.MenuItem { private string _checksum; @@ -53,6 +53,7 @@ namespace Diodon var box = new Gtk.Box(Gtk.Orientation.VERTICAL, 2); box.set_halign(Gtk.Align.CENTER); box.set_valign(Gtk.Align.CENTER); + box.set_can_focus(false); box.margin_top = 4; box.margin_bottom = 4; box.margin_start = 8; @@ -61,11 +62,19 @@ namespace Diodon // Center the thumbnail within the full tile width image.set_halign(Gtk.Align.CENTER); image.set_valign(Gtk.Align.CENTER); - box.pack_start(image, true, true, 0); + image.set_can_focus(false); + + // Request explicit size so the menu allocates enough space + Gdk.Pixbuf? pix = image.get_pixbuf(); + if (pix != null) { + image.set_size_request(pix.width, pix.height); + } + + box.pack_start(image, false, false, 0); add(box); - // Show dimensions on hover via tooltip + // Show image dimensions on hover set_tooltip_text(item.get_label()); } else { // Remove default child to replace with a wrapping label diff --git a/libdiodon/clipboard-menu.vala b/libdiodon/clipboard-menu.vala index 4c6bd12..81e8fa4 100644 --- a/libdiodon/clipboard-menu.vala +++ b/libdiodon/clipboard-menu.vala @@ -110,7 +110,12 @@ namespace Diodon // otherwise popup does not open Timeout.add( 250, - () => { popup(null, null, null, 0, Gtk.get_current_event_time()); return false; } + () => { + popup(null, null, null, 0, Gtk.get_current_event_time()); + // Focus the first item by default for keyboard navigation + select_first(true); + return false; + } ); } diff --git a/libdiodon/image-clipboard-item.vala b/libdiodon/image-clipboard-item.vala index d78da95..92a1c51 100644 --- a/libdiodon/image-clipboard-item.vala +++ b/libdiodon/image-clipboard-item.vala @@ -33,6 +33,61 @@ namespace Diodon private string? _origin; private DateTime _date_copied; + // Cached PNG payload bytes — avoids re-encoding on every get_payload() call + private uint8[]? _cached_png = null; + + // Static in-memory cache: checksum → {pixbuf, png_bytes} + // Keeps full-res data alive so paste from history is a dict + // lookup instead of Zeitgeist query + PNG decode + PNG re-encode. + private static GLib.HashTable? _pixbuf_cache = null; + private static GLib.HashTable? _png_cache = null; + + private static unowned GLib.HashTable get_pixbuf_cache() { + if (_pixbuf_cache == null) { + _pixbuf_cache = new GLib.HashTable(str_hash, str_equal); + } + return _pixbuf_cache; + } + + private static unowned GLib.HashTable get_png_cache() { + if (_png_cache == null) { + _png_cache = new GLib.HashTable(str_hash, str_equal); + } + return _png_cache; + } + + /** + * Try to build an ImageClipboardItem from the in-memory cache. + * Returns null if the checksum is not cached. + * Bypasses extract_pixbuf_info() entirely — no SHA1 rehash of + * the full pixel data. Both pixbuf AND PNG bytes are restored + * from cache so get_payload() never needs to re-encode either. + */ + public static ImageClipboardItem? from_cache(string checksum, string? origin, DateTime date_copied) { + unowned Gdk.Pixbuf? cached_pix = get_pixbuf_cache().lookup(checksum); + if (cached_pix == null) { + return null; + } + // Build item directly — do NOT call with_image/extract_pixbuf_info + // which would SHA1-hash 33MB of pixel data for nothing. + var item = new ImageClipboardItem._from_cache_internal(); + item._clipboard_type = ClipboardType.NONE; + item._origin = origin; + item._date_copied = date_copied; + item._checksum = checksum; + item._label = "[%dx%d]".printf(cached_pix.width, cached_pix.height); + item._pixbuf = cached_pix; + + // PNG bytes live in the static cache — get_payload() reads + // them by checksum. No copying needed here. + return item; + } + + // Private no-op constructor for from_cache() to avoid + // the expensive extract_pixbuf_info() path. + private ImageClipboardItem._from_cache_internal() { + } + /** * Create image clipboard item by a pixbuf. * @@ -61,11 +116,20 @@ namespace Diodon _origin = origin; _date_copied = date_copied; + // Cache the raw PNG bytes on the instance AND in the + // static cache so get_payload() never re-encodes. + _cached_png = new uint8[payload.data.length]; + GLib.Memory.copy(_cached_png, payload.data, payload.data.length); + Gdk.PixbufLoader loader = new Gdk.PixbufLoader(); loader.write(payload.data); loader.close(); Gdk.Pixbuf pixbuf = loader.get_pixbuf(); extract_pixbuf_info(pixbuf); + + // Also put PNG bytes in the static cache keyed by checksum + // (checksum is set by extract_pixbuf_info above) + get_png_cache().replace(_checksum, new GLib.Bytes(payload.data)); } /** @@ -155,8 +219,31 @@ namespace Diodon */ public ByteArray? get_payload() throws GLib.Error { + // 1. Check instance cache + if (_cached_png != null) { + ByteArray ba = new ByteArray.sized((uint) _cached_png.length); + ba.append(_cached_png); + return ba; + } + + // 2. Check static cache (from_cache items land here) + GLib.Bytes? cached = get_png_cache().lookup(_checksum); + if (cached != null) { + unowned uint8[] data = cached.get_data(); + ByteArray ba = new ByteArray.sized((uint) data.length); + ba.append(data); + return ba; + } + + // 3. Last resort: encode (first time only, e.g. fresh copy) uint8[] buffer; _pixbuf.save_to_buffer(out buffer, "png"); + + // Cache for future + _cached_png = new uint8[buffer.length]; + GLib.Memory.copy(_cached_png, buffer, buffer.length); + get_png_cache().replace(_checksum, new GLib.Bytes(buffer)); + return new ByteArray.take(buffer); } @@ -173,8 +260,72 @@ namespace Diodon */ public void to_clipboard(Gtk.Clipboard clipboard) { - clipboard.set_image(_pixbuf); - clipboard.store(); + // Use set_with_owner so WE control what data gets served + // to requesting apps. When an app asks for image/png, + // we serve our pre-encoded cached bytes directly — + // no re-encoding of 33MB of raw pixels on the main thread. + Gtk.TargetList target_list = new Gtk.TargetList(null); + target_list.add_image_targets(0, true); + Gtk.TargetEntry[] entries = Gtk.target_table_new_from_list(target_list); + + clipboard.set_with_owner( + entries, + clipboard_get_func, + clipboard_clear_func, + this + ); + } + + /** + * Called by GTK when a target app requests clipboard data. + * Serves cached PNG bytes directly for image/png requests, + * falls back to pixbuf encoding only for rare other formats. + */ + private static void clipboard_get_func( + Gtk.Clipboard clipboard, + Gtk.SelectionData selection_data, + uint info, + void* user_data_or_owner) + { + ImageClipboardItem self = (ImageClipboardItem) user_data_or_owner; + + // Try serving cached PNG for image/png requests (the common case) + string target_name = selection_data.get_target().name(); + if (target_name == "image/png") { + // Check static cache first, then instance + GLib.Bytes? cached = get_png_cache().lookup(self._checksum); + if (cached != null) { + unowned uint8[] data = cached.get_data(); + selection_data.set( + selection_data.get_target(), + 8, + data + ); + return; + } + if (self._cached_png != null) { + selection_data.set( + selection_data.get_target(), + 8, + self._cached_png + ); + return; + } + } + + // Fallback for other formats (image/bmp, image/jpeg, etc.) + // Let GDK encode from pixbuf — rare path + selection_data.set_pixbuf(self._pixbuf); + } + + /** + * Called by GTK when clipboard ownership is lost. + */ + private static void clipboard_clear_func( + Gtk.Clipboard clipboard, + void* user_data_or_owner) + { + // Nothing to clean up — data lives in the static cache } /** @@ -217,6 +368,10 @@ namespace Diodon // label in format [{width}x{height}] _label ="[%dx%d]".printf(pixbuf.width, pixbuf.height); _pixbuf = pixbuf; + + // Cache the pixbuf so future pastes from history are instant + // (dict lookup instead of Zeitgeist query + PNG decode) + get_pixbuf_cache().replace(_checksum, pixbuf); } /** @@ -230,9 +385,10 @@ namespace Diodon */ private static Gdk.Pixbuf create_scaled_pixbuf(Gdk.Pixbuf pixbuf) { - // Large preview area for crisp, clear thumbnails - int max_height = 360; - int max_width = 480; + // Thumbnail size that fits comfortably in a GTK menu + // without clipping, even with multiple items visible + int max_height = 150; + int max_width = 200; int src_width = pixbuf.width; int src_height = pixbuf.height; @@ -251,8 +407,7 @@ namespace Diodon int dest_width = int.max((int)(src_width * scale), 1); int dest_height = int.max((int)(src_height * scale), 1); - // HYPER provides the highest quality downscaling - return pixbuf.scale_simple(dest_width, dest_height, Gdk.InterpType.HYPER); + return pixbuf.scale_simple(dest_width, dest_height, Gdk.InterpType.BILINEAR); } /** diff --git a/libdiodon/zeitgeist-clipboard-storage.vala b/libdiodon/zeitgeist-clipboard-storage.vala index 485eeac..3cd389d 100644 --- a/libdiodon/zeitgeist-clipboard-storage.vala +++ b/libdiodon/zeitgeist-clipboard-storage.vala @@ -191,6 +191,16 @@ namespace Diodon { debug("Get item with given checksum %s", checksum); + // Fast path: if the pixbuf is already cached in memory, + // skip the entire Zeitgeist query + PNG decode. + // This makes paste from history nearly instant. + ImageClipboardItem? cached_item = ImageClipboardItem.from_cache( + checksum, null, new DateTime.now_utc()); + if (cached_item != null) { + debug("Cache hit for checksum %s, skipping Zeitgeist query", checksum); + return cached_item; + } + GenericArray templates = new GenericArray(); TimeRange time_range = new TimeRange.anytime(); Event template = new Event.full( From 08b1fcda567903a1c09ede63303e16fa8f748904 Mon Sep 17 00:00:00 2001 From: Sheikh Muneeb Ahmed Date: Sun, 8 Feb 2026 17:39:29 +0500 Subject: [PATCH 5/9] Refactor image handling and caching in Diodon - Added 'image-cache.vala' to implement a global LRU cache for PNG data, replacing the previous two-tier caching system. - Updated 'zeitgeist-clipboard-storage.vala' to utilize the new caching mechanism, improving performance during image pasting and menu display. - Introduced lightweight loading of thumbnails for menu display, significantly reducing memory usage and improving responsiveness. - Enhanced the overall architecture to eliminate feedback loops and reduce CPU usage during clipboard operations. - Documented the new implementation in 'IMPLEMENTATION.md', detailing the image lifecycle, caching strategy, and performance improvements. --- IMPLEMENTATION.md | 344 +++++++++++++ libdiodon/clipboard-manager.vala | 18 + libdiodon/clipboard-menu-item.vala | 16 + libdiodon/clipboard-menu.vala | 19 + libdiodon/image-cache.vala | 323 +++++++++++++ libdiodon/image-clipboard-item.vala | 530 ++++++++++++++------- libdiodon/meson.build | 1 + libdiodon/zeitgeist-clipboard-storage.vala | 39 +- 8 files changed, 1117 insertions(+), 173 deletions(-) create mode 100644 IMPLEMENTATION.md create mode 100644 libdiodon/image-cache.vala diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..ce77c74 --- /dev/null +++ b/IMPLEMENTATION.md @@ -0,0 +1,344 @@ +# Diodon Image Clipboard — Implementation Guide + +## Overview + +This document explains the **full lifecycle** of how images flow through Diodon's clipboard system, the architecture that prevents UI freezes and memory bloat, and the refactored caching and loop-detection mechanisms. + +--- + +## The Problem We Solved + +A single 4K image (3840×2160, RGBA) is **33 MB of raw pixel data**. The original Diodon code would: + +1. Hold the full 33 MB pixbuf in memory per image in the clipboard history +2. Call `clipboard.set_image()` + `clipboard.store()` which **re-encodes 33 MB to PNG synchronously on the main thread** — freezing the desktop for 2-5 seconds +3. Query Zeitgeist + decode PNG + SHA1 hash the raw pixels on every paste from history +4. Keep unbounded copies in memory — 5 images = 165 MB+ + +A subsequent two-tier caching approach (instance `_cached_png` + static single-slot cache) reduced the freeze but introduced **~144 MB** memory usage for 5 items (10 MB PNG per instance) and a fragile cache duality with race conditions. + +--- + +## Architecture (Refactored) + +### Data Representations + +| Form | Size (4K image) | Purpose | +|------|-----------------|---------| +| `Gdk.Pixbuf` (raw RGBA pixels) | ~33 MB | Required by GTK's `set_pixbuf()` for non-PNG targets. Only kept on `with_image()` items (ONE item). | +| PNG-encoded `GLib.Bytes` (in LRU cache) | ~10 MB | Compact lossless form, stored in global `ImageCache` | +| `Gdk.Pixbuf` thumbnail (`_thumbnail`) | ~100 KB | 200×150 preview for menu display, kept on instance | + +### Key Classes + +- **`ImageCache`** — Global LRU cache (singleton) with a hard 64 MB memory limit. Stores PNG `GLib.Bytes` keyed by SHA1 checksum. Evicts least-recently-used entries when full. Also provides a single-slot **speculative pixbuf warm-up** cache for hover-based pre-decoding. +- **`ImageClipboardItem`** — Represents a clipboard image. Four construction paths optimized for different use cases: + - `with_image()` — Fresh copies: keeps pixbuf, saves thumbnail to disk + - `with_metadata()` — Menu display: loads ONLY thumbnail from disk (~5 KB) + - `with_known_payload()` — Paste: known checksum, keeps pixbuf, no SHA1 re-compute + - `with_payload()` — Legacy fallback: full decode, drops pixbuf +- **`ZeitgeistClipboardStorage`** — Stores/retrieves items via Zeitgeist (local database). Uses **lightweight mode** for menu items (thumbnails only) and **full mode** for paste operations. +- **`ClipboardManager`** — Monitors the system clipboard for changes. Implements **ownership-based loop detection** via `Gtk.Clipboard.get_owner()`. +- **`Controller`** — Orchestrates paste operations and menu rebuilds. +- **`ClipboardMenuItem`** — GTK menu widget showing the thumbnail. Hooks `select` signal for speculative pixbuf warm-up on hover. + +--- + +## Full Lifecycle + +### Phase 1: Image Copied (User copies an image in any app) + +``` +App copies image → X11 clipboard changes → owner_change signal + → ClipboardManager.check_clipboard() + → OWNERSHIP CHECK: get_owner() returns null (external app) → proceed + → clipboard.wait_for_image() + → on_image_received(pixbuf) [33 MB raw pixels] +``` + +**`ImageClipboardItem.with_image(pixbuf)`** is called: +1. `extract_pixbuf_info(pixbuf)`: + - SHA1 hash of all raw pixels → `_checksum` (unique content ID) + - Create `_thumbnail` (200×150, bilinear, contain-fit) → ~100 KB + - **Save thumbnail to disk** (`~/.local/share/diodon/thumbnails/.png`) for instant menu loading + - Encode pixbuf → PNG → `GLib.Bytes` → stored in global `ImageCache` +2. `_pixbuf` stays set (needed for immediate clipboard serving and `keep_clipboard_content`) +3. Item passed to `controller.add_item()` → stored in Zeitgeist with PNG payload + +**Cost:** ~200ms for SHA1 + PNG encode + thumbnail save. One-time per copy event. + +### Phase 2: Menu Opens (User clicks Diodon indicator) + +``` +rebuild_recent_menu() + → storage.get_recent_items() + → Zeitgeist query returns events + → create_clipboard_items() iterates events (lightweight=true) + → For each image event: extract checksum from URI, load thumbnail from disk +``` + +**`ImageClipboardItem.with_metadata(checksum, label)`** is called per image: +1. Checksum extracted from Zeitgeist subject URI (`dav:`) — **no SHA1 computation** +2. Label (dimensions string) read from Zeitgeist subject text — **no PNG decode** +3. Thumbnail loaded from disk (`~/.local/share/diodon/thumbnails/.png`) — **~5 KB** +4. If thumbnail file missing (pre-persistence items): graceful fallback, show label only + +**Memory per menu item:** ~5 KB thumbnail only (was ~33 MB temporary pixbuf). +**Global LRU cache:** Not touched during menu load. +**Menu open time:** Near-instant regardless of image count (was O(n × 100ms) for decoding). + +### Phase 2.5: Speculative Decode (User hovers an image item) + +``` +User highlights menu item → Gtk.MenuItem `select` signal + → GLib.Idle.add() schedules warm-up (non-blocking) + → ImageCache.warm_pixbuf(checksum) + → If PNG in LRU cache: decode to pixbuf, store in single-slot warm cache + → If PNG not cached: skip (paste path will handle it) +``` + +**Speculative decode** pre-decodes the full image when the user hovers or keyboard-navigates to an item. The decoded pixbuf is stored in `ImageCache._warm_pixbuf` (single slot, ~33 MB). When the user clicks, `to_clipboard()` picks it up instantly — zero decode latency at paste time. + +Only ONE warm pixbuf exists at a time. Hovering a new item releases the previous one. + +### Phase 3: User Clicks an Item (Paste from History) + +``` +ClipboardMenuItem.activate signal + → controller.select_item_by_checksum(checksum) + → storage.get_item_by_checksum(checksum) [Zeitgeist query] + → create_clipboard_item(lightweight=false) + → ImageClipboardItem.with_known_payload(checksum, payload) + → storage.select_item(item) + → item.to_clipboard(clipboard) + → execute_paste() → fake Ctrl+V via XTest +``` + +**`with_known_payload(checksum, payload)`** does: +1. Checksum already known from Zeitgeist URI — **skips SHA1 of 33 MB pixels (~15ms saved)** +2. Decode PNG → pixbuf (~50ms for 4K) +3. Store PNG in global LRU cache +4. **KEEP `_pixbuf`** — needed for immediate clipboard serving (no second decode) +5. Save thumbnail to disk if not already persisted + +**`to_clipboard(clipboard)`** does: +1. Check `_pixbuf` — **non-null** (kept by `with_known_payload()`) → fast path +2. Check `ImageCache.get_warm_pixbuf()` — speculative warm-up from hover +3. Fallback: decode from LRU cache (only if neither of the above) +4. Call `clipboard.set_with_owner(targets, get_func, clear_func, this)` + +**Improvement over previous design:** +- Old: decode pixbuf in `with_payload()` → drop it → re-decode in `to_clipboard()` (2× decode) +- New: decode once in `with_known_payload()` → keep it → `to_clipboard()` finds it immediately (1× decode) + +### Phase 4: Target App Requests Data + +``` +App pastes (Ctrl+V) → X11 selection request + → GTK calls clipboard_get_func(selection_data, target) + → We check what format the app wants +``` + +**`clipboard_get_func` — Fail-Fast Contract (never blocks >5ms):** + +| Requested Target | What We Do | Cost | +|-----------------|------------|------| +| `image/png` | Serve cached PNG bytes from `ImageCache` directly | **~0ms** (memcpy) | +| `image/bmp`, `image/x-pixbuf`, etc. | `selection_data.set_pixbuf(_pixbuf)` — GDK converts | ~100ms | +| Any format, pixbuf not ready | **Fail immediately** — return without setting data | **~0ms** | + +**The PNG fast path is the key optimization.** Most modern apps (GIMP, Chrome, LibreOffice, etc.) accept PNG. We serve our pre-encoded ~10 MB PNG bytes directly — no re-encoding of 33 MB pixels on the main thread. + +**Fail-fast guarantee:** For non-PNG targets, if `_pixbuf` is null (shouldn't happen after `to_clipboard()`, but possible in edge cases), the callback checks the speculative warm-up cache. If still null, it returns immediately without blocking. The requesting app sees an empty selection and can retry or fall back to a different target. **NEVER** does a synchronous PNG→pixbuf decode inside this callback — that would freeze the entire desktop. + +### Phase 5: Clipboard Feedback Loop (Eliminated) + +``` +set_with_owner() changes clipboard ownership + → owner_change signal fires + → ClipboardManager.check_clipboard() + → get_owner() returns ImageClipboardItem instance + → SELF-OWNED: return immediately (zero CPU cost) +``` + +**The feedback loop is now eliminated at the source:** +1. Diodon calls `set_with_owner(this)` → Diodon owns the clipboard +2. `owner_change` signal fires → `check_clipboard()` runs +3. `_clipboard.get_owner()` returns the `ImageClipboardItem` → recognized as self-ownership +4. **Return immediately** — no image readback, no SHA1 hash, no duplicate check + +When an external app later copies something, GTK clears Diodon's ownership. `get_owner()` returns null → normal processing resumes. + +--- + +## Cache Architecture + +### Global LRU Cache (ImageCache) + +``` +┌─────────────────────────────────────────────┐ +│ ImageCache (singleton) │ +│ │ +│ Hard limit: 64 MB │ +│ Storage: GLib.Bytes (immutable, ref-count) │ +│ Eviction: Least Recently Used │ +│ Lookup: O(1) HashTable by checksum │ +│ Ordering: GLib.Queue for LRU │ +│ │ +│ Capacity: ~6 cached 4K images │ +│ At rest: typically 1 image (~10 MB) │ +│ │ +│ Warm pixbuf: single-slot pre-decode cache │ +│ (~33 MB, populated on hover, cleared on │ +│ next hover or cache clear) │ +│ │ +│ Used by: │ +│ - clipboard_get_func (PNG fast path) │ +│ - to_clipboard (pixbuf resolution) │ +│ - get_payload (Zeitgeist storage) │ +│ - warm_pixbuf (speculative decode) │ +└─────────────────────────────────────────────┘ +``` + +### Thumbnail Persistence + +``` +~/.local/share/diodon/thumbnails/ + .png (~5 KB, 200×150) + .png + ... +``` + +Thumbnails are saved to disk at copy time (`extract_pixbuf_info()`). At menu load time, only these tiny files are read — never the full PNG payload from Zeitgeist. This makes the menu open instantly regardless of how many 4K images are in history. + +Files are written idempotently (skip if exists) and survive application restarts. Old items from before thumbnail persistence was added will show label-only in the menu until they are reselected (which triggers `with_known_payload()` → `save_thumbnail_to_disk()`). + +### Why LRU Instead of Two-Tier? + +The previous two-tier system (instance `_cached_png` + static single-slot cache) had: +- **Unbounded memory**: 10 items × 10 MB = 100 MB of `_cached_png` on instances +- **Cache coherence bugs**: single-slot cache eviction could leave items with stale or no data +- **Empty-paste bugs**: when static cache was evicted AND instance `_cached_png` was null + +The global LRU cache: +- **Fixed 64 MB limit** regardless of history size +- **Single source of truth** — no consistency issues between tiers +- **Automatic eviction** — least-used images freed first +- **GLib.Bytes immutability** — zero-copy ref-counting, safe for callbacks + +--- + +## Loop Detection: Ownership Check + +### Previous Method (Eliminated) + +``` +owner_change → read clipboard → wait_for_image() → 33 MB pixbuf + → SHA1 hash 33 MB → compare with current item → skip if match + Cost: ~200ms CPU + 33 MB temporary allocation per loop iteration +``` + +### New Method + +``` +owner_change → get_owner() → is IClipboardItem? → skip + Cost: ~0ms, single pointer comparison +``` + +`Gtk.Clipboard.get_owner()` returns the GObject passed to `set_with_owner()`. When Diodon sets the clipboard with an `ImageClipboardItem` as owner, subsequent `owner_change` callbacks detect this immediately and return without reading the clipboard. + +When another application takes clipboard ownership: +1. GTK calls `clipboard_clear_func` on our item +2. GTK clears the stored owner reference +3. `get_owner()` returns null on the next `owner_change` +4. Normal clipboard processing resumes + +--- + +## Bug History & Fixes + +### Bug: Desktop Freezing on 4K Paste +**Root cause:** `clipboard.set_image()` + `clipboard.store()` synchronously encodes 33 MB pixbuf to multiple formats. +**Fix:** Replaced with `set_with_owner()` + lazy `clipboard_get_func` callbacks. + +### Bug: CPU Spike on Every Paste from History +**Root cause:** Each paste triggered: Zeitgeist query → PNG decode → SHA1 of 33 MB → PNG re-encode. +**Fix:** PNG bytes cached in global LRU cache. SHA1 computed once during initial copy. Feedback loop eliminated via ownership check. + +### Bug: 213 MB Memory Usage (HashMap cache) +**Root cause:** Unbounded HashMap caching both pixbufs (33 MB each) AND PNGs (10 MB each). +**Fix:** Global LRU cache (64 MB hard limit) + instances hold only thumbnails (~100 KB). + +### Bug: 144 MB Memory Usage (Two-tier cache) +**Root cause:** Instance `_cached_png` fields (10 MB each × N items) + static cache. +**Fix:** Eliminated instance-level PNG storage entirely. Single global LRU cache bounded at 64 MB. + +### Bug: Empty Image Pasted +**Root cause:** Static cache eviction left items with no data source. +**Fix:** All PNG data in single LRU cache. Paste path always re-queries Zeitgeist → `with_known_payload()` → LRU cache is populated immediately before `to_clipboard()`. + +### Bug: First Image Always Pasted (Regardless of Selection) +**Root cause:** `from_cache()` shortcut returned stale data; `clipboard_get_func` forced PNG atom for all targets. +**Fix:** Removed `from_cache()` entirely. Always query Zeitgeist for correctness. `clipboard_get_func` checks requested target format. + +### Bug: Feedback Loop CPU Waste +**Root cause:** Reading back own clipboard data and hashing 33 MB of pixels just to detect self-ownership. +**Fix:** `ClipboardManager.check_clipboard()` calls `get_owner()` — if Diodon owns the clipboard (via `set_with_owner`), return immediately. Zero-cost ownership check. + +### Bug: Menu Open Lag (4K images decode on every open) +**Root cause:** `create_clipboard_items()` called `with_payload()` for each image event, which decoded the full PNG (33 MB pixbuf) just to extract a thumbnail. 10 images = 10× ~100ms decode = 1+ second lag. +**Fix:** `with_metadata()` constructor loads ONLY the thumbnail from disk (~5 KB). Checksum extracted from Zeitgeist URI — no SHA1, no PNG decode. Menu opens instantly. + +### Bug: Double Pixbuf Decode on Paste +**Root cause:** `with_payload()` decoded PNG → pixbuf for checksum extraction, then dropped `_pixbuf`. `to_clipboard()` immediately re-decoded the same PNG from LRU cache. Two expensive decodes for one paste operation. +**Fix:** `with_known_payload()` takes the checksum from the Zeitgeist URI (no SHA1 needed) and KEEPS the decoded pixbuf. `to_clipboard()` finds `_pixbuf` non-null — zero additional decode. + +### Bug: Desktop Freeze from clipboard_get_func Blocking +**Root cause:** `clipboard_get_func()` had a "last resort" path that synchronously decoded a 4K PNG (~50-100ms) on the GTK main thread when `_pixbuf` was null. This blocked the entire desktop during paste. +**Fix:** Fail-fast contract: `clipboard_get_func()` never blocks >5ms. For non-PNG targets without a ready pixbuf, it returns immediately without setting data. The requesting app sees an empty selection and can retry or fall back. + +--- + +## Memory Profile (Steady State) + +| Component | Memory | Notes | +|-----------|--------|-------| +| Global LRU cache | ≤ 64 MB | Hard limit; typically ~10 MB (one image) | +| Per-item thumbnails (10 items) | ~50 KB | Loaded from disk, ~5 KB each | +| Warm pixbuf (speculative) | ~33 MB | Single-slot, only while hovering image item | +| Active paste pixbuf | ~33 MB | Only during paste, kept by `with_known_payload()` | +| Thumbnail files on disk | ~50 KB | Persistent, survives restarts | +| **Total (worst case, hovering)** | **~130 MB** | LRU max + warm pixbuf + thumbnails | +| **Total (typical, menu open)** | **~10.05 MB** | ~10 MB cache + 50 KB disk thumbnails | +| **Total (idle, menu closed)** | **≤ 10 MB** | Only last-touched image in LRU | + +**Improvement over previous architectures:** +- Original: **165 MB+** (unbounded pixbufs) +- Two-tier cache: **~144 MB** (instance `_cached_png` × N) +- LRU refactor: **~10 MB idle, 98 MB peak** (bounded) +- **Performance patch: ~10 MB idle, instant menu open, fail-fast paste** + +--- + +## UI Changes + +- **Image thumbnails:** 200×150 max, bilinear scaling, contain-fit (no crop, no upscale) +- **Menu items:** Base class `Gtk.MenuItem` (was `Gtk.ImageMenuItem`), centered image in `Gtk.Box` +- **Text wrapping:** Labels wrap to 4 lines max, 50 chars wide, ellipsize at end +- **Label length:** Text/file items show up to 100 chars (was 50) +- **Auto-select:** Menu auto-selects first item on popup for keyboard navigation + +--- + +## Build & Run + +```bash +# Build +cd builddir && ninja + +# Run (with custom libdiodon) +LD_LIBRARY_PATH=builddir/libdiodon builddir/diodon/diodon + +# Kill & restart +killall diodon; sleep 1; LD_LIBRARY_PATH=builddir/libdiodon builddir/diodon/diodon & +``` diff --git a/libdiodon/clipboard-manager.vala b/libdiodon/clipboard-manager.vala index 7e90129..e90e267 100644 --- a/libdiodon/clipboard-manager.vala +++ b/libdiodon/clipboard-manager.vala @@ -136,6 +136,24 @@ namespace Diodon */ protected virtual void check_clipboard() { + // === Ownership Check (self-loop detection) === + // When Diodon sets the clipboard via set_with_owner(), + // the owner_change signal fires back. Instead of reading + // the clipboard data and hashing pixels (expensive!), + // we check if WE still own the clipboard. If so, this is + // our own feedback loop — skip immediately. + // + // get_owner() returns the GObject passed to set_with_owner(). + // For ImageClipboardItem: returns the item instance. + // For text (set_text): returns null (no ownership tracking). + // When another app takes ownership: GTK clears the owner. + GLib.Object? owner = _clipboard.get_owner(); + if (owner != null && owner is IClipboardItem) { + debug("Clipboard owned by Diodon (%s), skipping self-feedback", + owner.get_type().name()); + return; + } + // on java applications such as jEdit wait_is_text_available returns // false even when some text is available string? text = request_text(); diff --git a/libdiodon/clipboard-menu-item.vala b/libdiodon/clipboard-menu-item.vala index 098a3bb..34818c5 100644 --- a/libdiodon/clipboard-menu-item.vala +++ b/libdiodon/clipboard-menu-item.vala @@ -24,10 +24,14 @@ namespace Diodon /** * A gtk menu item holding a checksum of a clipboard item. It only keeps * the checksum as it would waste memory to keep the hole item available. + * + * For image items, also exposes is_image_item() so the menu can + * hook the `select` signal for speculative pixbuf warm-up. */ class ClipboardMenuItem : Gtk.MenuItem { private string _checksum; + private bool _is_image; /** * Clipboard item constructor @@ -37,6 +41,7 @@ namespace Diodon public ClipboardMenuItem(IClipboardItem item) { _checksum = item.get_checksum(); + _is_image = (item.get_category() == ClipboardCategory.IMAGES); Gtk.Image? image = item.get_image(); if(image != null) { @@ -104,6 +109,17 @@ namespace Diodon return _checksum; } + /** + * Check if this menu item represents an image clipboard item. + * Used for speculative pixbuf warm-up on hover. + * + * @return true if the item is an image + */ + public bool is_image_item() + { + return _is_image; + } + /** * Highlight item by changing label to bold * TODO: get this up and running diff --git a/libdiodon/clipboard-menu.vala b/libdiodon/clipboard-menu.vala index 81e8fa4..5b3e6bd 100644 --- a/libdiodon/clipboard-menu.vala +++ b/libdiodon/clipboard-menu.vala @@ -94,12 +94,31 @@ namespace Diodon /** * Append given clipboard item to menu. * + * For image items, also hooks the `select` signal to trigger + * speculative pixbuf warm-up when the user hovers/navigates + * to the item. This pre-decodes the full image in an idle + * callback so paste is instant when they click. + * * @param entry entry to be added */ public void append_clipboard_item(IClipboardItem item) { ClipboardMenuItem menu_item = new ClipboardMenuItem(item); menu_item.activate.connect(on_clicked_item); + + // Speculative decode: when user hovers an image item, + // pre-decode the full pixbuf from LRU cache in an idle callback. + // This eliminates decode latency when they click to paste. + if (menu_item.is_image_item()) { + menu_item.select.connect(() => { + string cs = menu_item.get_item_checksum(); + GLib.Idle.add(() => { + ImageCache.get_default().warm_pixbuf(cs); + return GLib.Source.REMOVE; + }); + }); + } + menu_item.show(); append(menu_item); } diff --git a/libdiodon/image-cache.vala b/libdiodon/image-cache.vala new file mode 100644 index 0000000..feef3ed --- /dev/null +++ b/libdiodon/image-cache.vala @@ -0,0 +1,323 @@ +/* + * Diodon - GTK+ clipboard manager. + * Copyright (C) 2011-2013 Diodon Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published + * by the Free Software Foundation, either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public + * License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace Diodon +{ + /** + * Global LRU (Least Recently Used) cache for image PNG data. + * + * Replaces the fragile two-tier caching (instance _cached_png + + * static single-slot cache) with a single bounded cache. + * + * Designed for immutability: all data stored as GLib.Bytes + * (ref-counted, zero-copy sharing). Hard memory limit prevents + * unbounded growth — evicts least-recently-used entries when full. + * + * Typical usage: 64 MB limit ≈ 6 cached 4K PNG images. + * Only the actively-served image needs to be in cache; + * everything else is fetched from Zeitgeist on demand. + */ + public class ImageCache : GLib.Object + { + /** Default hard limit: 64 MB */ + public const int64 DEFAULT_MAX_BYTES = 64 * 1024 * 1024; + + /** + * A single cache entry: PNG data keyed by content checksum. + */ + private class CacheEntry + { + public string checksum; + public GLib.Bytes png_data; + public int width; + public int height; + public int64 size; + + public CacheEntry(string checksum, GLib.Bytes png_data, int width, int height) + { + this.checksum = checksum; + this.png_data = png_data; + this.width = width; + this.height = height; + this.size = (int64) png_data.get_size(); + } + } + + // LRU ordering: checksums from most-recently-used (head) + // to least-recently-used (tail). Used for eviction. + private GLib.Queue _lru_order; + + // O(1) lookup by checksum + private GLib.HashTable _entries; + + // Current total bytes of all cached PNG data + private int64 _current_bytes; + + // Hard memory limit + private int64 _max_bytes; + + // Singleton instance + private static ImageCache? _instance = null; + + /** + * Get the global singleton cache instance. + * + * Thread-safe: GLib guarantees static variable initialization + * is atomic on POSIX. All callers share one cache. + */ + public static ImageCache get_default() + { + if (_instance == null) { + _instance = new ImageCache(DEFAULT_MAX_BYTES); + } + return _instance; + } + + /** + * Create a cache with the given byte limit. + * Prefer get_default() for production; this constructor + * exists for testing with custom limits. + */ + public ImageCache(int64 max_bytes) + { + _max_bytes = max_bytes; + _current_bytes = 0; + _lru_order = new GLib.Queue(); + _entries = new GLib.HashTable( + GLib.str_hash, GLib.str_equal); + } + + /** + * Insert or update a PNG entry in the cache. + * Evicts least-recently-used entries until the new entry fits + * within the memory limit. + * + * @param checksum content checksum (SHA1 of raw pixels) + * @param png_data immutable PNG bytes (GLib.Bytes for zero-copy) + * @param width original image width in pixels + * @param height original image height in pixels + */ + public void put(string checksum, GLib.Bytes png_data, int width, int height) + { + // Remove existing entry first (updates LRU position) + remove(checksum); + + var entry = new CacheEntry(checksum, png_data, width, height); + + // Don't cache entries larger than the entire limit + if (entry.size > _max_bytes) { + debug("Image %s exceeds cache limit, not caching", checksum); + return; + } + + // Evict LRU entries until there's room + while (_current_bytes + entry.size > _max_bytes && _lru_order.length > 0) { + evict_oldest(); + } + + _lru_order.push_head(checksum); + _entries[checksum] = entry; + _current_bytes += entry.size; + + debug("Cache put: %s (%dx%d). Entries: %u", + checksum, width, height, _lru_order.length); + } + + /** + * Retrieve PNG bytes by checksum, promoting to most-recently-used. + * Returns null on cache miss. + * + * @param checksum content checksum to look up + * @return immutable PNG bytes, or null if not cached + */ + public GLib.Bytes? get_png(string checksum) + { + CacheEntry? entry = _entries[checksum]; + if (entry == null) { + return null; + } + + // Promote to MRU: remove from current position, push to head + // GLib.Queue.remove is O(n) but n ≤ 6 for typical 64MB / 10MB images + _lru_order.remove(checksum); + _lru_order.push_head(checksum); + + return entry.png_data; + } + + /** + * Get cached image dimensions without promoting in LRU. + * Useful for label generation without triggering eviction changes. + * + * @param checksum content checksum + * @param width output: image width, 0 if not cached + * @param height output: image height, 0 if not cached + * @return true if entry found + */ + public bool get_dimensions(string checksum, out int width, out int height) + { + width = 0; + height = 0; + CacheEntry? entry = _entries[checksum]; + if (entry == null) { + return false; + } + width = entry.width; + height = entry.height; + return true; + } + + /** + * Check if a checksum is present in the cache. + */ + public bool contains(string checksum) + { + return _entries.contains(checksum); + } + + /** + * Remove a specific entry from the cache. + */ + public void remove(string checksum) + { + CacheEntry? entry = _entries[checksum]; + if (entry == null) { + return; + } + _current_bytes -= entry.size; + _lru_order.remove(checksum); + _entries.remove(checksum); + } + + /** + * Clear all entries and reset memory counter. + */ + public void clear() + { + _lru_order.clear(); + _entries.remove_all(); + _current_bytes = 0; + clear_warm_pixbuf(); + } + + /** + * Evict the least-recently-used entry. + */ + private void evict_oldest() + { + string? oldest = _lru_order.pop_tail(); + if (oldest != null) { + CacheEntry? entry = _entries[oldest]; + if (entry != null) { + debug("Cache evict: %s", oldest); + _current_bytes -= entry.size; + _entries.remove(oldest); + } + } + } + + // === Speculative Pixbuf Warm-up === + // Single-slot cache: holds ONE pre-decoded pixbuf for the item + // the user is currently hovering over in the menu. Eliminates + // the decode latency when they click to paste. + private string? _warm_checksum = null; + private Gdk.Pixbuf? _warm_pixbuf = null; + + /** + * Speculatively decode the pixbuf for the given checksum. + * + * Called from an idle callback when the user hovers over an + * image menu item. Decodes PNG → pixbuf so it's ready when + * to_clipboard() or clipboard_get_func() needs it. + * + * Only ONE warm pixbuf is held at a time (~33 MB for 4K). + * Decoding a new checksum releases the previous one. + * + * @param checksum content checksum to pre-decode + */ + public void warm_pixbuf(string checksum) + { + // Already warmed for this checksum? + if (_warm_checksum == checksum && _warm_pixbuf != null) { + return; + } + + // Release previous warm pixbuf + _warm_pixbuf = null; + _warm_checksum = null; + + // Get PNG from cache; can't warm without data + GLib.Bytes? png = get_png(checksum); + if (png == null) { + debug("Warm pixbuf: no PNG cached for %s", checksum); + return; + } + + // Decode PNG → pixbuf + try { + unowned uint8[] data = png.get_data(); + Gdk.PixbufLoader loader = new Gdk.PixbufLoader(); + loader.write(data); + loader.close(); + _warm_pixbuf = loader.get_pixbuf(); + _warm_checksum = checksum; + debug("Warm pixbuf ready: %s (%dx%d)", checksum, + _warm_pixbuf.width, _warm_pixbuf.height); + } catch (GLib.Error e) { + warning("Warm pixbuf decode failed for %s: %s", checksum, e.message); + } + } + + /** + * Get the speculatively decoded pixbuf, if available. + * + * Returns the pre-decoded pixbuf only if it matches the + * requested checksum. Returns null on mismatch or if + * no warm-up has been performed. + * + * @param checksum content checksum to look up + * @return decoded pixbuf, or null + */ + public Gdk.Pixbuf? get_warm_pixbuf(string checksum) + { + if (_warm_checksum == checksum) { + return _warm_pixbuf; + } + return null; + } + + /** + * Clear the speculative pixbuf cache. + * Called on cache clear or when warm-up is no longer needed. + */ + public void clear_warm_pixbuf() + { + _warm_pixbuf = null; + _warm_checksum = null; + } + + /** Current total cached bytes */ + public int64 get_current_bytes() { return _current_bytes; } + + /** Configured maximum bytes */ + public int64 get_max_bytes() { return _max_bytes; } + + /** Number of entries in cache */ + public uint get_entry_count() { return _lru_order.length; } + } +} diff --git a/libdiodon/image-clipboard-item.vala b/libdiodon/image-clipboard-item.vala index 92a1c51..3a3bd3b 100644 --- a/libdiodon/image-clipboard-item.vala +++ b/libdiodon/image-clipboard-item.vala @@ -22,114 +22,207 @@ namespace Diodon { /** - * An image clipboard item representing such in a preview image. + * An image clipboard item representing an image in the clipboard history. + * + * === Memory Architecture (Performance Engineer Patch) === + * + * Three construction paths, each optimized for its purpose: + * + * 1. with_image() — Fresh clipboard copy + * - Decodes pixbuf info, saves thumbnail to disk, caches PNG in LRU + * - KEEPS _pixbuf for immediate clipboard serving + * - Only ONE such item exists at a time (current clipboard) + * + * 2. with_metadata() — Menu display (LIGHTWEIGHT) + * - Loads ONLY the tiny thumbnail from disk (~5 KB PNG) + * - Never touches the full PNG payload or Zeitgeist event data + * - Makes menu open instant even with dozens of 4K images + * + * 3. with_known_payload() — Paste path + * - Checksum already known from Zeitgeist URI (skip SHA1) + * - Decodes pixbuf and KEEPS it for clipboard serving + * - One decode instead of two (vs old with_payload + to_clipboard) + * + * === Fail-Fast Clipboard Serving === + * + * clipboard_get_func() NEVER blocks >5ms: + * - image/png: served from LRU cache in ~0ms (memcpy) + * - Other formats: served from _pixbuf if ready, or from + * speculative warm-up cache. If neither available, returns + * FALSE (fail-fast) instead of blocking for decode. + * + * === Speculative Decoding === + * + * When user hovers over a menu item, ImageCache.warm_pixbuf() + * pre-decodes the full image in an idle callback. When the user + * clicks, to_clipboard() picks up the warm pixbuf instantly. + * + * === Thumbnail Persistence === + * + * Thumbnails are saved to ~/.local/share/diodon/thumbnails/.png + * at copy time. Menu display loads ONLY this file, never the full payload. + * Backward-compatible: if thumbnail file missing, falls back gracefully + * with a null image (the menu item shows label text only). */ public class ImageClipboardItem : GLib.Object, IClipboardItem { private ClipboardType _clipboard_type; - private string _checksum; // checksum to identify pic content - private Gdk.Pixbuf _pixbuf; + private string _checksum; + private Gdk.Pixbuf? _pixbuf; // only for with_image/with_known_payload items + private Gdk.Pixbuf? _thumbnail; // ~5 KB, always set if available private string _label; private string? _origin; private DateTime _date_copied; - // Cached PNG payload bytes — avoids re-encoding on every get_payload() call - private uint8[]? _cached_png = null; - - // Static in-memory cache: checksum → {pixbuf, png_bytes} - // Keeps full-res data alive so paste from history is a dict - // lookup instead of Zeitgeist query + PNG decode + PNG re-encode. - private static GLib.HashTable? _pixbuf_cache = null; - private static GLib.HashTable? _png_cache = null; - - private static unowned GLib.HashTable get_pixbuf_cache() { - if (_pixbuf_cache == null) { - _pixbuf_cache = new GLib.HashTable(str_hash, str_equal); - } - return _pixbuf_cache; - } - - private static unowned GLib.HashTable get_png_cache() { - if (_png_cache == null) { - _png_cache = new GLib.HashTable(str_hash, str_equal); - } - return _png_cache; + /** + * Create image clipboard item from a live pixbuf (fresh clipboard copy). + * + * Called when an external app copies an image. The pixbuf is kept + * on the instance for immediate clipboard serving. PNG is encoded + * once and stored in the global LRU cache. Thumbnail is saved to + * disk for instant menu loading on future sessions. + * + * @param clipboard_type clipboard type item is coming from + * @param pixbuf image from clipboard (33 MB for 4K RGBA) + * @param origin origin of clipboard item as application path + * @param date_copied timestamp + */ + public ImageClipboardItem.with_image(ClipboardType clipboard_type, Gdk.Pixbuf pixbuf, string? origin, DateTime date_copied) throws GLib.Error + { + _clipboard_type = clipboard_type; + _origin = origin; + _date_copied = date_copied; + extract_pixbuf_info(pixbuf); + // Keep _pixbuf — needed for immediate clipboard serving + // and keep_clipboard_content sync restore. This is the ONLY + // construction path that holds a pixbuf long-term; all + // other paths drop theirs or never decode one. } /** - * Try to build an ImageClipboardItem from the in-memory cache. - * Returns null if the checksum is not cached. - * Bypasses extract_pixbuf_info() entirely — no SHA1 rehash of - * the full pixel data. Both pixbuf AND PNG bytes are restored - * from cache so get_payload() never needs to re-encode either. + * Create image clipboard item from stored PNG payload (Zeitgeist history). + * + * LEGACY constructor — kept for backward compatibility with callers + * that don't have the checksum yet. Prefer with_known_payload() or + * with_metadata() for new code paths. + * + * Decodes PNG to extract thumbnail and checksum, then DROPS the + * pixbuf. PNG bytes go into the global LRU cache (not on the instance). + * + * @param clipboard_type clipboard type + * @param payload PNG bytes from Zeitgeist event + * @param origin origin application path + * @param date_copied timestamp */ - public static ImageClipboardItem? from_cache(string checksum, string? origin, DateTime date_copied) { - unowned Gdk.Pixbuf? cached_pix = get_pixbuf_cache().lookup(checksum); - if (cached_pix == null) { - return null; - } - // Build item directly — do NOT call with_image/extract_pixbuf_info - // which would SHA1-hash 33MB of pixel data for nothing. - var item = new ImageClipboardItem._from_cache_internal(); - item._clipboard_type = ClipboardType.NONE; - item._origin = origin; - item._date_copied = date_copied; - item._checksum = checksum; - item._label = "[%dx%d]".printf(cached_pix.width, cached_pix.height); - item._pixbuf = cached_pix; - - // PNG bytes live in the static cache — get_payload() reads - // them by checksum. No copying needed here. - return item; - } + public ImageClipboardItem.with_payload(ClipboardType clipboard_type, ByteArray payload, string? origin, DateTime date_copied) throws GLib.Error + { + _clipboard_type = clipboard_type; + _origin = origin; + _date_copied = date_copied; - // Private no-op constructor for from_cache() to avoid - // the expensive extract_pixbuf_info() path. - private ImageClipboardItem._from_cache_internal() { + // Decode PNG -> pixbuf (temporary, ~33 MB) + Gdk.PixbufLoader loader = new Gdk.PixbufLoader(); + loader.write(payload.data); + loader.close(); + Gdk.Pixbuf pixbuf = loader.get_pixbuf(); + + // Extract checksum + thumbnail from pixbuf + extract_pixbuf_info(pixbuf); + + // Store PNG in global LRU cache (as immutable GLib.Bytes) + var png_bytes = new GLib.Bytes(payload.data); + ImageCache.get_default().put(_checksum, png_bytes, + pixbuf.width, pixbuf.height); + + // DROP the pixbuf — this item is display-only (thumbnail). + // Saves ~33 MB per history item. PNG lives in the LRU cache + // and can be re-fetched from Zeitgeist if evicted. + _pixbuf = null; } /** - * Create image clipboard item by a pixbuf. + * Create image clipboard item from metadata only (menu display). * - * @param clipboard_type clipboard type item is coming from - * @param pixbuf image from clipboard - * @param origin origin of clipboard item as application path + * LIGHTWEIGHT path — loads ONLY the tiny thumbnail from disk (~5 KB). + * Never touches the full PNG payload or decodes any image data. + * This makes menu open instant even with dozens of 4K images in history. + * + * Used exclusively by create_clipboard_items() for the recent menu. + * When the user clicks to paste, a new item is created via + * with_known_payload() which does the full decode. + * + * Falls back gracefully if thumbnail file is missing (old items + * from before thumbnail persistence was added): shows label only. + * + * @param clipboard_type clipboard type + * @param checksum SHA1 content checksum (extracted from Zeitgeist URI) + * @param label dimension string e.g. "[3840x2160]" + * @param origin origin application path + * @param date_copied timestamp */ - public ImageClipboardItem.with_image(ClipboardType clipboard_type, Gdk.Pixbuf pixbuf, string? origin, DateTime date_copied) throws GLib.Error + public ImageClipboardItem.with_metadata(ClipboardType clipboard_type, string checksum, string label, string? origin, DateTime date_copied) { _clipboard_type = clipboard_type; + _checksum = checksum; + _label = label; _origin = origin; _date_copied = date_copied; - extract_pixbuf_info(pixbuf); + _pixbuf = null; // No full image — menu display only + + // Load thumbnail from disk (~5 KB PNG) + string thumb_path = get_thumbnail_path(checksum); + try { + _thumbnail = new Gdk.Pixbuf.from_file(thumb_path); + } catch (GLib.Error e) { + debug("Thumbnail not on disk for %s, menu will show label only", checksum); + _thumbnail = null; + } } /** - * Create image clipboard item by given payload. + * Create image clipboard item from known checksum + payload (paste path). * - * @param clipboard_type clipboard type item is coming from - * @param pixbuf image from clipboard - * @param origin origin of clipboard item as application path + * Optimized paste constructor. The checksum is already known from the + * Zeitgeist subject URI, eliminating the expensive SHA1 re-computation + * over raw pixels (~15ms for 4K). Decodes the pixbuf and KEEPS it + * for immediate clipboard serving — no double-decode. + * + * Also saves thumbnail to disk if not already persisted (handles + * upgrade from pre-thumbnail versions). + * + * @param clipboard_type clipboard type + * @param checksum known SHA1 checksum from Zeitgeist URI + * @param payload PNG bytes from Zeitgeist event + * @param origin origin application path + * @param date_copied timestamp */ - public ImageClipboardItem.with_payload(ClipboardType clipboard_type, ByteArray payload, string? origin, DateTime date_copied) throws GLib.Error + public ImageClipboardItem.with_known_payload(ClipboardType clipboard_type, string checksum, ByteArray payload, string? origin, DateTime date_copied) throws GLib.Error { _clipboard_type = clipboard_type; + _checksum = checksum; _origin = origin; _date_copied = date_copied; - // Cache the raw PNG bytes on the instance AND in the - // static cache so get_payload() never re-encodes. - _cached_png = new uint8[payload.data.length]; - GLib.Memory.copy(_cached_png, payload.data, payload.data.length); - + // Decode PNG → pixbuf Gdk.PixbufLoader loader = new Gdk.PixbufLoader(); loader.write(payload.data); loader.close(); Gdk.Pixbuf pixbuf = loader.get_pixbuf(); - extract_pixbuf_info(pixbuf); - // Also put PNG bytes in the static cache keyed by checksum - // (checksum is set by extract_pixbuf_info above) - get_png_cache().replace(_checksum, new GLib.Bytes(payload.data)); + _label = "[%dx%d]".printf(pixbuf.width, pixbuf.height); + _thumbnail = create_scaled_pixbuf(pixbuf); + + // Store PNG in global LRU cache + var png_bytes = new GLib.Bytes(payload.data); + ImageCache.get_default().put(_checksum, png_bytes, + pixbuf.width, pixbuf.height); + + // Ensure thumbnail is persisted to disk + save_thumbnail_to_disk(_thumbnail, _checksum); + + // KEEP pixbuf — this is the paste path, immediate serving needed. + // to_clipboard() will find _pixbuf non-null and skip decode. + _pixbuf = pixbuf; } /** @@ -153,7 +246,7 @@ namespace Diodon */ public string get_text() { - return _label; // label is representation of image + return _label; } /** @@ -177,7 +270,6 @@ namespace Diodon */ public string get_mime_type() { - // images are always converted to png return "image/png"; } @@ -187,14 +279,16 @@ namespace Diodon public Icon get_icon() { try { - File file = save_tmp_pixbuf(_pixbuf); - FileIcon icon = new FileIcon(file); - return icon; + if (_pixbuf != null) { + File file = save_tmp_pixbuf(_pixbuf); + FileIcon icon = new FileIcon(file); + return icon; + } } catch(Error e) { warning("Could not create icon for image %s. Fallback to content type", _checksum); - return ContentType.get_icon(get_mime_type()); } + return ContentType.get_icon(get_mime_type()); } /** @@ -210,24 +304,26 @@ namespace Diodon */ public Gtk.Image? get_image() { - Gdk.Pixbuf pixbuf_preview = create_scaled_pixbuf(_pixbuf); - return new Gtk.Image.from_pixbuf(pixbuf_preview); + if (_thumbnail != null) { + return new Gtk.Image.from_pixbuf(_thumbnail); + } + if (_pixbuf != null) { + Gdk.Pixbuf preview = create_scaled_pixbuf(_pixbuf); + return new Gtk.Image.from_pixbuf(preview); + } + return null; } /** - * {@inheritDoc} - */ + * {@inheritDoc} + * + * Returns PNG payload for Zeitgeist storage. + * Checks global LRU cache first, then encodes from pixbuf. + */ public ByteArray? get_payload() throws GLib.Error { - // 1. Check instance cache - if (_cached_png != null) { - ByteArray ba = new ByteArray.sized((uint) _cached_png.length); - ba.append(_cached_png); - return ba; - } - - // 2. Check static cache (from_cache items land here) - GLib.Bytes? cached = get_png_cache().lookup(_checksum); + // 1. Check global LRU cache + GLib.Bytes? cached = ImageCache.get_default().get_png(_checksum); if (cached != null) { unowned uint8[] data = cached.get_data(); ByteArray ba = new ByteArray.sized((uint) data.length); @@ -235,16 +331,21 @@ namespace Diodon return ba; } - // 3. Last resort: encode (first time only, e.g. fresh copy) - uint8[] buffer; - _pixbuf.save_to_buffer(out buffer, "png"); + // 2. Encode from pixbuf (only for fresh with_image items) + if (_pixbuf != null) { + uint8[] buffer; + _pixbuf.save_to_buffer(out buffer, "png"); - // Cache for future - _cached_png = new uint8[buffer.length]; - GLib.Memory.copy(_cached_png, buffer, buffer.length); - get_png_cache().replace(_checksum, new GLib.Bytes(buffer)); + // Cache for future use + var png_bytes = new GLib.Bytes(buffer); + ImageCache.get_default().put(_checksum, png_bytes, + _pixbuf.width, _pixbuf.height); - return new ByteArray.take(buffer); + return new ByteArray.take(buffer); + } + + warning("No PNG data available for image %s", _checksum); + return null; } /** @@ -256,30 +357,88 @@ namespace Diodon } /** - * {@inheritDoc} - */ + * {@inheritDoc} + * + * Sets the image on the clipboard using set_with_owner() for + * lazy, on-demand data serving. + * + * Pixbuf resolution order: + * 1. Instance _pixbuf (with_image / with_known_payload items) + * 2. Speculative warm-up cache (user hovered before clicking) + * 3. Decode from LRU cache (fallback, ~50ms for 4K) + * 4. Give up (no data available) + */ public void to_clipboard(Gtk.Clipboard clipboard) { - // Use set_with_owner so WE control what data gets served - // to requesting apps. When an app asks for image/png, - // we serve our pre-encoded cached bytes directly — - // no re-encoding of 33MB of raw pixels on the main thread. - Gtk.TargetList target_list = new Gtk.TargetList(null); - target_list.add_image_targets(0, true); - Gtk.TargetEntry[] entries = Gtk.target_table_new_from_list(target_list); - - clipboard.set_with_owner( - entries, - clipboard_get_func, - clipboard_clear_func, - this - ); + // 1. Already have pixbuf (with_image / with_known_payload) + if (_pixbuf != null) { + // fast path — no decode needed + } + // 2. Check speculative warm-up from hover + else { + Gdk.Pixbuf? warm = ImageCache.get_default().get_warm_pixbuf(_checksum); + if (warm != null) { + _pixbuf = warm; + debug("to_clipboard: using warm pixbuf for %s", _checksum); + } + } + // 3. Fallback: decode from LRU cache + if (_pixbuf == null) { + GLib.Bytes? cached = ImageCache.get_default().get_png(_checksum); + if (cached != null) { + try { + unowned uint8[] data = cached.get_data(); + Gdk.PixbufLoader loader = new Gdk.PixbufLoader(); + loader.write(data); + loader.close(); + _pixbuf = loader.get_pixbuf(); + debug("to_clipboard: decoded from LRU for %s", _checksum); + } catch (GLib.Error e) { + warning("Failed to decode pixbuf for clipboard: %s", e.message); + } + } + } + + if (_pixbuf == null) { + warning("No image data available for clipboard (checksum: %s)", _checksum); + return; + } + + // Use set_with_owner for lazy data serving. + // Data is only encoded when an app actually requests it, + // and only in the requested format — no upfront serialization. + Gtk.TargetList target_list = new Gtk.TargetList(null); + target_list.add_image_targets(0, true); + Gtk.TargetEntry[] entries = Gtk.target_table_new_from_list(target_list); + + clipboard.set_with_owner( + entries, + clipboard_get_func, + clipboard_clear_func, + this + ); } /** * Called by GTK when a target app requests clipboard data. - * Serves cached PNG bytes directly for image/png requests, - * falls back to pixbuf encoding only for rare other formats. + * + * === FAIL-FAST CONTRACT: Never blocks >5ms === + * + * image/png: Served directly from LRU cache (~0ms memcpy). + * If not cached, fails immediately — the requesting app + * sees an empty selection and retries or falls back. + * + * Other formats (BMP, TIFF, etc.): Converted from _pixbuf + * via GDK. If _pixbuf is null, checks the speculative + * warm-up cache (populated when user hovered the menu item). + * If still null, fails immediately — NEVER does a synchronous + * PNG→pixbuf decode in this callback. + * + * Rationale: clipboard_get_func runs on the GTK main thread. + * A synchronous decode of a 4K PNG (~50-100ms) would freeze + * the entire desktop for every paste operation. By the time + * this callback fires, to_clipboard() should have already + * set _pixbuf via one of the three resolution paths. */ private static void clipboard_get_func( Gtk.Clipboard clipboard, @@ -288,34 +447,39 @@ namespace Diodon void* user_data_or_owner) { ImageClipboardItem self = (ImageClipboardItem) user_data_or_owner; - - // Try serving cached PNG for image/png requests (the common case) string target_name = selection_data.get_target().name(); + + // Fast path: serve cached PNG directly (~0ms, memcpy only) if (target_name == "image/png") { - // Check static cache first, then instance - GLib.Bytes? cached = get_png_cache().lookup(self._checksum); + GLib.Bytes? cached = ImageCache.get_default().get_png(self._checksum); if (cached != null) { unowned uint8[] data = cached.get_data(); - selection_data.set( - selection_data.get_target(), - 8, - data - ); - return; - } - if (self._cached_png != null) { - selection_data.set( - selection_data.get_target(), - 8, - self._cached_png - ); + selection_data.set(selection_data.get_target(), 8, data); return; } + // PNG not in cache — fail fast + debug("clipboard_get_func: PNG cache miss for %s, failing fast", self._checksum); + return; + } + + // Non-PNG formats: need pixbuf for GDK conversion + // 1. Use instance pixbuf if available (normal case after to_clipboard) + if (self._pixbuf != null) { + selection_data.set_pixbuf(self._pixbuf); + return; } - // Fallback for other formats (image/bmp, image/jpeg, etc.) - // Let GDK encode from pixbuf — rare path - selection_data.set_pixbuf(self._pixbuf); + // 2. Check speculative warm-up cache + Gdk.Pixbuf? warm = ImageCache.get_default().get_warm_pixbuf(self._checksum); + if (warm != null) { + self._pixbuf = warm; + selection_data.set_pixbuf(warm); + return; + } + + // 3. FAIL FAST — no synchronous decode, no blocking + debug("clipboard_get_func: pixbuf not ready for %s (target: %s), failing fast", + self._checksum, target_name); } /** @@ -325,7 +489,7 @@ namespace Diodon Gtk.Clipboard clipboard, void* user_data_or_owner) { - // Nothing to clean up — data lives in the static cache + // Nothing to clean up — data lives in the global LRU cache } /** @@ -348,53 +512,100 @@ namespace Diodon */ public uint hash() { - // use checksum to create hash code return str_hash(_checksum); } /** - * Extracts all pixbuf information which are needed to show image - * in the view without having the pixbuf in the memory. + * Extract checksum and thumbnail from a pixbuf. * - * @param pixbuf pixbuf to extract info from + * SHA1 hashes all raw pixels to produce a unique content ID. + * Creates a 200x150 thumbnail for menu display and saves it + * to disk for instant loading on future sessions. + * Encodes PNG and stores in the global LRU cache. + * + * @param pixbuf source pixbuf (typically ~33 MB for 4K RGBA) */ private void extract_pixbuf_info(Gdk.Pixbuf pixbuf) { - // create checksum of picture + // SHA1 hash of raw pixel data -> unique content checksum Checksum checksum = new Checksum(ChecksumType.SHA1); checksum.update(pixbuf.get_pixels(), pixbuf.height * pixbuf.rowstride); _checksum = checksum.get_string().dup(); - // label in format [{width}x{height}] - _label ="[%dx%d]".printf(pixbuf.width, pixbuf.height); + _label = "[%dx%d]".printf(pixbuf.width, pixbuf.height); _pixbuf = pixbuf; - // Cache the pixbuf so future pastes from history are instant - // (dict lookup instead of Zeitgeist query + PNG decode) - get_pixbuf_cache().replace(_checksum, pixbuf); + // Pre-compute thumbnail (200x150 max, bilinear, contain-fit) + _thumbnail = create_scaled_pixbuf(pixbuf); + + // Persist thumbnail to disk for instant menu loading + save_thumbnail_to_disk(_thumbnail, _checksum); + + // Encode PNG and store in global LRU cache + try { + uint8[] buf; + pixbuf.save_to_buffer(out buf, "png"); + var png_bytes = new GLib.Bytes(buf); + ImageCache.get_default().put(_checksum, png_bytes, + pixbuf.width, pixbuf.height); + } catch (GLib.Error e) { + warning("Failed to cache PNG for %s: %s", _checksum, e.message); + } + } + + /** + * Save a thumbnail pixbuf to disk for instant menu loading. + * + * Writes to ~/.local/share/diodon/thumbnails/.png + * Creates the directory if it doesn't exist. Skips if the + * thumbnail file already exists (idempotent). + * + * @param thumbnail thumbnail pixbuf to persist + * @param checksum content checksum for the filename + */ + private static void save_thumbnail_to_disk(Gdk.Pixbuf thumbnail, string checksum) + { + string thumb_path = get_thumbnail_path(checksum); + + // Skip if already saved (idempotent) + if (FileUtils.test(thumb_path, FileTest.EXISTS)) { + return; + } + + string thumb_dir = Path.get_dirname(thumb_path); + Utility.make_directory_with_parents(thumb_dir); + + try { + thumbnail.save(thumb_path, "png"); + } catch (GLib.Error e) { + warning("Failed to save thumbnail for %s: %s", checksum, e.message); + } } /** - * Create a thumbnail-sized scaled pixbuf that fits within the - * preview area while maintaining aspect ratio (contain fit). - * The thumbnail is sized at 3x the normal menu item height - * for clearly visible image previews. + * Get the filesystem path for a thumbnail PNG file. * - * @param pixbuf source pixbuf to scale - * @return scaled pixbuf preserving aspect ratio + * @param checksum content checksum + * @return absolute path to thumbnail file + */ + private static string get_thumbnail_path(string checksum) + { + return Path.build_filename( + Utility.get_user_data_dir(), "thumbnails", checksum + ".png"); + } + + /** + * Create a thumbnail-sized scaled pixbuf (contain-fit). + * Max 200x150, bilinear interpolation, never upscales. */ private static Gdk.Pixbuf create_scaled_pixbuf(Gdk.Pixbuf pixbuf) { - // Thumbnail size that fits comfortably in a GTK menu - // without clipping, even with multiple items visible int max_height = 150; int max_width = 200; int src_width = pixbuf.width; int src_height = pixbuf.height; - // Object-fit contain: scale to fill as much of the bounding - // box as possible while preserving the original aspect ratio double scale_x = (double) max_width / src_width; double scale_y = (double) max_height / src_height; double scale = double.min(scale_x, scale_y); @@ -411,10 +622,7 @@ namespace Diodon } /** - * Store pixbuf in tmp folder but only if it does not exist - * - * @param pixbuf pixbuf to be stored - * @return file object of stored pixbuf + * Store pixbuf in tmp folder (for icon generation). */ private File save_tmp_pixbuf(Gdk.Pixbuf pixbuf) throws GLib.Error { diff --git a/libdiodon/meson.build b/libdiodon/meson.build index d27155e..5242d65 100644 --- a/libdiodon/meson.build +++ b/libdiodon/meson.build @@ -26,6 +26,7 @@ libdiodon = shared_library('diodon', 'clipboard-type.vala', 'controller.vala', 'file-clipboard-item.vala', + 'image-cache.vala', 'image-clipboard-item.vala', 'preferences-view.vala', 'primary-clipboard-manager.vala', diff --git a/libdiodon/zeitgeist-clipboard-storage.vala b/libdiodon/zeitgeist-clipboard-storage.vala index 3cd389d..4b19d35 100644 --- a/libdiodon/zeitgeist-clipboard-storage.vala +++ b/libdiodon/zeitgeist-clipboard-storage.vala @@ -191,15 +191,10 @@ namespace Diodon { debug("Get item with given checksum %s", checksum); - // Fast path: if the pixbuf is already cached in memory, - // skip the entire Zeitgeist query + PNG decode. - // This makes paste from history nearly instant. - ImageClipboardItem? cached_item = ImageClipboardItem.from_cache( - checksum, null, new DateTime.now_utc()); - if (cached_item != null) { - debug("Cache hit for checksum %s, skipping Zeitgeist query", checksum); - return cached_item; - } + // Payload is loaded lazily: the Zeitgeist query returns the + // PNG payload which gets stored in the global LRU cache. + // No in-process cache shortcuts — always query Zeitgeist + // for correctness (it's fast, local DB). GenericArray templates = new GenericArray(); TimeRange time_range = new TimeRange.anytime(); @@ -537,7 +532,7 @@ namespace Diodon } } - private static IClipboardItem? create_clipboard_item(Event event, Subject subject) + private static IClipboardItem? create_clipboard_item(Event event, Subject subject, bool lightweight = false) { string interpretation = subject.interpretation; IClipboardItem item = null; @@ -556,7 +551,25 @@ namespace Diodon } else if(strcmp(NFO.IMAGE, interpretation) == 0) { - item = new ImageClipboardItem.with_payload(ClipboardType.NONE, payload, origin, date_copied); + // Extract checksum from Zeitgeist URI ("dav:") + string checksum = subject.uri.substring(CLIPBOARD_URI.length); + + if (lightweight) { + // Menu display path: load ONLY thumbnail from disk (~5 KB). + // Never touches the full PNG payload. Makes menu open instant. + item = new ImageClipboardItem.with_metadata( + ClipboardType.NONE, checksum, text, origin, date_copied); + } else if (payload != null) { + // Paste path: full decode with known checksum. + // Skips redundant SHA1 re-computation since checksum + // is already embedded in the Zeitgeist URI. + item = new ImageClipboardItem.with_known_payload( + ClipboardType.NONE, checksum, payload, origin, date_copied); + } else { + warning("Image item %s has no payload, using metadata fallback", checksum); + item = new ImageClipboardItem.with_metadata( + ClipboardType.NONE, checksum, text, origin, date_copied); + } } else { @@ -594,7 +607,9 @@ namespace Diodon foreach(Event event in events) { if (event.num_subjects() > 0) { Subject subject = event.get_subject(0); - IClipboardItem item = create_clipboard_item(event, subject); + // lightweight=true: for menu display, load only thumbnails + // from disk. Never decode full PNG payloads here. + IClipboardItem item = create_clipboard_item(event, subject, true); if(item != null) { items.append(item); } From ef58ce839522eb797d4a49d02de999a9ca0c8127 Mon Sep 17 00:00:00 2001 From: Sheikh Muneeb Ahmed Date: Sun, 8 Feb 2026 17:41:17 +0500 Subject: [PATCH 6/9] Update version to 2.0.0 and enhance README with new features and improvements --- README.md | 15 +++++++++++++-- meson.build | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1a34755..4db5de1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,17 @@ -# Diodon +# Diodon (Enhanced Edition) -Aiming to be the best integrated clipboard manager for the Unity desktop. +A high-performance GTK+ clipboard manager — forked from [diodon-dev/diodon](https://github.com/diodon-dev/diodon) with major image handling improvements. + +## What's New in This Version + +- **3× larger image thumbnails** — 200×150 previews with contain-fit scaling, centered in the menu +- **Instant 4K image paste** — lazy clipboard serving via `set_with_owner()` eliminates desktop freezes +- **90% less memory** — single-slot PNG cache (~10 MB) replaces unbounded pixbuf storage (was 200+ MB) +- **No CPU spikes** — cached PNG bytes served directly to apps requesting `image/png`, zero re-encoding +- **Correct paste-from-history** — each item retains its own PNG data, no more stale image bugs +- **Wider text labels** — 100-char labels with 4-line word wrapping + +See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the full technical architecture and lifecycle documentation. ## Installing diff --git a/meson.build b/meson.build index ffb1ba1..79b89fc 100644 --- a/meson.build +++ b/meson.build @@ -17,7 +17,7 @@ # Oliver Sauder project('diodon', ['vala', 'c'], - version: '1.13.0', + version: '2.0.0', license: 'GPLv2+', default_options: [ 'warning_level=1', From be3fc188a7b083de9b9f348a2512cc78e2f16b21 Mon Sep 17 00:00:00 2001 From: Sheikh Muneeb Ahmed Date: Sun, 8 Feb 2026 18:28:35 +0500 Subject: [PATCH 7/9] Enhance clipboard management by preventing zombie data and cleaning up thumbnails - Release clipboard ownership to avoid lingering pixbuf references after clearing history. - Implement debounce for speculative decoding to optimize performance during rapid item scrolling. - Add methods to delete individual and all thumbnail files to prevent orphaned data accumulation. --- libdiodon/clipboard-manager.vala | 19 ++++++-- libdiodon/clipboard-menu.vala | 28 +++++++++-- libdiodon/image-clipboard-item.vala | 56 +++++++++++++++++++--- libdiodon/zeitgeist-clipboard-storage.vala | 10 ++++ 4 files changed, 98 insertions(+), 15 deletions(-) diff --git a/libdiodon/clipboard-manager.vala b/libdiodon/clipboard-manager.vala index e90e267..446855a 100644 --- a/libdiodon/clipboard-manager.vala +++ b/libdiodon/clipboard-manager.vala @@ -119,14 +119,23 @@ namespace Diodon } /** - * Clear managed clipboard + * Clear managed clipboard. + * + * Releases Diodon's clipboard ownership first (triggering + * clipboard_clear_func which nulls _pixbuf), then sets empty + * text. This prevents the "Zombie Clipboard" bug where + * Ctrl+V after Clear History could still paste sensitive data + * from a lingering pixbuf reference. */ public void clear() { - // clearing only works when clipboard is called by a callback - // from clipboard itself. This is not the case here - // so therefore we just set an empty text to clear the clipboard - //clipboard.clear(); + // Release ownership — triggers clipboard_clear_func on the + // current owner (ImageClipboardItem), nulling its _pixbuf. + // This is safe to call even if Diodon doesn't own the clipboard. + _clipboard.clear(); + + // Set empty text so the clipboard isn't completely blank + // (some apps crash on truly empty selections). _clipboard.set_text("", -1); } diff --git a/libdiodon/clipboard-menu.vala b/libdiodon/clipboard-menu.vala index 5b3e6bd..cbe7fdb 100644 --- a/libdiodon/clipboard-menu.vala +++ b/libdiodon/clipboard-menu.vala @@ -29,6 +29,12 @@ namespace Diodon private Controller controller; private unowned List static_menu_items; + // Debounce source ID for speculative warm-up. + // Prevents the "Thundering Herd" when user holds Down Arrow + // and rapidly scrolls through 50 items — only the item they + // stop on (after 150ms of no movement) triggers a decode. + private uint _warmup_source_id = 0; + /** * Create clipboard menu * @@ -106,14 +112,22 @@ namespace Diodon ClipboardMenuItem menu_item = new ClipboardMenuItem(item); menu_item.activate.connect(on_clicked_item); - // Speculative decode: when user hovers an image item, - // pre-decode the full pixbuf from LRU cache in an idle callback. - // This eliminates decode latency when they click to paste. + // Speculative decode with debounce: when user hovers an + // image item, schedule warm-up after 150ms of no movement. + // This prevents the "Thundering Herd" — holding Down Arrow + // through 50 items won't spawn 50 decode tasks. Only the + // item the user stops on actually triggers a decode. if (menu_item.is_image_item()) { menu_item.select.connect(() => { + // Cancel any pending warm-up from a previous item + if (_warmup_source_id != 0) { + GLib.Source.remove(_warmup_source_id); + _warmup_source_id = 0; + } string cs = menu_item.get_item_checksum(); - GLib.Idle.add(() => { + _warmup_source_id = GLib.Timeout.add(150, () => { ImageCache.get_default().warm_pixbuf(cs); + _warmup_source_id = 0; return GLib.Source.REMOVE; }); }); @@ -143,6 +157,12 @@ namespace Diodon */ public void destroy_menu() { + // Cancel any pending warm-up before destroying + if (_warmup_source_id != 0) { + GLib.Source.remove(_warmup_source_id); + _warmup_source_id = 0; + } + foreach(Gtk.Widget item in get_children()) { remove(item); diff --git a/libdiodon/image-clipboard-item.vala b/libdiodon/image-clipboard-item.vala index 3a3bd3b..fd62241 100644 --- a/libdiodon/image-clipboard-item.vala +++ b/libdiodon/image-clipboard-item.vala @@ -484,12 +484,19 @@ namespace Diodon /** * Called by GTK when clipboard ownership is lost. + * + * Nulls out _pixbuf to prevent zombie data after Clear History. + * Without this, a Ctrl+V after Clear could still paste the + * sensitive image from the lingering pixbuf reference. */ private static void clipboard_clear_func( Gtk.Clipboard clipboard, void* user_data_or_owner) { - // Nothing to clean up — data lives in the global LRU cache + ImageClipboardItem self = (ImageClipboardItem) user_data_or_owner; + self._pixbuf = null; + // Also clear warm pixbuf in case it references this item + ImageCache.get_default().clear_warm_pixbuf(); } /** @@ -567,10 +574,10 @@ namespace Diodon { string thumb_path = get_thumbnail_path(checksum); - // Skip if already saved (idempotent) - if (FileUtils.test(thumb_path, FileTest.EXISTS)) { - return; - } + // Always overwrite — handles the Resurrection scenario where + // user deletes an image, then copies the exact same pixels again. + // The old thumbnail was deleted by remove_item(); we must + // regenerate it unconditionally to avoid a broken menu icon. string thumb_dir = Path.get_dirname(thumb_path); Utility.make_directory_with_parents(thumb_dir); @@ -588,12 +595,49 @@ namespace Diodon * @param checksum content checksum * @return absolute path to thumbnail file */ - private static string get_thumbnail_path(string checksum) + public static string get_thumbnail_path(string checksum) { return Path.build_filename( Utility.get_user_data_dir(), "thumbnails", checksum + ".png"); } + /** + * Delete the thumbnail file for a given checksum. + * Called when an item is removed from history to prevent + * orphaned thumbnails accumulating on disk. + * + * @param checksum content checksum of the item being removed + */ + public static void delete_thumbnail(string checksum) + { + string thumb_path = get_thumbnail_path(checksum); + if (FileUtils.test(thumb_path, FileTest.EXISTS)) { + FileUtils.unlink(thumb_path); + } + } + + /** + * Delete ALL thumbnail files from disk. + * Called when the entire clipboard history is cleared. + */ + public static void delete_all_thumbnails() + { + string thumb_dir = Path.build_filename( + Utility.get_user_data_dir(), "thumbnails"); + try { + Dir dir = Dir.open(thumb_dir); + string? name = null; + while ((name = dir.read_name()) != null) { + if (name.has_suffix(".png")) { + string path = Path.build_filename(thumb_dir, name); + FileUtils.unlink(path); + } + } + } catch (GLib.FileError e) { + debug("Could not clean thumbnails dir: %s", e.message); + } + } + /** * Create a thumbnail-sized scaled pixbuf (contain-fit). * Max 200x150, bilinear interpolation, never upscales. diff --git a/libdiodon/zeitgeist-clipboard-storage.vala b/libdiodon/zeitgeist-clipboard-storage.vala index 4b19d35..d0225e7 100644 --- a/libdiodon/zeitgeist-clipboard-storage.vala +++ b/libdiodon/zeitgeist-clipboard-storage.vala @@ -179,6 +179,12 @@ namespace Diodon warning("Remove item %s not successful, error: %s", item.get_checksum(), e.message); } + + // Clean up thumbnail file and LRU cache entry + if (item is ImageClipboardItem) { + ImageClipboardItem.delete_thumbnail(item.get_checksum()); + ImageCache.get_default().remove(item.get_checksum()); + } } /** @@ -452,6 +458,10 @@ namespace Diodon warning("Failed to clear items: %s", e.message); } + // Clean up all thumbnails and LRU cache + ImageClipboardItem.delete_all_thumbnails(); + ImageCache.get_default().clear(); + current_items.remove_all(); } From 42ed73fb91621dc857cd04fc7dff0f60b54cfd2c Mon Sep 17 00:00:00 2001 From: Sheikh Muneeb Ahmed Date: Sun, 8 Feb 2026 18:30:59 +0500 Subject: [PATCH 8/9] Implement clipboard suppression to prevent "Paste-to-Self" feedback loop and enhance thumbnail saving with atomic writes --- libdiodon/clipboard-manager.vala | 42 +++++++++++++++++++++++++++++ libdiodon/controller.vala | 13 +++++++++ libdiodon/image-clipboard-item.vala | 10 ++++++- 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/libdiodon/clipboard-manager.vala b/libdiodon/clipboard-manager.vala index 446855a..7af71a6 100644 --- a/libdiodon/clipboard-manager.vala +++ b/libdiodon/clipboard-manager.vala @@ -32,6 +32,12 @@ namespace Diodon protected Gtk.Clipboard _clipboard = null; protected ClipboardConfiguration _configuration; + // Refractory period: when true, check_clipboard() is suppressed. + // Engaged during execute_paste() to prevent the synthetic Ctrl+V + // keystroke from triggering a "Paste-to-Self" feedback loop. + private bool _suppressed = false; + private uint _suppress_source_id = 0; + /** * Called when text from the clipboard has been received * @@ -139,12 +145,48 @@ namespace Diodon _clipboard.set_text("", -1); } + /** + * Suppress clipboard monitoring for the given duration. + * + * Used as a "refractory period" during execute_paste() to + * prevent the synthetic Ctrl+V from triggering a feedback loop + * where the target app re-announces ownership and Diodon + * re-reads and re-adds the same item. + * + * @param duration_ms suppression duration in milliseconds + */ + public void suppress_for(uint duration_ms) + { + // Cancel any existing suppression timer + if (_suppress_source_id != 0) { + GLib.Source.remove(_suppress_source_id); + } + + _suppressed = true; + debug("Clipboard %d suppressed for %ums", type, duration_ms); + + _suppress_source_id = GLib.Timeout.add(duration_ms, () => { + _suppressed = false; + _suppress_source_id = 0; + debug("Clipboard %d suppression lifted", type); + return GLib.Source.REMOVE; + }); + } + /** * Request text from managed clipboard. If result is valid * on_text_received will be called. */ protected virtual void check_clipboard() { + // === Refractory Period === + // Suppressed during execute_paste() to prevent the synthetic + // keystroke from causing a "Paste-to-Self" feedback loop. + if (_suppressed) { + debug("Clipboard %d check suppressed (refractory period)", type); + return; + } + // === Ownership Check (self-loop detection) === // When Diodon sets the clipboard via set_with_owner(), // the owner_change signal fires back. Instead of reading diff --git a/libdiodon/controller.vala b/libdiodon/controller.vala index 5e7fec7..2f5f77d 100644 --- a/libdiodon/controller.vala +++ b/libdiodon/controller.vala @@ -269,6 +269,12 @@ namespace Diodon /** * Execute paste instantly according to set preferences. * + * Engages a 500ms refractory period on all clipboard managers + * BEFORE injecting the synthetic keystroke. This prevents the + * "Paste-to-Self" feedback loop where the target app receives + * the paste, re-announces clipboard ownership, and Diodon + * re-reads and re-adds the same content. + * * @param item item to be pasted */ public void execute_paste(IClipboardItem item) @@ -292,6 +298,13 @@ namespace Diodon } if(key != null) { + // Engage refractory period on all clipboard managers + // BEFORE the keystroke. 500ms is enough for the target + // app to process the paste and settle its ownership. + foreach(ClipboardManager manager in clipboard_managers.get_values()) { + manager.suppress_for(500); + } + debug("Execute paste with keybinding %s", key); Utility.perform_key_event(key, true, 100); Utility.perform_key_event(key, false, 0); diff --git a/libdiodon/image-clipboard-item.vala b/libdiodon/image-clipboard-item.vala index fd62241..4946141 100644 --- a/libdiodon/image-clipboard-item.vala +++ b/libdiodon/image-clipboard-item.vala @@ -582,10 +582,18 @@ namespace Diodon string thumb_dir = Path.get_dirname(thumb_path); Utility.make_directory_with_parents(thumb_dir); + // Atomic write: write to .tmp, then rename. + // rename() is atomic on POSIX — the file either exists + // fully or not at all. Prevents half-written thumbnails + // if the process is killed mid-write (SIGKILL, shutdown). + string tmp_path = thumb_path + ".tmp"; try { - thumbnail.save(thumb_path, "png"); + thumbnail.save(tmp_path, "png"); + FileUtils.rename(tmp_path, thumb_path); } catch (GLib.Error e) { warning("Failed to save thumbnail for %s: %s", checksum, e.message); + // Clean up partial temp file + FileUtils.unlink(tmp_path); } } From 5679341cf4bb1313f2a59f17ebadbedbf8f745f2 Mon Sep 17 00:00:00 2001 From: Sheikh Muneeb Ahmed Date: Wed, 11 Feb 2026 22:25:46 +0500 Subject: [PATCH 9/9] Implement orphaned thumbnail cleanup on startup and during event deletions added delete orphan method to make ensure that orphan thumbnails are deleted after an expiry of 24 hours --- libdiodon/controller.vala | 88 ++++++++++++++++++++++ libdiodon/image-clipboard-item.vala | 36 +++++++++ libdiodon/zeitgeist-clipboard-storage.vala | 60 ++++++++++++++- 3 files changed, 183 insertions(+), 1 deletion(-) diff --git a/libdiodon/controller.vala b/libdiodon/controller.vala index 2f5f77d..cd7490b 100644 --- a/libdiodon/controller.vala +++ b/libdiodon/controller.vala @@ -133,6 +133,11 @@ namespace Diodon // make sure that recent menu gets rebuild when recent history changes yield rebuild_recent_menu(); + // Deferred maintenance: wait 60 s after startup to let the + // boot storm settle, then purge orphaned thumbnails at most + // once every 24 h. See schedule_background_maintenance(). + schedule_background_maintenance(); + storage.on_items_deleted.connect(() => { rebuild_recent_menu.begin(); } ); storage.on_items_inserted.connect(() => { rebuild_recent_menu.begin(); } ); @@ -673,6 +678,89 @@ namespace Diodon yield rebuild_recent_menu(); } + /* ── Lazy-Janitor maintenance scheduler ─────────────── */ + + private const uint MAINTENANCE_DELAY_S = 60; // seconds after boot + private const int64 MAINTENANCE_INTERVAL = 24 * 3600; // 24 h in seconds + + /** + * Returns the path to the maintenance stamp file: + * ~/.local/share/diodon/maintenance.stamp + */ + private static string get_stamp_path() + { + return Path.build_filename( + Utility.get_user_data_dir(), "maintenance.stamp"); + } + + /** + * Schedule a one-shot, low-priority background maintenance task. + * + * 1. Wait MAINTENANCE_DELAY_S seconds (60 s) so the boot storm + * has settled and we are not competing for disk I/O. + * 2. Check the mtime of the stamp file; if the last run was + * less than 24 h ago, abort. + * 3. Spawn a dedicated thread to run the purge so the main + * loop is never blocked. + * 4. Touch the stamp file on success to reset the 24 h timer. + */ + private void schedule_background_maintenance() + { + GLib.Timeout.add_seconds(MAINTENANCE_DELAY_S, () => { + debug("Maintenance timer fired – checking stamp"); + + string stamp = get_stamp_path(); + int64 now = GLib.get_real_time() / 1000000; // µs → s + + // ── 24-hour gate ────────────────────────────── + try { + var info = File.new_for_path(stamp) + .query_info(FileAttribute.TIME_MODIFIED, + FileQueryInfoFlags.NONE); + int64 mtime = (int64) info.get_modification_date_time() + .to_unix(); + if ((now - mtime) < MAINTENANCE_INTERVAL) { + debug("Maintenance stamp is fresh (age %" + int64.FORMAT + + " s < %" + int64.FORMAT + " s), skipping", + now - mtime, MAINTENANCE_INTERVAL); + return Source.REMOVE; + } + } catch (GLib.Error e) { + // File missing or unreadable → first run, proceed. + debug("No valid stamp file (%s), proceeding", e.message); + } + + // ── Spawn background thread ────────────────── + new Thread("maintenance", () => { + debug("Maintenance thread started"); + + // Drive the async purge with a private MainLoop + // so this thread does not block the UI. + var loop = new MainLoop(null, false); + storage.purge_orphaned_thumbnails.begin(null, () => { + loop.quit(); + }); + loop.run(); + + // Touch the stamp file to reset the 24 h window. + try { + string dir = Path.get_dirname(stamp); + DirUtils.create_with_parents(dir, 0755); + FileUtils.set_contents(stamp, + new DateTime.now_utc().to_string()); + debug("Maintenance stamp updated: %s", stamp); + } catch (GLib.Error e) { + warning("Could not update maintenance stamp: %s", + e.message); + } + + debug("Maintenance thread finished"); + }); + + return Source.REMOVE; // one-shot timer + }); + } + /** * Quit diodon */ diff --git a/libdiodon/image-clipboard-item.vala b/libdiodon/image-clipboard-item.vala index 4946141..fc6716b 100644 --- a/libdiodon/image-clipboard-item.vala +++ b/libdiodon/image-clipboard-item.vala @@ -646,6 +646,42 @@ namespace Diodon } } + /** + * Remove orphaned thumbnails from disk. + * + * Scans the thumbnails directory and deletes any file whose + * checksum is not in the given live set. Called at daemon + * startup and when Zeitgeist fires events_deleted so that + * thumbnails belonging to expired or externally-purged events + * don't accumulate forever. + * + * @param live_checksums set of checksums that still have a + * corresponding Zeitgeist event (i.e., are still in history) + */ + public static void cleanup_orphaned_thumbnails(GenericSet live_checksums) + { + string thumb_dir = Path.build_filename( + Utility.get_user_data_dir(), "thumbnails"); + try { + Dir dir = Dir.open(thumb_dir); + string? name = null; + while ((name = dir.read_name()) != null) { + if (!name.has_suffix(".png")) continue; + + // Extract checksum from filename: ".png" + string checksum = name.substring(0, name.length - 4); + if (!live_checksums.contains(checksum)) { + string path = Path.build_filename(thumb_dir, name); + debug("Removing orphaned thumbnail: %s", name); + FileUtils.unlink(path); + ImageCache.get_default().remove(checksum); + } + } + } catch (GLib.FileError e) { + debug("Could not scan thumbnails dir for orphans: %s", e.message); + } + } + /** * Create a thumbnail-sized scaled pixbuf (contain-fit). * Max 200x150, bilinear interpolation, never upscales. diff --git a/libdiodon/zeitgeist-clipboard-storage.vala b/libdiodon/zeitgeist-clipboard-storage.vala index d0225e7..32d48eb 100644 --- a/libdiodon/zeitgeist-clipboard-storage.vala +++ b/libdiodon/zeitgeist-clipboard-storage.vala @@ -93,7 +93,10 @@ namespace Diodon this.monitor = new Monitor(new TimeRange.from_now(), get_items_event_templates()); this.monitor.events_inserted.connect(() => { on_items_inserted(); } ); - this.monitor.events_deleted.connect(() => { on_items_deleted(); } ); + this.monitor.events_deleted.connect(() => { + on_items_deleted(); + purge_orphaned_thumbnails.begin(); + }); this.log = Zeitgeist.Log.get_default(); @@ -465,6 +468,61 @@ namespace Diodon current_items.remove_all(); } + /** + * Remove thumbnail files that no longer have a corresponding + * Zeitgeist event. Called at startup and when Zeitgeist's + * events_deleted monitor fires (e.g., auto-expiry, privacy + * purge, external deletion via zeitgeist-daemon). + * + * Queries Zeitgeist for all live image event URIs, extracts + * the checksum set, then removes any thumbnail on disk whose + * checksum is not in that set. + */ + public async void purge_orphaned_thumbnails(Cancellable? cancellable = null) + { + try { + // Query for all live image events + GenericArray img_templates = new GenericArray(); + img_templates.add(new Event.full( + ZG.CREATE_EVENT, ZG.USER_ACTIVITY, + null, + "application://diodon.desktop", + new Subject.full( + CLIPBOARD_URI + "*", + NFO.IMAGE, + NFO.DATA_CONTAINER, + null, null, null, null))); + + ResultSet events = yield log.find_events( + new TimeRange.anytime(), + img_templates, + StorageState.ANY, + 1000, // generous upper bound + ResultType.MOST_RECENT_SUBJECTS, + cancellable); + + // Collect live checksums from URIs ("dav:") + var live = new GenericSet(str_hash, str_equal); + foreach (Event ev in events) { + if (ev.num_subjects() > 0) { + Subject subj = ev.get_subject(0); + string uri = subj.uri; + if (uri != null && uri.has_prefix(CLIPBOARD_URI)) { + live.add(uri.substring(CLIPBOARD_URI.length)); + } + } + } + + ImageClipboardItem.cleanup_orphaned_thumbnails(live); + debug("Orphaned thumbnail purge complete, %u live images", + live.length); + } catch (IOError.CANCELLED ioe) { + debug("Orphaned thumbnail purge cancelled: %s", ioe.message); + } catch (GLib.Error e) { + warning("Orphaned thumbnail purge failed: %s", e.message); + } + } + private static void prepare_category_templates(HashTable templates) { // match all