diff options
Diffstat (limited to 'src/thumbnail.c')
| -rw-r--r-- | src/thumbnail.c | 241 |
1 files changed, 241 insertions, 0 deletions
diff --git a/src/thumbnail.c b/src/thumbnail.c new file mode 100644 index 0000000..67c1c89 --- /dev/null +++ b/src/thumbnail.c @@ -0,0 +1,241 @@ +/*:* + * ggaze — thumbnail cache + * + * freedesktop TMS: ~/.cache/thumbnails/{normal(128), large(256)} + a custom + * x-large bucket (512) for >256 (decision #37/T). Caches PNG keyed by md5(URI), + * stores Thumb::URI/MTime/Size, verifies mtime. Async decode via GTask. + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include "thumbnail.h" + +#include <gdk-pixbuf/gdk-pixbuf.h> +#include <gdk/gdk.h> +#include <gio/gio.h> +#include <glib.h> + +#define GGAZE_TMS_NORMAL 128 +#define GGAZE_TMS_LARGE 256 +#define GGAZE_TMS_XLARGE 512 + +struct Thumbnail { + int i_unused; +}; + +typedef struct { + Thumbnail *p_t; + GFile *p_file; /* owned */ + int i_size; /* requested size */ + int i_bucket; /* bucket size actually cached */ + char *c_cache_path; /* owned */ +} ThumbTask; + +/* --- helpers ------------------------------------------------------------- */ + +static int +_bucket_for(int i_size) { + if (i_size <= GGAZE_TMS_NORMAL) { + return (GGAZE_TMS_NORMAL); + } + if (i_size <= GGAZE_TMS_LARGE) { + return (GGAZE_TMS_LARGE); + } + return (GGAZE_TMS_XLARGE); +} + +static char * +_cache_dir_for_bucket(int i_bucket) { + const char *c_sub = (i_bucket <= GGAZE_TMS_NORMAL) ? "normal" + : (i_bucket <= GGAZE_TMS_LARGE) ? "large" + : "x-large"; + const char *c_cache = g_get_user_cache_dir(); + char *c_dir = g_build_filename(c_cache, "thumbnails", c_sub, NULL); + return (c_dir); +} + +static char * +_cache_path(Thumbnail *p_t, GFile *p_file, int i_bucket) { + (void)p_t; + char *c_uri = g_file_get_uri(p_file); + GChecksum *p_sum = g_checksum_new(G_CHECKSUM_MD5); + g_checksum_update(p_sum, (const guchar *)c_uri, strlen(c_uri)); + const char *c_hex = g_checksum_get_string(p_sum); + char *c_dir = _cache_dir_for_bucket(i_bucket); + char *c_path = g_strdup_printf("%s/%s.png", c_dir, c_hex); + g_free(c_dir); + g_checksum_free(p_sum); + g_free(c_uri); + return (c_path); +} + +static GdkTexture * +_texture_from_pixbuf(GdkPixbuf *p_pix) { + g_return_val_if_fail(GDK_IS_PIXBUF(p_pix), NULL); + int i_w = gdk_pixbuf_get_width(p_pix); + int i_h = gdk_pixbuf_get_height(p_pix); + GdkPixbuf *p_rgba = gdk_pixbuf_get_has_alpha(p_pix) + ? GDK_PIXBUF(g_object_ref(p_pix)) + : gdk_pixbuf_add_alpha(p_pix, FALSE, 0, 0, 0); + if (p_rgba == NULL) { + return (NULL); + } + int i_rowstride = gdk_pixbuf_get_rowstride(p_rgba); + guchar *p_pixels = gdk_pixbuf_get_pixels(p_rgba); + gsize u_len = (gsize)(i_h - 1) * (gsize)i_rowstride + (gsize)i_w * 4u; + GBytes *p_bytes = g_bytes_new_with_free_func( + p_pixels, u_len, (GDestroyNotify)g_object_unref, p_rgba); + GdkTexture *p_tex = gdk_memory_texture_new(i_w, i_h, GDK_MEMORY_R8G8B8A8, + p_bytes, (gsize)i_rowstride); + g_bytes_unref(p_bytes); + return (p_tex); +} + +/* Load a cached PNG into a texture, verifying Thumb::MTime == i_mtime. */ +static GdkTexture * +_load_cached(const char *c_path, gint64 i_mtime) { + GError *p_err = NULL; + GdkPixbuf *p_pix = gdk_pixbuf_new_from_file(c_path, &p_err); + if (p_pix == NULL) { + g_clear_error(&p_err); + return (NULL); + } + const char *c_m = gdk_pixbuf_get_option(p_pix, "Thumb::MTime"); + if (c_m == NULL || g_ascii_strtoll(c_m, NULL, 10) != i_mtime) { + g_object_unref(p_pix); + return (NULL); /* stale or missing mtime */ + } + GdkTexture *p_tex = _texture_from_pixbuf(p_pix); + g_object_unref(p_pix); + return (p_tex); +} + +/* Decode the image at <= i_bucket px (preserving aspect), apply EXIF + * orientation, and write a TMS PNG to c_cache_path. Returns the texture. */ +static GdkTexture * +_generate(GFile *p_file, int i_bucket, const char *c_cache_path, gint64 i_mtime, + gint64 i_size, GError **p_err) { + char *c_path = g_file_get_path(p_file); + if (c_path == NULL) { + g_set_error(p_err, G_IO_ERROR, G_IO_ERROR_FAILED, + "cannot thumbnail a non-local file"); + return (NULL); + } + GdkPixbuf *p_pix = gdk_pixbuf_new_from_file_at_scale(c_path, i_bucket, + i_bucket, TRUE, p_err); + g_free(c_path); + if (p_pix == NULL) { + return (NULL); + } + GdkPixbuf *p_oriented = gdk_pixbuf_apply_embedded_orientation(p_pix); + GdkPixbuf *p_use = + (p_oriented != NULL) ? p_oriented : GDK_PIXBUF(g_object_ref(p_pix)); + g_object_unref(p_pix); + + /* Best-effort write to the cache; failure to write is non-fatal. */ + char c_mtime[32]; + char c_size[32]; + g_snprintf(c_mtime, sizeof(c_mtime), "%" G_GINT64_FORMAT, i_mtime); + g_snprintf(c_size, sizeof(c_size), "%" G_GINT64_FORMAT, i_size); + char *c_uri = g_file_get_uri(p_file); + GError *p_werr = NULL; + gdk_pixbuf_save(p_use, c_cache_path, "png", &p_werr, "tEXt::Thumb::URI", + c_uri, "tEXt::Thumb::MTime", c_mtime, "tEXt::Thumb::Size", + c_size, NULL); + if (p_werr != NULL) { + g_error_free(p_werr); + } + g_free(c_uri); + + GdkTexture *p_tex = _texture_from_pixbuf(p_use); + g_object_unref(p_use); + return (p_tex); +} + +/* --- GTask worker -------------------------------------------------------- */ + +static void +_thumb_task_free(gpointer p_void) { + ThumbTask *p_tt = (ThumbTask *)p_void; + g_clear_object(&p_tt->p_file); + g_free(p_tt->c_cache_path); + g_free(p_tt); +} + +static void +_thumb_task_thread(GTask *p_task, gpointer p_src, gpointer p_task_data, + GCancellable *p_cancel) { + (void)p_src; + (void)p_cancel; + ThumbTask *p_tt = (ThumbTask *)p_task_data; + GError *p_err = NULL; + + /* File mtime + size (for verify + Thumb::Size). */ + GFileInfo *p_info = + g_file_query_info(p_tt->p_file, "standard::size,time::modified", + G_FILE_QUERY_INFO_NONE, NULL, &p_err); + if (p_info == NULL) { + g_task_return_error(p_task, p_err); + return; + } + gint64 i_mtime = (gint64)g_file_info_get_attribute_uint64( + p_info, G_FILE_ATTRIBUTE_TIME_MODIFIED); + gint64 i_size = (gint64)g_file_info_get_size(p_info); + g_object_unref(p_info); + + /* Ensure the cache dir exists (best-effort). */ + char *c_dir = g_path_get_dirname(p_tt->c_cache_path); + g_mkdir_with_parents(c_dir, 0700); + g_free(c_dir); + + GdkTexture *p_tex = _load_cached(p_tt->c_cache_path, i_mtime); + if (p_tex == NULL) { + p_tex = _generate(p_tt->p_file, p_tt->i_bucket, p_tt->c_cache_path, + i_mtime, i_size, &p_err); + } + if (p_tex == NULL) { + g_task_return_error(p_task, p_err); + } else { + g_task_return_pointer(p_task, p_tex, (GDestroyNotify)g_object_unref); + } +} + +/* --- public ------------------------------------------------------------- */ + +Thumbnail * +thumbnail_new(void) { + Thumbnail *p_t = g_new(Thumbnail, 1); + return (p_t); +} + +void +thumbnail_delete(Thumbnail *p_t) { + g_free(p_t); +} + +void +thumbnail_get_async(Thumbnail *p_t, GFile *p_file, int i_size, + GCancellable *p_cancel, GAsyncReadyCallback p_cb, + gpointer p_data) { + g_return_if_fail(p_t != NULL); + g_return_if_fail(G_IS_FILE(p_file)); + int i_bucket = _bucket_for(i_size); + ThumbTask *p_tt = g_new(ThumbTask, 1); + p_tt->p_t = p_t; + p_tt->p_file = (GFile *)g_object_ref(p_file); + p_tt->i_size = i_size; + p_tt->i_bucket = i_bucket; + p_tt->c_cache_path = _cache_path(p_t, p_file, i_bucket); + GTask *p_task = g_task_new(p_file, p_cancel, p_cb, p_data); + g_task_set_task_data(p_task, p_tt, _thumb_task_free); + g_task_run_in_thread(p_task, _thumb_task_thread); + g_object_unref(p_task); +} + +GdkTexture * +thumbnail_get_finish(Thumbnail *p_t, GAsyncResult *p_res, GError **p_err) { + (void)p_t; + g_return_val_if_fail(G_IS_TASK(p_res), NULL); + return ((GdkTexture *)g_task_propagate_pointer((GTask *)p_res, p_err)); +}
\ No newline at end of file |
