summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--meson.build6
-rw-r--r--meson_options.txt5
-rw-r--r--src/loader/backends/jpeg.c178
-rw-r--r--src/loader/loader.c58
-rw-r--r--src/loader/loader.h9
-rw-r--r--src/window.c34
-rw-r--r--tests/meson.build23
-rw-r--r--tests/test_loader_jpeg.c97
-rw-r--r--tests/test_progressive_jpeg.c94
9 files changed, 495 insertions, 9 deletions
diff --git a/meson.build b/meson.build
index 4611532..78cb24a 100644
--- a/meson.build
+++ b/meson.build
@@ -23,6 +23,7 @@ gio_dep = dependency('gio-2.0')
adwaita_dep = dependency('libadwaita-1')
gdkpixbuf_dep = dependency('gdk-pixbuf-2.0') # pixbuf loader backend
libexif_dep = dependency('libexif') # EXIF gather (info overlay, M4)
+libjpeg_dep = dependency('libjpeg') # libjpeg-turbo direct JPEG (M6)
# --- Optional backends (feature: auto/disabled/enabled). See meson_options.txt.
gegl_dep = dependency('gegl-0.4', required : get_option('gegl'))
@@ -86,6 +87,11 @@ if heif_dep.found()
ggaze_c_args += '-DHAVE_HEIF'
ggaze_extra_deps += heif_dep
endif
+if libjpeg_dep.found()
+ ggaze_src += files('src/loader/backends/jpeg.c')
+ ggaze_c_args += '-DHAVE_JPEG'
+ ggaze_extra_deps += libjpeg_dep
+endif
ggaze_lib = static_library('ggaze',
ggaze_src,
diff --git a/meson_options.txt b/meson_options.txt
index 153a3e3..03054ff 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -18,4 +18,7 @@ option('avif', type : 'feature', value : 'auto',
description : 'AVIF loader backend (libavif)')
option('heif', type : 'feature', value : 'auto',
- description : 'HEIF/HEIC loader backend (libheif)') \ No newline at end of file
+ description : 'HEIF/HEIC loader backend (libheif)')
+
+option('jpeg', type : 'feature', value : 'auto',
+ description : 'libjpeg-turbo direct JPEG backend (progressive low-res preview)') \ No newline at end of file
diff --git a/src/loader/backends/jpeg.c b/src/loader/backends/jpeg.c
new file mode 100644
index 0000000..7c32196
--- /dev/null
+++ b/src/loader/backends/jpeg.c
@@ -0,0 +1,178 @@
+/*:*
+ * ggaze — libjpeg-turbo direct JPEG backend (progressive low-res preview)
+ *
+ * Two-phase decode: a quick 1/8-scale decode (coarse frame, <50 ms for a
+ * 40 MP JPEG) is emitted via the progress callback, then the full decode
+ * completes and is returned. Uses libjpeg-turbo's jpeg_mem_src + scale_num/
+ * scale_denom for the low-res phase. Compiled when meson feature `jpeg` is on.
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "../loader.h"
+#include "../detect.h"
+
+#include <gdk/gdk.h>
+#include <gio/gio.h>
+#include <jpeglib.h>
+#include <setjmp.h>
+#include <gdk-pixbuf/gdk-pixbuf.h>
+
+#define GGAZE_JPEG_LORES_DENOM 8
+
+struct _jerr_jmp {
+ struct jpeg_error_mgr pub;
+ jmp_buf buf;
+};
+
+static void
+_jerr_exit(j_common_ptr p_cinfo) {
+ struct _jerr_jmp *p_ej = (struct _jerr_jmp *)p_cinfo->err;
+ longjmp(p_ej->buf, 1);
+}
+
+/* Decode JPEG data at the given scale denominator, returning RGBA pixels.
+ * Caller frees *pp_pixels. Returns TRUE on success. */
+static gboolean
+_decode_at_scale(const guint8 *p_data, gsize u_len, int i_denom, int *p_w,
+ int *p_h, guint8 **pp_pixels, GError **p_err) {
+ struct jpeg_decompress_struct cinfo;
+ struct _jerr_jmp jerr;
+ cinfo.err = jpeg_std_error(&jerr.pub);
+ jerr.pub.error_exit = _jerr_exit;
+ if (setjmp(jerr.buf)) {
+ jpeg_destroy_decompress(&cinfo);
+ g_set_error(p_err, G_IO_ERROR, G_IO_ERROR_FAILED, "jpeg: decode error");
+ return (FALSE);
+ }
+ jpeg_create_decompress(&cinfo);
+ jpeg_mem_src(&cinfo, (const unsigned char *)p_data, (unsigned long)u_len);
+ jpeg_read_header(&cinfo, TRUE);
+ cinfo.scale_num = 1;
+ cinfo.scale_denom = (unsigned int)i_denom;
+ cinfo.out_color_space = JCS_RGB;
+ jpeg_start_decompress(&cinfo);
+
+ int i_w = (int)cinfo.output_width;
+ int i_h = (int)cinfo.output_height;
+ int i_rowstride = i_w * 3;
+ guint8 *p_rgb = g_malloc((gsize)i_h * (gsize)i_rowstride);
+
+ while (cinfo.output_scanline < (JDIMENSION)i_h) {
+ guint8 *p_rows[1] = {p_rgb + (gsize)cinfo.output_scanline * i_rowstride};
+ jpeg_read_scanlines(&cinfo, p_rows, 1);
+ }
+ jpeg_finish_decompress(&cinfo);
+ jpeg_destroy_decompress(&cinfo);
+
+ /* Convert RGB → RGBA. */
+ guint8 *p_rgba = g_malloc((gsize)i_w * (gsize)i_h * 4u);
+ for (int i = 0; i < i_w * i_h; i++) {
+ p_rgba[i * 4 + 0] = p_rgb[i * 3 + 0];
+ p_rgba[i * 4 + 1] = p_rgb[i * 3 + 1];
+ p_rgba[i * 4 + 2] = p_rgb[i * 3 + 2];
+ p_rgba[i * 4 + 3] = 255;
+ }
+ g_free(p_rgb);
+ *p_w = i_w;
+ *p_h = i_h;
+ *pp_pixels = p_rgba;
+ return (TRUE);
+}
+
+static GdkTexture *
+_make_texture(int i_w, int i_h, guint8 *p_pixels) {
+ GBytes *p_bytes = g_bytes_new_take(p_pixels, (gsize)i_w * (gsize)i_h * 4u);
+ GdkTexture *p_tex = gdk_memory_texture_new(i_w, i_h, GDK_MEMORY_R8G8B8A8,
+ p_bytes, (gsize)i_w * 4u);
+ g_bytes_unref(p_bytes);
+ return (p_tex);
+}
+
+static gboolean
+_jpeg_can_load(const guint8 *p_head, gsize u_len) {
+ return (detect_format(p_head, u_len) == GGAZE_FMT_JPEG);
+}
+
+static GdkTexture *
+_jpeg_load(GFile *p_file, GCancellable *p_cancel, GError **p_err) {
+ (void)p_cancel;
+ gchar *c_buf = NULL;
+ gsize u_len = 0;
+ if (!g_file_load_contents(p_file, NULL, &c_buf, &u_len, NULL, p_err)) {
+ return (NULL);
+ }
+ int i_w, i_h;
+ guint8 *p_pixels = NULL;
+ if (!_decode_at_scale((const guint8 *)c_buf, u_len, 1, &i_w, &i_h, &p_pixels,
+ p_err)) {
+ g_free(c_buf);
+ return (NULL);
+ }
+ g_free(c_buf);
+ return (_make_texture(i_w, i_h, p_pixels));
+}
+
+static GdkTexture *
+_jpeg_load_progressive(GFile *p_file, GCancellable *p_cancel,
+ LoaderProgressCb p_progress, gpointer p_progress_data,
+ GError **p_err) {
+ (void)p_cancel;
+ gchar *c_buf = NULL;
+ gsize u_len = 0;
+ if (!g_file_load_contents(p_file, NULL, &c_buf, &u_len, NULL, p_err)) {
+ return (NULL);
+ }
+ /* Phase 1: low-res (1/8 scale). */
+ int i_lw, i_lh;
+ guint8 *p_lp = NULL;
+ if (_decode_at_scale((const guint8 *)c_buf, u_len, GGAZE_JPEG_LORES_DENOM,
+ &i_lw, &i_lh, &p_lp, NULL)) {
+ GdkTexture *p_partial = _make_texture(i_lw, i_lh, p_lp);
+ if (p_progress != NULL) {
+ p_progress(p_partial, p_progress_data);
+ }
+ g_object_unref(p_partial);
+ }
+ /* Phase 2: full decode via GdkPixbuf (applies EXIF orientation). */
+ char *c_path = g_file_get_path(p_file);
+ g_free(c_buf);
+ if (c_path == NULL) {
+ g_set_error(p_err, G_IO_ERROR, G_IO_ERROR_FAILED, "jpeg: non-local file");
+ return (NULL);
+ }
+ GError *p_sub = NULL;
+ GdkPixbuf *p_pix = gdk_pixbuf_new_from_file(c_path, &p_sub);
+ g_free(c_path);
+ if (p_pix == NULL) {
+ g_propagate_error(p_err, p_sub);
+ 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);
+ /* Convert to GdkTexture (RGBA). */
+ int i_w = gdk_pixbuf_get_width(p_use);
+ int i_h = gdk_pixbuf_get_height(p_use);
+ GdkPixbuf *p_rgba = gdk_pixbuf_get_has_alpha(p_use)
+ ? GDK_PIXBUF(g_object_ref(p_use))
+ : gdk_pixbuf_add_alpha(p_use, FALSE, 0, 0, 0);
+ int i_rowstride = gdk_pixbuf_get_rowstride(p_rgba);
+ guchar *p_px = gdk_pixbuf_get_pixels(p_rgba);
+ gsize u_len2 = (gsize)(i_h - 1) * (gsize)i_rowstride + (gsize)i_w * 4u;
+ GBytes *p_bytes = g_bytes_new_with_free_func(
+ p_px, u_len2, (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);
+ g_object_unref(p_use);
+ return (p_tex);
+}
+
+const GgazeLoaderBackend jpeg_backend = {
+ .can_load = _jpeg_can_load,
+ .load = _jpeg_load,
+ .load_progressive = _jpeg_load_progressive,
+}; \ No newline at end of file
diff --git a/src/loader/loader.c b/src/loader/loader.c
index 9721c2a..65cc4cb 100644
--- a/src/loader/loader.c
+++ b/src/loader/loader.c
@@ -28,7 +28,6 @@ extern const GgazeLoaderBackend avif_backend;
#ifdef HAVE_HEIF
extern const GgazeLoaderBackend heif_backend;
#endif
-
static const GgazeLoaderBackend *BACKENDS[] = {
#ifdef HAVE_JXL
&jxl_backend,
@@ -70,7 +69,7 @@ loader_load(GFile *p_file, GCancellable *p_cancel, GError **p_err) {
guint8 head[GGAZE_SNIFF_LEN];
gsize u_read = _read_header(p_file, p_cancel, head, GGAZE_SNIFF_LEN, p_err);
- if (u_read == 0 && p_err != NULL && *p_err != NULL) {
+ if (u_read == 0 && p_err != NULL) {
return (NULL);
}
@@ -87,12 +86,54 @@ loader_load(GFile *p_file, GCancellable *p_cancel, GError **p_err) {
/* --- async wrapper (M3) -------------------------------------------------- */
+typedef struct {
+ LoaderProgressCb p_cb;
+ gpointer p_data;
+} ProgressPair;
+
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);
+ ProgressPair *p_pair = (ProgressPair *)p_task_data;
+ GError *p_err = NULL;
+ GdkTexture *p_tex = NULL;
+
+ /* Sniff and dispatch to the first matching backend. */
+ guint8 head[GGAZE_SNIFF_LEN];
+ gsize u_read =
+ _read_header((GFile *)p_src, p_cancel, head, GGAZE_SNIFF_LEN, &p_err);
+ if (u_read == 0 && p_err != NULL) {
+ g_task_return_error(p_task, p_err);
+ return;
+ }
+ gboolean b_found = FALSE;
+#ifdef HAVE_JPEG
+ extern const GgazeLoaderBackend jpeg_backend;
+ if (detect_format(head, u_read) == GGAZE_FMT_JPEG && p_pair != NULL) {
+ p_tex = jpeg_backend.load_progressive(
+ (GFile *)p_src, p_cancel, p_pair->p_cb, p_pair->p_data, &p_err);
+ b_found = TRUE;
+ }
+#endif
+ if (!b_found) {
+ for (gsize u_i = 0; u_i < G_N_ELEMENTS(BACKENDS); u_i++) {
+ if (BACKENDS[u_i]->can_load(head, u_read)) {
+ if (BACKENDS[u_i]->load_progressive != NULL && p_pair != NULL) {
+ p_tex = BACKENDS[u_i]->load_progressive((GFile *)p_src, p_cancel,
+ p_pair->p_cb,
+ p_pair->p_data, &p_err);
+ } else {
+ p_tex = BACKENDS[u_i]->load((GFile *)p_src, p_cancel, &p_err);
+ }
+ b_found = TRUE;
+ break;
+ }
+ }
+ } /* end if (!b_found) */
+ if (!b_found) {
+ g_set_error(&p_err, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
+ "unsupported or unrecognized image format");
+ }
if (p_tex == NULL) {
g_task_return_error(p_task, p_err);
} else {
@@ -102,9 +143,16 @@ _load_task_thread(GTask *p_task, gpointer p_src, gpointer p_task_data,
void
loader_load_async(GFile *p_file, GCancellable *p_cancel,
+ LoaderProgressCb p_progress, gpointer p_progress_data,
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);
+ if (p_progress != NULL) {
+ ProgressPair *p_pair = g_new(ProgressPair, 1);
+ p_pair->p_cb = p_progress;
+ p_pair->p_data = p_progress_data;
+ g_task_set_task_data(p_task, p_pair, (GDestroyNotify)g_free);
+ }
g_task_run_in_thread(p_task, _load_task_thread);
g_object_unref(p_task);
}
diff --git a/src/loader/loader.h b/src/loader/loader.h
index bfee29d..c2228f5 100644
--- a/src/loader/loader.h
+++ b/src/loader/loader.h
@@ -23,11 +23,19 @@
G_BEGIN_DECLS
+/* Optional progressive-load callback (called from the worker thread with a
+ * low-res partial texture before the full decode completes). */
+typedef void (*LoaderProgressCb)(GdkTexture *p_partial, gpointer p_data);
+
/* A loader backend. Compiled in conditionally (meson feature options) and
* registered with the loader at link time. */
typedef struct {
gboolean (*can_load)(const guint8 *p_head, gsize u_len);
GdkTexture *(*load)(GFile *p_file, GCancellable *p_cancel, GError **p_err);
+ /* Optional: two-phase load (low-res first via p_progress, then full). */
+ GdkTexture *(*load_progressive)(GFile *p_file, GCancellable *p_cancel,
+ LoaderProgressCb p_progress,
+ gpointer p_progress_data, GError **p_err);
} GgazeLoaderBackend;
/* Backends register a const instance; the dispatcher (loader.c) iterates
@@ -45,6 +53,7 @@ GdkTexture *loader_load(GFile *p_file, GCancellable *p_cancel, GError **p_err);
* 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,
+ LoaderProgressCb p_progress, gpointer p_progress_data,
GAsyncReadyCallback p_cb, gpointer p_data);
/* Finish an async load; returns the GdkTexture (transfer full) or NULL. */
diff --git a/src/window.c b/src/window.c
index 4c26ea7..688f879 100644
--- a/src/window.c
+++ b/src/window.c
@@ -464,6 +464,34 @@ _prefetch_finish_cb(GObject *p_src, GAsyncResult *p_res, gpointer p_data) {
g_object_unref(p_win);
}
+/* --- M6: progressive low-res preview ------------------------------------ */
+
+typedef struct {
+ GgazeWindow *p_win;
+ GdkTexture *p_tex;
+} ProgressInvoke;
+
+static gboolean
+_on_progress_main(gpointer p_data) {
+ ProgressInvoke *p_pi = (ProgressInvoke *)p_data;
+ /* Show the partial; the full result replaces it in _load_finish_cb. */
+ ggaze_viewer_set_texture(GGAZE_VIEWER(p_pi->p_win->p_viewer), p_pi->p_tex);
+ g_object_unref(p_pi->p_tex);
+ g_object_unref(p_pi->p_win);
+ g_free(p_pi);
+ return (G_SOURCE_REMOVE);
+}
+
+static void
+_load_progress_cb(GdkTexture *p_partial, gpointer p_data) {
+ GgazeWindow *p_win = GGAZE_WINDOW(p_data);
+ ProgressInvoke *p_pi = g_new(ProgressInvoke, 1);
+ p_pi->p_win = (GgazeWindow *)g_object_ref(p_win);
+ p_pi->p_tex = (GdkTexture *)g_object_ref(p_partial);
+ g_main_context_invoke_full(NULL, G_PRIORITY_DEFAULT, _on_progress_main, p_pi,
+ NULL);
+}
+
/* Visible-load callback: show only if this is still the current file
* (last-write-wins), then cache it and prefetch neighbours. */
static void
@@ -518,7 +546,7 @@ _prefetch(GgazeWindow *p_win) {
}
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,
+ loader_load_async(p_file, p_win->p_prefetch_cancel, NULL, NULL,
_prefetch_finish_cb, g_object_ref(p_win));
}
}
@@ -554,8 +582,8 @@ _load_current(GgazeWindow *p_win) {
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));
+ loader_load_async(p_cur, p_win->p_cancel, _load_progress_cb,
+ g_object_ref(p_win), _load_finish_cb, g_object_ref(p_win));
_update_header(p_win);
}
diff --git a/tests/meson.build b/tests/meson.build
index 86eee1d..dbe21f2 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -67,6 +67,18 @@ test_loader_pixbuf = executable(
)
test('loader_pixbuf', test_loader_pixbuf, suite : 'unit', env : fixtures_env)
+if libjpeg_dep.found()
+ test_loader_jpeg = executable(
+ 'test_loader_jpeg',
+ ['test_loader_jpeg.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('loader_jpeg', test_loader_jpeg, suite : 'unit', env : fixtures_env)
+endif
+
test_navigator = executable(
'test_navigator',
['test_navigator.c', ggaze_conf_h],
@@ -172,4 +184,15 @@ test_grid_cull = executable(
)
test('grid_cull', test_grid_cull, suite : 'integration', env : fixtures_env)
+test_progressive_jpeg = executable(
+ 'test_progressive_jpeg',
+ ['test_progressive_jpeg.c', ggaze_conf_h],
+ include_directories : [inc, src_inc],
+ dependencies : ggaze_deps,
+ link_with : ggaze_lib,
+ install : false,
+)
+test('progressive_jpeg', test_progressive_jpeg,
+ suite : 'integration', env : fixtures_env)
+
subdir('integration') \ No newline at end of file
diff --git a/tests/test_loader_jpeg.c b/tests/test_loader_jpeg.c
new file mode 100644
index 0000000..6a2e0e5
--- /dev/null
+++ b/tests/test_loader_jpeg.c
@@ -0,0 +1,97 @@
+/*:*
+ * ggaze — libjpeg-turbo progressive loader unit test (feature-gated)
+ *
+ * Tests the progressive JPEG path: loader_load_async with a progress callback
+ * fires the callback (low-res partial) and returns the full texture. Only
+ * compiled when libjpeg-turbo is found (HAVE_JPEG).
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "loader/loader.h"
+
+#include <gdk/gdk.h>
+#include <gio/gio.h>
+#include <glib.h>
+
+static volatile int g_progress_fired = 0;
+static GMainLoop *g_loop = NULL;
+static GdkTexture *g_result = NULL;
+
+static void
+progress_cb(GdkTexture *p_partial, gpointer p_data) {
+ (void)p_partial;
+ (void)p_data;
+ g_atomic_int_set(&g_progress_fired, 1);
+}
+
+static void
+finish_cb(GObject *p_src, GAsyncResult *p_res, gpointer p_data) {
+ (void)p_src;
+ (void)p_data;
+ GError *p_err = NULL;
+ g_result = loader_load_finish(p_res, &p_err);
+ g_assert_no_error(p_err);
+ g_main_loop_quit(g_loop);
+}
+
+static void
+test_progressive_jpeg(void) {
+ const char *c_dir = g_getenv("GGAZE_FIXTURES_DIR");
+ g_assert_nonnull(c_dir);
+ char *c_path = g_build_filename(c_dir, "plain.jpg", NULL);
+ GFile *p_file = g_file_new_for_path(c_path);
+ g_free(c_path);
+
+ g_progress_fired = 0;
+ g_result = NULL;
+ g_loop = g_main_loop_new(NULL, FALSE);
+ loader_load_async(p_file, NULL, progress_cb, NULL, finish_cb, NULL);
+ g_main_loop_run(g_loop);
+ g_main_loop_unref(g_loop);
+ g_loop = NULL;
+
+ g_assert_nonnull(g_result);
+ g_assert_cmpint(gdk_texture_get_width(g_result), ==, 6);
+ g_assert_cmpint(gdk_texture_get_height(g_result), ==, 3);
+ g_assert_cmpint(g_atomic_int_get(&g_progress_fired), ==, 1);
+ g_object_unref(g_result);
+ g_object_unref(p_file);
+}
+
+static void
+test_progressive_rotated(void) {
+ const char *c_dir = g_getenv("GGAZE_FIXTURES_DIR");
+ g_assert_nonnull(c_dir);
+ char *c_path = g_build_filename(c_dir, "rot6.jpg", NULL);
+ GFile *p_file = g_file_new_for_path(c_path);
+ g_free(c_path);
+
+ g_progress_fired = 0;
+ g_result = NULL;
+ g_loop = g_main_loop_new(NULL, FALSE);
+ loader_load_async(p_file, NULL, progress_cb, NULL, finish_cb, NULL);
+ g_main_loop_run(g_loop);
+ g_main_loop_unref(g_loop);
+
+ /* Full result via GdkPixbuf applies EXIF orientation → 4x8. */
+ g_assert_nonnull(g_result);
+ g_assert_cmpint(gdk_texture_get_width(g_result), ==, 4);
+ g_assert_cmpint(gdk_texture_get_height(g_result), ==, 8);
+ g_object_unref(g_result);
+ g_object_unref(p_file);
+}
+
+int
+main(int i_argc, char **c_argv) {
+ g_test_init(&i_argc, &c_argv, NULL);
+ if (g_getenv("GGAZE_FIXTURES_DIR") == NULL) {
+ g_test_skip("GGAZE_FIXTURES_DIR unset");
+ return (g_test_run());
+ }
+ g_test_add_func("/loader/jpeg/progressive", test_progressive_jpeg);
+ g_test_add_func("/loader/jpeg/progressive_rotated",
+ test_progressive_rotated);
+ return (g_test_run());
+} \ No newline at end of file
diff --git a/tests/test_progressive_jpeg.c b/tests/test_progressive_jpeg.c
new file mode 100644
index 0000000..8fc8f19
--- /dev/null
+++ b/tests/test_progressive_jpeg.c
@@ -0,0 +1,94 @@
+/*:*
+ * ggaze — progressive JPEG integration test
+ *
+ * Opens a large sample JPEG via the window (async + progressive path) and
+ * asserts the viewer shows a texture. The progressive low-res partial fires
+ * first (via the loader's progress callback), then the full result replaces
+ * it. Skip-if-absent (./sample-images not in CI). Needs a display.
+ *
+ * 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 GgazeWindow *
+new_window(void) {
+ return (GGAZE_WINDOW(g_object_new(GGAZE_TYPE_WINDOW, NULL)));
+}
+
+static GdkTexture *
+viewer_texture(GgazeWindow *p_win) {
+ GtkStack *p_stack = ggaze_window_get_stack(p_win);
+ GtkWidget *p_large = gtk_stack_get_child_by_name(p_stack, "large");
+ return (ggaze_viewer_get_texture(GGAZE_VIEWER(p_large)));
+}
+
+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
+test_progressive_sample(void) {
+ const gchar *c_dir = g_getenv("GGAZE_SAMPLE_DIR");
+ if (c_dir == NULL || *c_dir == '\0' ||
+ !g_file_test(c_dir, G_FILE_TEST_IS_DIR)) {
+ g_test_skip("GGAZE_SAMPLE_DIR unset/absent (./sample-images)");
+ return;
+ }
+ GDir *p_d = g_dir_open(c_dir, 0, NULL);
+ g_assert_nonnull(p_d);
+ const gchar *c_name;
+ while ((c_name = g_dir_read_name(p_d)) != NULL) {
+ if (g_str_has_suffix(c_name, ".jpg") ||
+ g_str_has_suffix(c_name, ".JPG")) {
+ break;
+ }
+ }
+ char *c_path =
+ (c_name != NULL) ? g_build_filename(c_dir, c_name, NULL) : NULL;
+ g_dir_close(p_d);
+ if (c_path == NULL) {
+ g_test_skip("no JPEG sample found");
+ return;
+ }
+
+ GgazeWindow *p_win = new_window();
+ GFile *p_file = g_file_new_for_path(c_path);
+ ggaze_window_open(p_win, p_file);
+ g_object_unref(p_file);
+ g_free(c_path);
+
+ /* Pump until the viewer shows a texture (the progressive partial or the
+ * full result). */
+ 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);
+}
+
+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("/progressive/sample", test_progressive_sample);
+ return (g_test_run());
+} \ No newline at end of file