diff options
| -rw-r--r-- | meson.build | 1 | ||||
| -rw-r--r-- | src/loader/loader.c | 30 | ||||
| -rw-r--r-- | src/loader/loader.h | 13 | ||||
| -rw-r--r-- | src/shortcuts.c | 2 | ||||
| -rw-r--r-- | src/texturecache.c | 117 | ||||
| -rw-r--r-- | src/texturecache.h | 40 | ||||
| -rw-r--r-- | src/window.c | 143 | ||||
| -rw-r--r-- | tests/meson.build | 22 | ||||
| -rw-r--r-- | tests/test_open_and_show.c | 44 | ||||
| -rw-r--r-- | tests/test_responsive_nav.c | 157 | ||||
| -rw-r--r-- | tests/test_texturecache.c | 108 | ||||
| -rw-r--r-- | tests/test_walk_folder.c | 38 |
12 files changed, 676 insertions, 39 deletions
diff --git a/meson.build b/meson.build index 6fcd4cc..f27b935 100644 --- a/meson.build +++ b/meson.build @@ -59,6 +59,7 @@ ggaze_lib = static_library('ggaze', 'src/viewer.c', 'src/navigator.c', 'src/shortcuts.c', + 'src/texturecache.c', 'src/loader/loader.c', 'src/loader/detect.c', 'src/loader/backends/pixbuf.c', diff --git a/src/loader/loader.c b/src/loader/loader.c index b1c4851..2121534 100644 --- a/src/loader/loader.c +++ b/src/loader/loader.c @@ -64,4 +64,34 @@ loader_load(GFile *p_file, GCancellable *p_cancel, GError **p_err) { g_set_error(p_err, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED, "unsupported or unrecognized image format"); return (NULL); +} + +/* --- async wrapper (M3) -------------------------------------------------- */ + +static void +_load_task_thread(GTask *p_task, gpointer p_src, gpointer p_task_data, + GCancellable *p_cancel) { + (void)p_task_data; + GError *p_err = NULL; + GdkTexture *p_tex = loader_load((GFile *)p_src, p_cancel, &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); + } +} + +void +loader_load_async(GFile *p_file, GCancellable *p_cancel, + GAsyncReadyCallback p_cb, gpointer p_data) { + g_return_if_fail(G_IS_FILE(p_file)); + GTask *p_task = g_task_new(p_file, p_cancel, p_cb, p_data); + g_task_run_in_thread(p_task, _load_task_thread); + g_object_unref(p_task); +} + +GdkTexture * +loader_load_finish(GAsyncResult *p_res, GError **p_err) { + 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 diff --git a/src/loader/loader.h b/src/loader/loader.h index 5b5c5e6..bfee29d 100644 --- a/src/loader/loader.h +++ b/src/loader/loader.h @@ -36,9 +36,20 @@ typedef struct { extern const GgazeLoaderBackend pixbuf_backend; /* Synchronously load p_file into a GdkTexture (EXIF orientation applied). - * Returns a new GdkTexture (caller owns it) or NULL with p_err set. */ + * Returns a new GdkTexture (caller owns it) or NULL with p_err set. Used by + * tests and the clipboard helpers; the window uses the async variant below. */ GdkTexture *loader_load(GFile *p_file, GCancellable *p_cancel, GError **p_err); +/* Asynchronous load: runs the sync worker in a GTask thread, returns the + * GdkTexture via p_cb on the main thread. The source object of the task is + * p_file, so the finish callback can check it against navigator.current + * (last-write-wins). */ +void loader_load_async(GFile *p_file, GCancellable *p_cancel, + GAsyncReadyCallback p_cb, gpointer p_data); + +/* Finish an async load; returns the GdkTexture (transfer full) or NULL. */ +GdkTexture *loader_load_finish(GAsyncResult *p_res, GError **p_err); + G_END_DECLS #endif /* GGAZE_LOADER_H */
\ No newline at end of file diff --git a/src/shortcuts.c b/src/shortcuts.c index 958af22..d946d30 100644 --- a/src/shortcuts.c +++ b/src/shortcuts.c @@ -43,7 +43,7 @@ shortcuts_install(GtkWidget *p_widget) { SHORTCUTS[u_i].u_keyval, SHORTCUTS[u_i].e_mods)), gtk_named_action_new(SHORTCUTS[u_i].c_action)); gtk_shortcut_controller_add_shortcut(GTK_SHORTCUT_CONTROLLER(p_ctrl), - p_s); + p_s); } gtk_widget_add_controller(p_widget, GTK_EVENT_CONTROLLER(p_ctrl)); }
\ No newline at end of file diff --git a/src/texturecache.c b/src/texturecache.c new file mode 100644 index 0000000..f92f0c4 --- /dev/null +++ b/src/texturecache.c @@ -0,0 +1,117 @@ +/*:* + * ggaze — decoded-texture cache + * + * Bounded LRU (GFile -> GdkTexture) with O(1) get/put via a hash mapping keys + * to GQueue nodes. Main-thread only. + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include "texturecache.h" + +typedef struct { + GFile *file; /* owned ref */ + GdkTexture *tex; /* owned ref */ + GList *link; /* node in the order queue (MRU at tail) */ +} CacheEntry; + +struct TextureCache { + guint u_cap; + GHashTable *p_map; /* GFile* (owned) -> CacheEntry* (owned) */ + GQueue *p_order; /* CacheEntry* MRU at tail */ +}; + +static void +_entry_free(gpointer p_void) { + CacheEntry *p_e = (CacheEntry *)p_void; + g_clear_object(&p_e->file); + g_clear_object(&p_e->tex); + g_free(p_e); +} + +TextureCache * +texturecache_new(guint u_cap) { + TextureCache *p_c = g_new(TextureCache, 1); + p_c->u_cap = (u_cap == 0) ? 1 : u_cap; + p_c->p_map = + g_hash_table_new_full((GHashFunc)g_file_hash, (GEqualFunc)g_file_equal, + NULL, _entry_free); /* entry owns the key */ + p_c->p_order = g_queue_new(); + return (p_c); +} + +void +texturecache_delete(TextureCache *p_cache) { + if (p_cache == NULL) { + return; + } + /* Clearing the hash frees entries (which are not in the queue order list as + * separate refs — the queue holds the same pointers, so free the queue list + * itself without touching the data). */ + g_queue_free(p_cache->p_order); + g_hash_table_unref(p_cache->p_map); + g_free(p_cache); +} + +GdkTexture * +texturecache_get(TextureCache *p_cache, GFile *p_file) { + g_return_val_if_fail(p_cache != NULL, NULL); + CacheEntry *p_e = (CacheEntry *)g_hash_table_lookup(p_cache->p_map, p_file); + if (p_e == NULL) { + return (NULL); + } + /* Mark most-recently-used: move to tail. */ + g_queue_unlink(p_cache->p_order, p_e->link); + g_queue_push_tail_link(p_cache->p_order, p_e->link); + return (p_e->tex); +} + +void +texturecache_put(TextureCache *p_cache, GFile *p_file, GdkTexture *p_tex) { + g_return_if_fail(p_cache != NULL); + g_return_if_fail(G_IS_FILE(p_file)); + g_return_if_fail(GDK_IS_TEXTURE(p_tex)); + + CacheEntry *p_e = (CacheEntry *)g_hash_table_lookup(p_cache->p_map, p_file); + if (p_e != NULL) { + /* Replace the texture; keep MRU position fresh. */ + g_set_object(&p_e->tex, p_tex); + g_queue_unlink(p_cache->p_order, p_e->link); + g_queue_push_tail_link(p_cache->p_order, p_e->link); + return; + } + + p_e = g_new(CacheEntry, 1); + p_e->file = (GFile *)g_object_ref(p_file); + p_e->tex = (GdkTexture *)g_object_ref(p_tex); + p_e->link = g_list_alloc(); + p_e->link->data = p_e; + g_queue_push_tail_link(p_cache->p_order, p_e->link); + g_hash_table_insert(p_cache->p_map, p_e->file, p_e); + + /* Evict LRU (head) while over capacity. */ + while (g_queue_get_length(p_cache->p_order) > p_cache->u_cap) { + GList *p_head = g_queue_pop_head_link(p_cache->p_order); + CacheEntry *p_old = (CacheEntry *)p_head->data; + /* Removing from the hash frees the entry (and its file/tex). The link is + * freed by g_list_free below. */ + g_hash_table_remove(p_cache->p_map, p_old->file); + g_list_free(p_head); + } +} + +guint +texturecache_get_size(TextureCache *p_cache) { + g_return_val_if_fail(p_cache != NULL, 0); + return ((guint)g_queue_get_length(p_cache->p_order)); +} + +void +texturecache_clear(TextureCache *p_cache) { + g_return_if_fail(p_cache != NULL); + /* Removing all hash entries frees the CacheEntry structs; the queue list + * nodes are cleared without freeing the data again. */ + g_queue_clear(p_cache->p_order); + g_hash_table_remove_all(p_cache->p_map); +}
\ No newline at end of file diff --git a/src/texturecache.h b/src/texturecache.h new file mode 100644 index 0000000..b92d2fb --- /dev/null +++ b/src/texturecache.h @@ -0,0 +1,40 @@ +#ifndef GGAZE_TEXTURECACHE_H +#define GGAZE_TEXTURECACHE_H + +/*:* + * ggaze — decoded-texture cache + * + * A bounded LRU of (GFile -> GdkTexture) used to make flipping between images + * feel instant and to cap memory on large folders/huge images. Main-thread + * only: prefetch loads complete on the main thread and put here; the viewer + * reads from here. See docs/architecture.md "Concurrency model" + "Prefetch". + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include <gdk/gdk.h> +#include <gio/gio.h> +#include <glib.h> + +G_BEGIN_DECLS + +typedef struct TextureCache TextureCache; + +TextureCache *texturecache_new(guint u_cap); +void texturecache_delete(TextureCache *p_cache); + +/* Look up p_file; returns its GdkTexture (transfer none) or NULL, and marks it + * most-recently-used. */ +GdkTexture *texturecache_get(TextureCache *p_cache, GFile *p_file); + +/* Store p_tex for p_file (refs both); evicts the least-recently-used entry if + * the cache is over capacity. Replaces an existing entry. */ +void texturecache_put(TextureCache *p_cache, GFile *p_file, GdkTexture *p_tex); + +guint texturecache_get_size(TextureCache *p_cache); +void texturecache_clear(TextureCache *p_cache); + +G_END_DECLS + +#endif /* GGAZE_TEXTURECACHE_H */
\ No newline at end of file diff --git a/src/window.c b/src/window.c index 7ce7b38..7b860f3 100644 --- a/src/window.c +++ b/src/window.c @@ -20,12 +20,15 @@ #include "loader/loader.h" #include "navigator.h" #include "shortcuts.h" +#include "texturecache.h" #include "viewer.h" struct _GgazeWindow { GtkApplicationWindow parent_instance; - Navigator *p_nav; /* current folder listing (NULL until open) */ - GCancellable *p_cancel; /* single in-flight load; cancelled on each nav */ + Navigator *p_nav; /* current folder listing (NULL until open) */ + GCancellable *p_cancel; /* visible load; cancelled on each nav */ + GCancellable *p_prefetch_cancel; /* prefetch round; cancelled on new round */ + TextureCache *p_cache; /* bounded LRU of decoded GdkTextures */ GtkWidget *p_stack; /* GtkStack: grid (placeholder) / large (viewer) */ GtkWidget *p_viewer; /* GgazeViewer — the large view */ }; @@ -34,6 +37,7 @@ G_DEFINE_TYPE(GgazeWindow, ggaze_window, GTK_TYPE_APPLICATION_WINDOW) /* --- forward decls ------------------------------------------------------- */ static void _load_current(GgazeWindow *p_win); +static void _prefetch(GgazeWindow *p_win); static void _update_header(GgazeWindow *p_win); /* --- actions ------------------------------------------------------------- */ @@ -145,16 +149,94 @@ nav_changed_cb(Navigator *p_nav, gpointer p_data) { /* --- load current into the viewer ---------------------------------------- */ static void -_load_current(GgazeWindow *p_win) { +_show_texture(GgazeWindow *p_win, GdkTexture *p_tex) { + ggaze_viewer_set_texture(GGAZE_VIEWER(p_win->p_viewer), p_tex); + gtk_stack_set_visible_child_name(GTK_STACK(p_win->p_stack), "large"); +} + +/* Prefetch callback: just cache the result (never touches the viewer). p_data + * is a ref on the window (released here) so the window outlives the load. */ +static void +_prefetch_finish_cb(GObject *p_src, GAsyncResult *p_res, gpointer p_data) { + (void)p_src; + GgazeWindow *p_win = GGAZE_WINDOW(p_data); + GError *p_err = NULL; + GdkTexture *p_tex = loader_load_finish(p_res, &p_err); + if (p_tex != NULL) { + GFile *p_file = (GFile *)g_task_get_source_object((GTask *)p_res); + texturecache_put(p_win->p_cache, p_file, p_tex); + g_object_unref(p_tex); + } else { + g_clear_error(&p_err); + } + g_object_unref(p_win); +} + +/* Visible-load callback: show only if this is still the current file + * (last-write-wins), then cache it and prefetch neighbours. */ +static void +_load_finish_cb(GObject *p_src, GAsyncResult *p_res, gpointer p_data) { + (void)p_src; + GgazeWindow *p_win = GGAZE_WINDOW(p_data); + GError *p_err = NULL; + GdkTexture *p_tex = loader_load_finish(p_res, &p_err); + if (p_tex == NULL) { + if (p_err != NULL && + !g_error_matches(p_err, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + char *c_name = g_file_get_basename( + (GFile *)g_task_get_source_object((GTask *)p_res)); + g_warning("ggaze: failed to load %s: %s", c_name, p_err->message); + g_free(c_name); + } + g_clear_error(&p_err); + g_object_unref(p_win); + return; + } + GFile *p_loaded = (GFile *)g_task_get_source_object((GTask *)p_res); + GFile *p_cur = navigator_get_current(p_win->p_nav); + if (p_cur != NULL && g_file_equal(p_cur, p_loaded)) { + _show_texture(p_win, p_tex); + texturecache_put(p_win->p_cache, p_loaded, p_tex); + _prefetch(p_win); + } + g_object_unref(p_tex); + g_object_unref(p_win); +} + +/* Prefetch the next/previous images into the cache (not shown). Cancels the + * previous prefetch round so at most two prefetch loads are in flight. */ +static void +_prefetch(GgazeWindow *p_win) { if (p_win->p_nav == NULL) { return; } - /* Single in-flight load: cancel the previous and start a new one. - * M1/M2 use synchronous load; M3 swaps this for a GTask. */ - g_cancellable_cancel(p_win->p_cancel); - g_clear_object(&p_win->p_cancel); - p_win->p_cancel = g_cancellable_new(); + g_cancellable_cancel(p_win->p_prefetch_cancel); + g_clear_object(&p_win->p_prefetch_cancel); + p_win->p_prefetch_cancel = g_cancellable_new(); + gint i_idx = navigator_get_current_index(p_win->p_nav); + guint u_n = navigator_get_count(p_win->p_nav); + if (u_n == 0) { + return; + } + for (gint i_delta = -1; i_delta <= 1; i_delta += 2) { + gint i_j = i_idx + i_delta; + if (i_j < 0 || i_j >= (gint)u_n) { + continue; + } + GFile *p_file = navigator_get_file(p_win->p_nav, (guint)i_j); + if (p_file != NULL && texturecache_get(p_win->p_cache, p_file) == NULL) { + loader_load_async(p_file, p_win->p_prefetch_cancel, + _prefetch_finish_cb, g_object_ref(p_win)); + } + } +} + +static void +_load_current(GgazeWindow *p_win) { + if (p_win->p_nav == NULL) { + return; + } GFile *p_cur = navigator_get_current(p_win->p_nav); if (p_cur == NULL) { ggaze_viewer_set_texture(GGAZE_VIEWER(p_win->p_viewer), NULL); @@ -162,25 +244,26 @@ _load_current(GgazeWindow *p_win) { return; } - GError *p_err = NULL; - GdkTexture *p_tex = loader_load(p_cur, p_win->p_cancel, &p_err); - if (p_tex != NULL) { - /* Last-write-wins: only show if this is still the current file. Sync - * load makes this trivially true; the guard is here for M3's async path. - */ - GFile *p_now = navigator_get_current(p_win->p_nav); - if (p_now != NULL && g_file_equal(p_now, p_cur)) { - ggaze_viewer_set_texture(GGAZE_VIEWER(p_win->p_viewer), p_tex); - gtk_stack_set_visible_child_name(GTK_STACK(p_win->p_stack), "large"); - } - g_object_unref(p_tex); - } else { - const gchar *c_msg = (p_err != NULL) ? p_err->message : "unknown error"; - char *c_name = g_file_get_basename(p_cur); - g_warning("ggaze: failed to load %s: %s", c_name, c_msg); - g_free(c_name); - g_clear_error(&p_err); + /* Cache hit: show immediately, no async load. */ + GdkTexture *p_cached = texturecache_get(p_win->p_cache, p_cur); + if (p_cached != NULL) { + /* Cancel any in-flight visible load for a now-stale path. */ + g_cancellable_cancel(p_win->p_cancel); + g_clear_object(&p_win->p_cancel); + p_win->p_cancel = g_cancellable_new(); + _show_texture(p_win, p_cached); + _update_header(p_win); + _prefetch(p_win); + return; } + + /* Cache miss: cancel the previous visible load, start a new async load. + * Last-write-wins is enforced in _load_finish_cb. */ + g_cancellable_cancel(p_win->p_cancel); + g_clear_object(&p_win->p_cancel); + p_win->p_cancel = g_cancellable_new(); + loader_load_async(p_cur, p_win->p_cancel, _load_finish_cb, + g_object_ref(p_win)); _update_header(p_win); } @@ -218,7 +301,11 @@ ggaze_window_dispose(GObject *p_obj) { g_signal_handlers_disconnect_by_data(p_win->p_nav, p_win); g_clear_object(&p_win->p_nav); } + g_cancellable_cancel(p_win->p_prefetch_cancel); + g_clear_object(&p_win->p_prefetch_cancel); + g_cancellable_cancel(p_win->p_cancel); g_clear_object(&p_win->p_cancel); + g_clear_pointer(&p_win->p_cache, texturecache_delete); /* p_stack/p_viewer are GtkWidgets parented to the window; GTK releases them. */ G_OBJECT_CLASS(ggaze_window_parent_class)->dispose(p_obj); @@ -232,7 +319,9 @@ ggaze_window_class_init(GgazeWindowClass *p_klass) { static void ggaze_window_init(GgazeWindow *p_win) { - p_win->p_cancel = g_cancellable_new(); + p_win->p_cancel = g_cancellable_new(); + p_win->p_prefetch_cancel = g_cancellable_new(); + p_win->p_cache = texturecache_new(4); /* Header bar (libadwaita, decision #29). */ GtkWidget *p_header = adw_header_bar_new(); diff --git a/tests/meson.build b/tests/meson.build index bd45863..1b99012 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -77,6 +77,16 @@ test_navigator = executable( ) test('navigator', test_navigator, suite : 'unit', env : fixtures_env) +test_texturecache = executable( + 'test_texturecache', + ['test_texturecache.c', ggaze_conf_h], + include_directories : [inc, src_inc], + dependencies : [gdkpixbuf_dep, gtk4_dep, glib_dep, gio_dep], + link_with : ggaze_lib, + install : false, +) +test('texturecache', test_texturecache, suite : 'unit', env : fixtures_env) + # --- integration track (needs a display; CI uses xvfb-run) --- test_window = executable( 'test_window', @@ -110,4 +120,16 @@ test_walk_folder = executable( ) test('walk_folder', test_walk_folder, suite : 'integration', env : fixtures_env) +test_responsive_nav = executable( + 'test_responsive_nav', + ['test_responsive_nav.c', ggaze_conf_h], + include_directories : [inc, src_inc], + dependencies : ggaze_deps, + link_with : ggaze_lib, + install : false, +) +test('responsive_nav', test_responsive_nav, + suite : 'integration', + env : fixtures_env) + subdir('integration')
\ No newline at end of file diff --git a/tests/test_open_and_show.c b/tests/test_open_and_show.c index e707555..3c16996 100644 --- a/tests/test_open_and_show.c +++ b/tests/test_open_and_show.c @@ -36,18 +36,36 @@ static GgazeWindow * new_window(void) { return (GGAZE_WINDOW(g_object_new(GGAZE_TYPE_WINDOW, NULL))); } +/* Drain in-flight async loads so their callbacks (which hold a ref on the + * window) fire and release before the process exits. */ +static void +drain_main(guint u_ms) { + for (guint u = 0; u < u_ms; u++) { + g_main_context_iteration(g_main_context_default(), FALSE); + g_usleep(1000); + } +} static void assert_shown_large_with_dims(GgazeWindow *p_win, int i_w, int i_h) { GtkWidget *p_child = gtk_window_get_child(GTK_WINDOW(p_win)); g_assert_true(GTK_IS_STACK(p_child)); - g_assert_cmpstr(gtk_stack_get_visible_child_name(GTK_STACK(p_child)), ==, - "large"); - GtkWidget *p_large = gtk_stack_get_child_by_name(GTK_STACK(p_child), "large"); g_assert_true(GGAZE_IS_VIEWER(p_large)); - GdkTexture *p_tex = ggaze_viewer_get_texture(GGAZE_VIEWER(p_large)); + /* Loads are async; pump until the viewer shows i_w x i_h. */ + GdkTexture *p_tex = NULL; + for (guint u = 0; u < 3000; u++) { + p_tex = ggaze_viewer_get_texture(GGAZE_VIEWER(p_large)); + if (p_tex != NULL && gdk_texture_get_width(p_tex) == i_w && + gdk_texture_get_height(p_tex) == i_h) { + break; + } + g_main_context_iteration(g_main_context_default(), FALSE); + g_usleep(1000); + } + g_assert_cmpstr(gtk_stack_get_visible_child_name(GTK_STACK(p_child)), ==, + "large"); g_assert_nonnull(p_tex); g_assert_cmpint(gdk_texture_get_width(p_tex), ==, i_w); g_assert_cmpint(gdk_texture_get_height(p_tex), ==, i_h); @@ -61,6 +79,7 @@ test_open_fixture_shows_large(void) { assert_shown_large_with_dims(p_win, 6, 3); g_object_unref(p_file); g_object_unref(p_win); + drain_main(500); } static void @@ -71,6 +90,7 @@ test_open_rotated_fixture(void) { assert_shown_large_with_dims(p_win, 4, 8); g_object_unref(p_file); g_object_unref(p_win); + drain_main(500); } static void @@ -108,15 +128,25 @@ test_open_sample_image(void) { GFile *p_file = g_file_new_for_path(c_path); ggaze_window_open(p_win, p_file); GtkWidget *p_child = gtk_window_get_child(GTK_WINDOW(p_win)); - g_assert_cmpstr(gtk_stack_get_visible_child_name(GTK_STACK(p_child)), ==, - "large"); GtkWidget *p_large = gtk_stack_get_child_by_name(GTK_STACK(p_child), "large"); - GdkTexture *p_tex = ggaze_viewer_get_texture(GGAZE_VIEWER(p_large)); + /* async load: pump until a texture lands */ + GdkTexture *p_tex = NULL; + for (guint u = 0; u < 3000; u++) { + p_tex = ggaze_viewer_get_texture(GGAZE_VIEWER(p_large)); + if (p_tex != NULL) { + break; + } + g_main_context_iteration(g_main_context_default(), FALSE); + g_usleep(1000); + } + g_assert_cmpstr(gtk_stack_get_visible_child_name(GTK_STACK(p_child)), ==, + "large"); g_assert_nonnull(p_tex); /* loaded, whatever its dims */ g_object_unref(p_file); g_object_unref(p_win); + drain_main(500); g_free(c_path); } diff --git a/tests/test_responsive_nav.c b/tests/test_responsive_nav.c new file mode 100644 index 0000000..0a13e1a --- /dev/null +++ b/tests/test_responsive_nav.c @@ -0,0 +1,157 @@ +/*:* + * ggaze — responsive navigation integration test + * + * Fires 10 rapid next-actions (async loads) and asserts last-write-wins: after + * the main loop drains, the viewer shows the final current image, not a stale + * intermediate. Also confirms the rapid navigation did not block (the loop + * completes and the final texture arrives within a timeout). Needs a display + * (integration suite; CI uses xvfb). + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include "viewer.h" +#include "window.h" + +#include <gdk/gdk.h> +#include <gio/gio.h> +#include <glib.h> +#include <gtk/gtk.h> + +static void +copy_fixture(const char *c_dir, const char *c_name) { + const gchar *c_fx = g_getenv("GGAZE_FIXTURES_DIR"); + g_assert_nonnull(c_fx); + char *c_src = g_build_filename(c_fx, c_name, NULL); + char *c_dst = g_build_filename(c_dir, c_name, NULL); + GFile *p_src = g_file_new_for_path(c_src); + GFile *p_dst = g_file_new_for_path(c_dst); + GError *p_err = NULL; + g_assert_true(g_file_copy(p_src, p_dst, G_FILE_COPY_OVERWRITE, NULL, NULL, + NULL, &p_err)); + g_assert_no_error(p_err); + g_object_unref(p_src); + g_object_unref(p_dst); + g_free(c_src); + g_free(c_dst); +} + +static void +cleanup_temp_dir(char *c_dir) { + GFile *p_dir = g_file_new_for_path(c_dir); + GFileEnumerator *p_e = + g_file_enumerate_children(p_dir, "standard::name,standard::type", + G_FILE_QUERY_INFO_NONE, NULL, NULL); + if (p_e != NULL) { + GFileInfo *p_info; + while ((p_info = g_file_enumerator_next_file(p_e, NULL, NULL)) != NULL) { + GFile *p_child = g_file_get_child(p_dir, g_file_info_get_name(p_info)); + g_file_delete(p_child, NULL, NULL); + g_object_unref(p_child); + g_object_unref(p_info); + } + g_object_unref(p_e); + } + g_file_delete(p_dir, NULL, NULL); + g_object_unref(p_dir); + g_free(c_dir); +} + +static GgazeWindow * +new_window(void) { + return (GGAZE_WINDOW(g_object_new(GGAZE_TYPE_WINDOW, NULL))); +} +/* Drain in-flight async loads so their callbacks (which hold a ref on the + * window) fire and release before the process exits. */ +static void +drain_main(guint u_ms) { + for (guint u = 0; u < u_ms; u++) { + g_main_context_iteration(g_main_context_default(), FALSE); + g_usleep(1000); + } +} + +static GdkTexture * +viewer_texture(GgazeWindow *p_win) { + GtkWidget *p_child = gtk_window_get_child(GTK_WINDOW(p_win)); + GtkWidget *p_large = + gtk_stack_get_child_by_name(GTK_STACK(p_child), "large"); + return (ggaze_viewer_get_texture(GGAZE_VIEWER(p_large))); +} + +static gboolean +_dims_are(gpointer p_data) { + GgazeWindow *p_win = GGAZE_WINDOW(p_data); + GdkTexture *p_t = viewer_texture(p_win); + if (p_t == NULL) { + return (FALSE); + } + gint i_w = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(p_win), "w")); + gint i_h = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(p_win), "h")); + return (gdk_texture_get_width(p_t) == i_w && + gdk_texture_get_height(p_t) == i_h); +} + +static gboolean +pump_until(gboolean (*p_pred)(gpointer), gpointer p_data, guint u_ms) { + GMainContext *p_ctx = g_main_context_default(); + for (guint u = 0; u < u_ms; u++) { + if (p_pred != NULL && p_pred(p_data)) { + return (TRUE); + } + g_main_context_iteration(p_ctx, FALSE); + g_usleep(1000); + } + return (p_pred != NULL && p_pred(p_data)); +} + +/* Name sort: plain.jpg (6x3), rot6.jpg (8x4 orient6 -> 4x8), small.png (5x2). + * 10 rapid nexts from idx 0 -> (0+10) % 3 = 1 -> rot6.jpg (4x8). */ +static void +test_rapid_next_last_write_wins(void) { + GError *p_err = NULL; + char *c_dir = g_dir_make_tmp("ggaze-resp-XXXXXX", &p_err); + g_assert_no_error(p_err); + copy_fixture(c_dir, "plain.jpg"); + copy_fixture(c_dir, "rot6.jpg"); + copy_fixture(c_dir, "small.png"); + + char *c_plain_path = g_build_filename(c_dir, "plain.jpg", NULL); + GFile *p_plain = g_file_new_for_path(c_plain_path); + g_free(c_plain_path); + GgazeWindow *p_win = new_window(); + ggaze_window_open(p_win, p_plain); /* current = plain.jpg (idx 0) */ + + /* Let the first load settle so the navigator is ready. */ + g_object_set_data(G_OBJECT(p_win), "w", GINT_TO_POINTER(6)); + g_object_set_data(G_OBJECT(p_win), "h", GINT_TO_POINTER(3)); + g_assert_true(pump_until(_dims_are, p_win, 2000)); + + /* Fire 10 rapid nexts; async loads must not block, and the viewer must end + * on the final current (rot6.jpg, 4x8), not a stale intermediate. */ + for (gint i = 0; i < 10; i++) { + ggaze_window_next(p_win); + } + g_object_set_data(G_OBJECT(p_win), "w", GINT_TO_POINTER(4)); + g_object_set_data(G_OBJECT(p_win), "h", GINT_TO_POINTER(8)); + g_assert_true(pump_until(_dims_are, p_win, 3000)); + + g_object_unref(p_win); + drain_main(500); + g_object_unref(p_plain); + cleanup_temp_dir(c_dir); +} + +int +main(int i_argc, char **c_argv) { + g_test_init(&i_argc, &c_argv, NULL); + g_log_set_always_fatal(G_LOG_LEVEL_ERROR | G_LOG_LEVEL_CRITICAL); + if (!gtk_init_check()) { + g_test_skip("no display available (run under xvfb)"); + return (g_test_run()); + } + g_test_add_func("/responsive/rapid_next_last_write_wins", + test_rapid_next_last_write_wins); + return (g_test_run()); +}
\ No newline at end of file diff --git a/tests/test_texturecache.c b/tests/test_texturecache.c new file mode 100644 index 0000000..3ad24f8 --- /dev/null +++ b/tests/test_texturecache.c @@ -0,0 +1,108 @@ +/*:* + * ggaze — texture cache unit test + * + * Exercises the bounded LRU: capacity cap + eviction, MRU ordering on get, + * replace, and miss. Uses 1x1 GdkMemoryTextures (no display needed). + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include "texturecache.h" + +#include <gdk/gdk.h> +#include <gio/gio.h> +#include <glib.h> + +static GdkTexture * +mk_tex(void) { + static const guint8 u_px[4] = {0, 0, 0, 255}; + GBytes *p_b = g_bytes_new_static(u_px, 4); + GdkTexture *p_t = gdk_memory_texture_new(1, 1, GDK_MEMORY_R8G8B8A8, p_b, 4); + g_bytes_unref(p_b); + return (p_t); +} + +static GFile * +mk_file(const char *c_name) { + return (g_file_new_for_path(c_name)); +} + +static void +test_cap_and_evict(void) { + TextureCache *p_c = texturecache_new(4); + const char *names[5] = {"a.jpg", "b.jpg", "c.jpg", "d.jpg", "e.jpg"}; + GFile *f[5]; + GdkTexture *t[5]; + for (gint i = 0; i < 5; i++) { + f[i] = g_file_new_for_path(names[i]); + t[i] = mk_tex(); + texturecache_put(p_c, f[i], t[i]); + } + g_assert_cmpint(texturecache_get_size(p_c), ==, 4); /* cap 4, 5 put */ + g_assert_null(texturecache_get(p_c, f[0])); /* LRU evicted */ + g_assert_nonnull(texturecache_get(p_c, f[4])); /* newest kept */ + + for (gint i = 0; i < 5; i++) { + g_object_unref(f[i]); + g_object_unref(t[i]); + } + texturecache_delete(p_c); +} + +static void +test_lru_order(void) { + TextureCache *p_c = texturecache_new(2); + GFile *f1 = g_file_new_for_path("1.jpg"); + GFile *f2 = g_file_new_for_path("2.jpg"); + GFile *f3 = g_file_new_for_path("3.jpg"); + GdkTexture *t1 = mk_tex(), *t2 = mk_tex(), *t3 = mk_tex(); + + texturecache_put(p_c, f1, t1); + texturecache_put(p_c, f2, t2); + /* Touch f1 -> f2 becomes LRU. */ + g_assert_nonnull(texturecache_get(p_c, f1)); + texturecache_put(p_c, f3, t3); /* evicts LRU = f2 */ + g_assert_null(texturecache_get(p_c, f2)); + g_assert_nonnull(texturecache_get(p_c, f1)); + g_assert_nonnull(texturecache_get(p_c, f3)); + + g_object_unref(f1); + g_object_unref(f2); + g_object_unref(f3); + g_object_unref(t1); + g_object_unref(t2); + g_object_unref(t3); + texturecache_delete(p_c); +} + +static void +test_replace_and_miss(void) { + TextureCache *p_c = texturecache_new(4); + GFile *f = g_file_new_for_path("x.jpg"); + GdkTexture *t1 = mk_tex(), *t2 = mk_tex(); + + texturecache_put(p_c, f, t1); + g_assert_cmpint(texturecache_get_size(p_c), ==, 1); + texturecache_put(p_c, f, t2); /* replace, no size growth */ + g_assert_cmpint(texturecache_get_size(p_c), ==, 1); + g_assert_true(texturecache_get(p_c, f) == t2); + + GFile *f_other = g_file_new_for_path("other.jpg"); + g_assert_null(texturecache_get(p_c, f_other)); /* miss */ + + g_object_unref(f); + g_object_unref(f_other); + g_object_unref(t1); + g_object_unref(t2); + texturecache_delete(p_c); +} + +int +main(int i_argc, char **c_argv) { + g_test_init(&i_argc, &c_argv, NULL); + g_test_add_func("/texturecache/cap_and_evict", test_cap_and_evict); + g_test_add_func("/texturecache/lru_order", test_lru_order); + g_test_add_func("/texturecache/replace_and_miss", test_replace_and_miss); + return (g_test_run()); +}
\ No newline at end of file diff --git a/tests/test_walk_folder.c b/tests/test_walk_folder.c index 660b164..f2f4f15 100644 --- a/tests/test_walk_folder.c +++ b/tests/test_walk_folder.c @@ -73,6 +73,15 @@ static GgazeWindow * new_window(void) { return (GGAZE_WINDOW(g_object_new(GGAZE_TYPE_WINDOW, NULL))); } +/* Drain in-flight async loads so their callbacks (which hold a ref on the + * window) fire and release before the process exits. */ +static void +drain_main(guint u_ms) { + for (guint u = 0; u < u_ms; u++) { + g_main_context_iteration(g_main_context_default(), FALSE); + g_usleep(1000); + } +} static GdkTexture * viewer_texture(GgazeWindow *p_win) { @@ -84,7 +93,20 @@ viewer_texture(GgazeWindow *p_win) { static void assert_dims(GgazeWindow *p_win, int i_w, int i_h) { - GdkTexture *p_tex = viewer_texture(p_win); + /* Loads are async; pump the main loop until the viewer shows i_w x i_h. */ + GtkWidget *p_child = gtk_window_get_child(GTK_WINDOW(p_win)); + GtkWidget *p_large = + gtk_stack_get_child_by_name(GTK_STACK(p_child), "large"); + GdkTexture *p_tex = NULL; + for (guint u = 0; u < 3000; u++) { + p_tex = ggaze_viewer_get_texture(GGAZE_VIEWER(p_large)); + if (p_tex != NULL && gdk_texture_get_width(p_tex) == i_w && + gdk_texture_get_height(p_tex) == i_h) { + break; + } + g_main_context_iteration(g_main_context_default(), FALSE); + g_usleep(1000); + } g_assert_nonnull(p_tex); g_assert_cmpint(gdk_texture_get_width(p_tex), ==, i_w); g_assert_cmpint(gdk_texture_get_height(p_tex), ==, i_h); @@ -104,7 +126,6 @@ test_walk_temp(void) { GError *p_err = NULL; char *c_dir = g_dir_make_tmp("ggaze-walk-XXXXXX", &p_err); g_assert_no_error(p_err); - copy_fixture(c_dir, "plain.jpg"); copy_fixture(c_dir, "rot6.jpg"); copy_fixture(c_dir, "small.png"); @@ -132,6 +153,7 @@ test_walk_temp(void) { assert_dims(p_win, 5, 2); g_object_unref(p_win); + drain_main(500); g_object_unref(p_plain); cleanup_temp_dir(c_dir); } @@ -168,15 +190,25 @@ test_walk_sample(void) { GFile *p_file = g_file_new_for_path(c_path); ggaze_window_open(p_win, p_file); g_object_unref(p_file); + /* wait for the async load to land a texture */ + for (guint u = 0; u < 3000 && viewer_texture(p_win) == NULL; u++) { + g_main_context_iteration(g_main_context_default(), FALSE); + g_usleep(1000); + } g_assert_nonnull(viewer_texture(p_win)); /* Walk a few; each step must produce a (possibly new) texture. */ for (int i = 0; i < 5; i++) { ggaze_window_next(p_win); - g_assert_nonnull(viewer_texture(p_win)); } + for (guint u = 0; u < 3000 && viewer_texture(p_win) == NULL; u++) { + g_main_context_iteration(g_main_context_default(), FALSE); + g_usleep(1000); + } + g_assert_nonnull(viewer_texture(p_win)); g_object_unref(p_win); + drain_main(500); g_free(c_path); } |
