summaryrefslogtreecommitdiff
path: root/src/thumbnail.c
diff options
context:
space:
mode:
Diffstat (limited to 'src/thumbnail.c')
-rw-r--r--src/thumbnail.c241
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