diff options
| author | Paul Buetow <paul@buetow.org> | 2026-07-13 20:57:16 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-07-13 20:57:16 +0300 |
| commit | 0795d3cf926d0d937f25fcc699f5f674bf618c72 (patch) | |
| tree | 48e3fcf44a7c5213c11c63c1f69eb8b336edb325 /src/thumbnail.c | |
| parent | 48aa1b3cdbf27be2e016e8b532687226f9249043 (diff) | |
gridcull: thumbnail TMS cache, grid view, trash, window d/D/u/t (jt0)
M7 (task jt0): thumbnail cache + grid view + trash.
- src/trash.{c,h}: ./Trash bin (lazy create, collision suffix -1/-2, restore_last
for u undo, permanent delete for D). Plain-C, unit-tested.
- src/thumbnail.{c,h}: freedesktop TMS cache (normal/large/x-large buckets,
md5(URI) key, Thumb::URI/MTime/Size, mtime verify). Async GTask worker with
GCancellable so the grid can cancel pending requests on dispose.
- src/gridview.{c,h}: GgazeGrid (GtkWidget wrapping GtkFlowBox): one cell per
navigator file, lazy thumbnail decode on realize, +/- resize 64-512, dim
removed cells, mark badges, Enter/double-click → 'activate' signal, cursor
sync. ggaze_grid_detach() breaks the nav ref before dispose.
- src/navigator.{c,h}: removed set (mark_removed/is_removed/unmark_removed);
_relist preserves removed items (stay dimmed even if absent from disk);
get_remaining = count - removed_count.
- src/window.c: d (trash+mark_removed+advance), D (permanent delete, confirm
dialog if >1 marked), u (restore_last+rescan), t (toggle grid/large),
+/- (viewer zoom in large / grid resize). Header counter uses remaining.
- src/shortcuts.c: d/D/u/t/+/= bindings added.
- tests: unit test_trash + test_thumbnail, integration test_grid_cull
(grid count + trash/undo + ./sample-images 613-cell grid). 13/13 green,
ASan clean.
Fixed: grid dispose UAF (detach before nav freed), g_file_get_basename leak,
test p_plain lifetime, thumbnail async-cancellable for clean dispose, test
grid-toggle avoiding async-thumbnail drain issues.
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 |
