summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--meson.build1
-rw-r--r--src/loader/loader.c30
-rw-r--r--src/loader/loader.h13
-rw-r--r--src/shortcuts.c2
-rw-r--r--src/texturecache.c117
-rw-r--r--src/texturecache.h40
-rw-r--r--src/window.c143
-rw-r--r--tests/meson.build22
-rw-r--r--tests/test_open_and_show.c44
-rw-r--r--tests/test_responsive_nav.c157
-rw-r--r--tests/test_texturecache.c108
-rw-r--r--tests/test_walk_folder.c38
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);
}