From 6b0f2de39c2d8b9e8d346abd8548024da4ae8185 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 12 Jul 2026 17:51:22 +0300 Subject: showimage: loader, detect, viewer widget, EXIF orientation, tests (gt0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 (task gt0): show one image with zoom/pan. - src/loader/detect.{c,h}: magic-byte format sniffing (JPEG/PNG/GIF/WebP/ TIFF/ICO/JXL/AVIF/HEIF) -> GgazeFormat; pure, no I/O, unit-testable. - src/loader/loader.{c,h}: loader_load() sniffs the header and dispatches to the first registered backend; GgazeLoaderBackend struct; pixbuf is the fallback (last, accepts UNKNOWN). M1 ships only the pixbuf backend. - src/loader/backends/pixbuf.c: GdkPixbufLoader decode + gdk_pixbuf_apply_embedded_orientation (decision #26) -> GdkTexture via gdk_memory_texture_new (avoids the deprecated gdk_texture_new_for_pixbuf). - src/viewer.{c,h}: GgazeViewer : GtkWidget custom widget (decision #31) — fit/100%/in/out zoom, cursor-centered zoom, drag-to-pan with clamping, dark background, GtkSnapshot render nodes. - src/window.c: open -> loader_load -> viewer_set_texture -> stack 'large'. - tests: unit test_detect (13 cases) + test_loader_pixbuf (plain/rotated-EXIF 8x4 orient6->4x8/png/rgba/missing/unsupported jxl-avif-heif/corrupt), integration test_open_and_show (fixture + rotated + ./sample-images skip-if-absent). 6/6 green; detect 97% / loader 91% / pixbuf 87% coverage. - fixtures: gen.py produces plain.jpg, rot6.jpg, small.png, rgba.png. - AGENTS.md: documents the ./sample-images optional test corpus convention. Sub-agent review fixes: use-after-free of c_name in ggaze_window_open (BLOCKER), gtk_stack_get_pages leak in test_window (BLOCKER), coverage gap, dead branch, viewer measure, _prefix/_cb naming, include order, extern in header, pan clamp, stale comments — all addressed. --- AGENTS.md | 8 + meson.build | 22 ++- src/loader/backends/pixbuf.c | 121 ++++++++++++++ src/loader/detect.c | 82 ++++++++++ src/loader/detect.h | 40 +++++ src/loader/loader.c | 67 ++++++++ src/loader/loader.h | 45 +++++ src/viewer.c | 382 +++++++++++++++++++++++++++++++++++++++++++ src/viewer.h | 42 +++++ src/window.c | 42 ++++- src/window.h | 8 +- tests/fixtures/.gitkeep | 0 tests/fixtures/gen.py | 93 +++++++++++ tests/fixtures/plain.jpg | Bin 0 -> 769 bytes tests/fixtures/rgba.png | Bin 0 -> 108 bytes tests/fixtures/rot6.jpg | Bin 0 -> 768 bytes tests/fixtures/small.png | Bin 0 -> 97 bytes tests/meson.build | 43 +++++ tests/test_detect.c | 111 +++++++++++++ tests/test_loader_pixbuf.c | 163 ++++++++++++++++++ tests/test_open_and_show.c | 138 ++++++++++++++++ tests/test_window.c | 1 + 22 files changed, 1389 insertions(+), 19 deletions(-) create mode 100644 src/loader/backends/pixbuf.c create mode 100644 src/loader/detect.c create mode 100644 src/loader/detect.h create mode 100644 src/loader/loader.c create mode 100644 src/loader/loader.h create mode 100644 src/viewer.c create mode 100644 src/viewer.h delete mode 100644 tests/fixtures/.gitkeep create mode 100644 tests/fixtures/gen.py create mode 100644 tests/fixtures/plain.jpg create mode 100644 tests/fixtures/rgba.png create mode 100644 tests/fixtures/rot6.jpg create mode 100644 tests/fixtures/small.png create mode 100644 tests/test_detect.c create mode 100644 tests/test_loader_pixbuf.c create mode 100644 tests/test_open_and_show.c diff --git a/AGENTS.md b/AGENTS.md index f56a48a..8b3ac2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,14 @@ Shared helpers go in `tests/helpers/`; fixtures in `tests/fixtures/` (grow per milestone). Integration suites land with the milestone that first makes a flow possible — see `docs/IMPLEMENTATION.md` "Planned integration suites". +**Optional realistic corpus:** `./sample-images/` (a local, NOT git-tracked +613-image camera dump: 601 JPEG + 12 PNG, sizes up to 46MB, varied EXIF) is +the realistic test corpus for integration tests and leak-check sessions. Any +test or scripted session that uses it MUST skip cleanly when the directory is +absent (so CI, which only has `tests/fixtures/`, stays green) and MUST NOT +mutate the corpus — work on temp copies. The committed `tests/fixtures/` are +the CI-portable baseline; `./sample-images/` is a local supplement. + ## Conventions (enforced) - `docs/coding-conventions.md` summarizes; the `c-best-practices` skill wins. diff --git a/meson.build b/meson.build index e21b70c..b496e25 100644 --- a/meson.build +++ b/meson.build @@ -17,10 +17,11 @@ project('ggaze', 'c', ) # --- Always-required dependencies (the core viewer needs all of these) --- -gtk4_dep = dependency('gtk4') -glib_dep = dependency('glib-2.0') -gio_dep = dependency('gio-2.0') -adwaita_dep = dependency('libadwaita-1') +gtk4_dep = dependency('gtk4') +glib_dep = dependency('glib-2.0') +gio_dep = dependency('gio-2.0') +adwaita_dep = dependency('libadwaita-1') +gdkpixbuf_dep = dependency('gdk-pixbuf-2.0') # pixbuf loader backend # --- Optional backends (feature: auto/disabled/enabled). See meson_options.txt. gegl_dep = dependency('gegl-0.4', required : get_option('gegl')) @@ -52,13 +53,20 @@ src_inc = include_directories('src') # duplicating sources). Plain-C modules will join this library as they # land (navigator, trash, mover, ...). ggaze_lib = static_library('ggaze', - files('src/app.c', 'src/window.c'), + files( + 'src/app.c', + 'src/window.c', + 'src/viewer.c', + 'src/loader/loader.c', + 'src/loader/detect.c', + 'src/loader/backends/pixbuf.c', + ), include_directories : [inc, src_inc], - dependencies : [gtk4_dep, glib_dep, gio_dep, adwaita_dep], + dependencies : [gtk4_dep, glib_dep, gio_dep, adwaita_dep, gdkpixbuf_dep], install : false, ) -ggaze_deps = [gtk4_dep, glib_dep, gio_dep, adwaita_dep] +ggaze_deps = [gtk4_dep, glib_dep, gio_dep, adwaita_dep, gdkpixbuf_dep] subdir('src') subdir('data') diff --git a/src/loader/backends/pixbuf.c b/src/loader/backends/pixbuf.c new file mode 100644 index 0000000..e66f461 --- /dev/null +++ b/src/loader/backends/pixbuf.c @@ -0,0 +1,121 @@ +/*:* + * ggaze — GdkPixbuf loader backend (fallback) + * + * Decodes any GdkPixbuf-supported format (PNG/JPEG/GIF/WebP/TIFF/ICO) via a + * GdkPixbufLoader, applies the embedded EXIF Orientation (decision #26) so the + * returned GdkTexture is upright, and hands the result to the caller. Acts as + * the fallback backend: can_load() returns TRUE for unknown formats too (let + * GdkPixbuf try) and FALSE only for formats owned by the JXL/AVIF/HEIF + * backends in M5. + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include +#include +#include + +#include "../detect.h" +#include "../loader.h" + +static GdkTexture *_texture_from_pixbuf(GdkPixbuf *p_pix); + +static gboolean +_pixbuf_can_load(const guint8 *p_head, gsize u_len) { + switch (detect_format(p_head, u_len)) { + case GGAZE_FMT_JXL: + case GGAZE_FMT_AVIF: + case GGAZE_FMT_HEIF: + return (FALSE); /* owned by specific backends (M5) */ + case GGAZE_FMT_UNKNOWN: + case GGAZE_FMT_JPEG: + case GGAZE_FMT_PNG: + case GGAZE_FMT_GIF: + case GGAZE_FMT_WEBP: + case GGAZE_FMT_TIFF: + case GGAZE_FMT_ICO: + return (TRUE); + } + return (FALSE); /* unreachable; keeps -Wreturn-type calm */ +} + +static GdkTexture * +_pixbuf_load(GFile *p_file, GCancellable *p_cancel, GError **p_err) { + gchar *c_buf = NULL; + gsize u_len = 0; + if (!g_file_load_contents(p_file, p_cancel, &c_buf, &u_len, NULL, p_err)) { + return (NULL); + } + + GdkPixbufLoader *p_loader = gdk_pixbuf_loader_new(); + GError *p_sub = NULL; + if (!gdk_pixbuf_loader_write(p_loader, (const guchar *)c_buf, u_len, + &p_sub)) { + g_propagate_error(p_err, p_sub); + g_object_unref(p_loader); + g_free(c_buf); + return (NULL); + } + + /* Close may fail on truncated data but a pixbuf may still be available. */ + if (!gdk_pixbuf_loader_close(p_loader, &p_sub)) { + if (p_sub != NULL) { + g_error_free(p_sub); + } + } + + GdkPixbuf *p_pix = gdk_pixbuf_loader_get_pixbuf(p_loader); + if (p_pix == NULL) { + g_set_error(p_err, G_IO_ERROR, G_IO_ERROR_FAILED, + "could not decode image (GdkPixbuf produced no pixbuf)"); + g_object_unref(p_loader); + g_free(c_buf); + return (NULL); + } + + /* Honor EXIF Orientation so the texture is upright (decision #26). */ + 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)); + + GdkTexture *p_tex = _texture_from_pixbuf(p_use); + + g_object_unref(p_use); + g_object_unref(p_loader); + g_free(c_buf); + return (p_tex); +} + +const GgazeLoaderBackend pixbuf_backend = { + .can_load = _pixbuf_can_load, + .load = _pixbuf_load, +}; + +/* Build a GdkTexture from a GdkPixbuf without the deprecated + * gdk_texture_new_for_pixbuf(). GdkPixbuf stores non-premultiplied R8G8B8A8 + * when it has alpha; otherwise we add an alpha channel first. */ +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); + g_return_val_if_fail(i_w > 0 && i_h > 0, NULL); + + 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); +} \ No newline at end of file diff --git a/src/loader/detect.c b/src/loader/detect.c new file mode 100644 index 0000000..1806874 --- /dev/null +++ b/src/loader/detect.c @@ -0,0 +1,82 @@ +/*:* + * ggaze — image format detection + * + * Magic-byte sniffing. Pure function, no I/O, no GTK -> unit-testable. + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include "detect.h" + +#include + +GgazeFormat +detect_format(const guint8 *p_head, gsize u_len) { + if (p_head == NULL || u_len == 0) { + return (GGAZE_FMT_UNKNOWN); + } + + /* JPEG: FF D8 FF */ + if (u_len >= 3 && p_head[0] == 0xFF && p_head[1] == 0xD8 && + p_head[2] == 0xFF) { + return (GGAZE_FMT_JPEG); + } + + /* PNG: 89 50 4E 47 0D 0A 1A 0A */ + if (u_len >= 8 && p_head[0] == 0x89 && p_head[1] == 'P' && + p_head[2] == 'N' && p_head[3] == 'G' && p_head[4] == 0x0D && + p_head[5] == 0x0A && p_head[6] == 0x1A && p_head[7] == 0x0A) { + return (GGAZE_FMT_PNG); + } + + /* GIF: "GIF8" */ + if (u_len >= 4 && p_head[0] == 'G' && p_head[1] == 'I' && p_head[2] == 'F' && + p_head[3] == '8') { + return (GGAZE_FMT_GIF); + } + + /* WebP: RIFF .... WEBP */ + if (u_len >= 12 && memcmp(p_head, "RIFF", 4) == 0 && + memcmp(p_head + 8, "WEBP", 4) == 0) { + return (GGAZE_FMT_WEBP); + } + + /* TIFF: II 2A 00 (little) | MM 00 2A (big) */ + if (u_len >= 4 && ((p_head[0] == 'I' && p_head[1] == 'I' && + p_head[2] == 0x2A && p_head[3] == 0x00) || + (p_head[0] == 'M' && p_head[1] == 'M' && + p_head[2] == 0x00 && p_head[3] == 0x2A))) { + return (GGAZE_FMT_TIFF); + } + + /* ICO: 00 00 01 00 */ + if (u_len >= 4 && p_head[0] == 0x00 && p_head[1] == 0x00 && + p_head[2] == 0x01 && p_head[3] == 0x00) { + return (GGAZE_FMT_ICO); + } + + /* JPEG XL: codestream FF 0A, or container 00 00 00 0C "JXL " */ + if (u_len >= 2 && p_head[0] == 0xFF && p_head[1] == 0x0A) { + return (GGAZE_FMT_JXL); + } + if (u_len >= 12 && p_head[0] == 0x00 && p_head[1] == 0x00 && + p_head[2] == 0x00 && p_head[3] == 0x0C && + memcmp(p_head + 4, "JXL ", 4) == 0) { + return (GGAZE_FMT_JXL); + } + + /* AVIF / HEIF: ISO BMFF ftyp box at offset 4; brand at offset 8. */ + if (u_len >= 12 && memcmp(p_head + 4, "ftyp", 4) == 0) { + const guint8 *p_brand = p_head + 8; + if (memcmp(p_brand, "avif", 4) == 0 || memcmp(p_brand, "avis", 4) == 0) { + return (GGAZE_FMT_AVIF); + } + if (memcmp(p_brand, "heic", 4) == 0 || memcmp(p_brand, "heix", 4) == 0 || + memcmp(p_brand, "mif1", 4) == 0) { + return (GGAZE_FMT_HEIF); + } + } + + return (GGAZE_FMT_UNKNOWN); +} \ No newline at end of file diff --git a/src/loader/detect.h b/src/loader/detect.h new file mode 100644 index 0000000..95e549c --- /dev/null +++ b/src/loader/detect.h @@ -0,0 +1,40 @@ +#ifndef GGAZE_DETECT_H +#define GGAZE_DETECT_H + +/*:* + * ggaze — image format detection + * + * Content-sniffing (magic bytes), never extension-based. detect_format() takes + * the first N bytes of a file and returns the detected GgazeFormat. The loader + * uses this to dispatch to the right backend; see docs/architecture.md "Image + * decode" and docs/tech-stack.md. + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include + +G_BEGIN_DECLS + +typedef enum { + GGAZE_FMT_UNKNOWN = 0, + GGAZE_FMT_JPEG, /* FF D8 FF */ + GGAZE_FMT_PNG, /* 89 50 4E 47 0D 0A 1A 0A */ + GGAZE_FMT_GIF, /* "GIF8" */ + GGAZE_FMT_WEBP, /* RIFF .... WEBP */ + GGAZE_FMT_TIFF, /* II 2A 00 | MM 00 2A */ + GGAZE_FMT_ICO, /* 00 00 01 00 */ + GGAZE_FMT_JXL, /* FF 0A | "....JXL " container */ + GGAZE_FMT_AVIF, /* ftyp avif/avis */ + GGAZE_FMT_HEIF /* ftyp heic/heix/mif1 */ +} GgazeFormat; + +/* Sniff p_head (u_len bytes) and return the detected format. Never reads + * past u_len. Returns GGAZE_FMT_UNKNOWN if the buffer is too short or + * unrecognized. */ +GgazeFormat detect_format(const guint8 *p_head, gsize u_len); + +G_END_DECLS + +#endif /* GGAZE_DETECT_H */ \ No newline at end of file diff --git a/src/loader/loader.c b/src/loader/loader.c new file mode 100644 index 0000000..b1c4851 --- /dev/null +++ b/src/loader/loader.c @@ -0,0 +1,67 @@ +/*:* + * ggaze — image loader dispatcher + * + * Reads a short header, sniffs the format, and hands off to the first backend + * whose can_load() accepts it. BACKENDS[] is ordered so format-specific + * backends (JXL/AVIF/HEIF, M5) win over the GdkPixbuf fallback, which is last + * and accepts GGAZE_FMT_UNKNOWN. M1 ships only the pixbuf backend. + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include "loader.h" + +#include +#include + +#include "detect.h" + +/* Registered backends, priority order (specific first, fallback LAST). + * pixbuf_backend must remain last: it accepts GGAZE_FMT_UNKNOWN. */ +static const GgazeLoaderBackend *BACKENDS[] = { + /* jxl_backend, avif_backend, heif_backend land here in M5. */ + &pixbuf_backend, +}; + +#define GGAZE_SNIFF_LEN 64 + +static gsize +_read_header(GFile *p_file, GCancellable *p_cancel, guint8 *p_head, gsize u_max, + GError **p_err) { + GError *p_sub = NULL; + GFileInputStream *p_in = g_file_read(p_file, p_cancel, &p_sub); + if (p_in == NULL) { + g_propagate_error(p_err, p_sub); + return (0); + } + gssize n = g_input_stream_read(G_INPUT_STREAM(p_in), p_head, u_max, p_cancel, + &p_sub); + g_object_unref(p_in); + if (n < 0) { + g_propagate_error(p_err, p_sub); + return (0); + } + return ((gsize)n); +} + +GdkTexture * +loader_load(GFile *p_file, GCancellable *p_cancel, GError **p_err) { + g_return_val_if_fail(G_IS_FILE(p_file), NULL); + + 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) { + return (NULL); + } + + for (gsize u_i = 0; u_i < G_N_ELEMENTS(BACKENDS); u_i++) { + if (BACKENDS[u_i]->can_load(head, u_read)) { + return (BACKENDS[u_i]->load(p_file, p_cancel, p_err)); + } + } + + g_set_error(p_err, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED, + "unsupported or unrecognized image format"); + return (NULL); +} \ No newline at end of file diff --git a/src/loader/loader.h b/src/loader/loader.h new file mode 100644 index 0000000..0795097 --- /dev/null +++ b/src/loader/loader.h @@ -0,0 +1,45 @@ +#ifndef GGAZE_LOADER_H +#define GGAZE_LOADER_H + +/*:* + * ggaze — image loader + * + * Synchronous load API for M1; M3 adds loader_load_async/_finish on top of the + * same worker. The loader sniffs the format from the file header (detect.c) + * and dispatches to the first registered backend whose can_load() accepts the + * header. GdkPixbuf is the fallback backend (covers PNG/JPEG/GIF/WebP/TIFF/ICO + * and anything GdkPixbuf happens to understand); JXL/AVIF/HEIF get specific + * backends in M5. Every backend honors EXIF Orientation so the returned + * GdkTexture is upright (decision #26). See docs/architecture.md "Image + * decode". + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include +#include +#include + +G_BEGIN_DECLS + +/* 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); +} GgazeLoaderBackend; + +/* Backends register a const instance; the dispatcher (loader.c) iterates + * BACKENDS[] in priority order. pixbuf_backend is the fallback and MUST stay + * last (it accepts GGAZE_FMT_UNKNOWN). */ +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. */ +GdkTexture *loader_load(GFile *p_file, GCancellable *p_cancel, GError **p_err); + +G_END_DECLS + +#endif /* GGAZE_LOADER_H */ \ No newline at end of file diff --git a/src/viewer.c b/src/viewer.c new file mode 100644 index 0000000..e38d415 --- /dev/null +++ b/src/viewer.c @@ -0,0 +1,382 @@ +/*:* + * ggaze — large single-image viewer + * + * Custom GtkWidget (decision #31). Draws the GdkTexture scaled into the widget + * with letterboxing, zoom (fit / 100% / in / out), cursor-centered zoom, + * drag-to-pan with clamping, and a dark background. M1: synchronous single + * image. Compare-before/after (hold-Space) and tool overlays land in M9. + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include "viewer.h" + +#include +#include + +#define GGAZE_ZOOM_FACTOR 1.25 +#define GGAZE_ZOOM_MIN 0.02 +#define GGAZE_ZOOM_MAX 64.0 +#define GGAZE_PAN_STEP 24.0 + +struct _GgazeViewer { + GtkWidget parent_instance; + GdkTexture *p_texture; + gboolean b_fit; /* TRUE = fit-to-window; FALSE = use d_zoom */ + gdouble d_zoom; /* 1.0 = 100% (used when !b_fit) */ + gdouble d_pan_x; /* offset from centred, in widget px */ + gdouble d_pan_y; + gdouble d_drag_start_pan_x; + gdouble d_drag_start_pan_y; +}; + +G_DEFINE_TYPE(GgazeViewer, ggaze_viewer, GTK_TYPE_WIDGET) + +/* --- geometry -------------------------------------------------------------- + */ + +static int +_tex_w(GgazeViewer *p_v) { + return (p_v->p_texture != NULL) ? gdk_texture_get_width(p_v->p_texture) : 0; +} + +static int +_tex_h(GgazeViewer *p_v) { + return (p_v->p_texture != NULL) ? gdk_texture_get_height(p_v->p_texture) : 0; +} + +/* Display scale + clamped top-left for the current state. Also writes the + * clamped pan back so stored state matches what is drawn (no "dead zone" when + * a new drag begins from a clamped edge). */ +static void +_compute_geom(GgazeViewer *p_v, int i_w, int i_h, gdouble *p_scale, + gdouble *p_x, gdouble *p_y, gdouble *p_dw, gdouble *p_dh) { + int i_tw = _tex_w(p_v); + int i_th = _tex_h(p_v); + + gdouble s; + if (p_v->b_fit) { + if (i_tw <= 0 || i_th <= 0 || i_w <= 0 || i_h <= 0) { + s = 1.0; + } else { + s = MIN((gdouble)i_w / i_tw, (gdouble)i_h / i_th); + } + } else { + s = p_v->d_zoom; + } + + gdouble dw = (gdouble)i_tw * s; + gdouble dh = (gdouble)i_th * s; + gdouble x = ((gdouble)i_w - dw) / 2.0 + p_v->d_pan_x; + gdouble y = ((gdouble)i_h - dh) / 2.0 + p_v->d_pan_y; + + /* Clamp so the image can't drift off-screen. */ + gdouble cx = + CLAMP(x, MIN(0.0, (gdouble)i_w - dw), MAX(0.0, (gdouble)i_w - dw)); + gdouble cy = + CLAMP(y, MIN(0.0, (gdouble)i_h - dh), MAX(0.0, (gdouble)i_h - dh)); + /* Write clamped pan back so the stored state tracks the drawn position. */ + p_v->d_pan_x = cx - ((gdouble)i_w - dw) / 2.0; + p_v->d_pan_y = cy - ((gdouble)i_h - dh) / 2.0; + + if (p_scale != NULL) { + *p_scale = s; + } + if (p_x != NULL) { + *p_x = cx; + } + if (p_y != NULL) { + *p_y = cy; + } + if (p_dw != NULL) { + *p_dw = dw; + } + if (p_dh != NULL) { + *p_dh = dh; + } +} + +static gdouble +_current_scale(GgazeViewer *p_v) { + if (p_v->b_fit) { + gdouble s = 1.0; + _compute_geom(p_v, gtk_widget_get_width(GTK_WIDGET(p_v)), + gtk_widget_get_height(GTK_WIDGET(p_v)), &s, NULL, NULL, + NULL, NULL); + return (s); + } + return (p_v->d_zoom); +} + +/* Zoom around widget point (d_cx, d_cy), keeping that point over the same + * image pixel. _compute_geom clamps on the next draw. */ +static void +_zoom_at(GgazeViewer *p_v, gdouble d_cx, gdouble d_cy, gdouble d_new_zoom) { + if (p_v->p_texture == NULL) { + return; + } + d_new_zoom = CLAMP(d_new_zoom, GGAZE_ZOOM_MIN, GGAZE_ZOOM_MAX); + + int i_w = gtk_widget_get_width(GTK_WIDGET(p_v)); + int i_h = gtk_widget_get_height(GTK_WIDGET(p_v)); + gdouble s_old; + gdouble x_old, y_old; + _compute_geom(p_v, i_w, i_h, &s_old, &x_old, &y_old, NULL, NULL); + + /* Image-space pixel under the cursor before zoom. */ + gdouble img_x = (s_old > 0.0) ? (d_cx - x_old) / s_old : 0.0; + gdouble img_y = (s_old > 0.0) ? (d_cy - y_old) / s_old : 0.0; + + p_v->b_fit = FALSE; + p_v->d_zoom = d_new_zoom; + + gdouble s_new = d_new_zoom; + gdouble want_x = d_cx - img_x * s_new; + gdouble want_y = d_cy - img_y * s_new; + p_v->d_pan_x = want_x - ((gdouble)i_w - (gdouble)_tex_w(p_v) * s_new) / 2.0; + p_v->d_pan_y = want_y - ((gdouble)i_h - (gdouble)_tex_h(p_v) * s_new) / 2.0; + + gtk_widget_queue_draw(GTK_WIDGET(p_v)); +} + +/* --- GtkWidget vfuncs ----------------------------------------------------- */ + +static void +ggaze_viewer_measure(GtkWidget *p_widget, GtkOrientation o, int i_for_size, + int *p_min, int *p_nat, int *p_min_bl, int *p_nat_bl) { + (void)p_widget; + (void)o; + (void)i_for_size; + /* Fit-to-window fills the allocation via hexpand/vexpand; the viewer's + * own natural size is small so a large image doesn't grow the window. */ + *p_min = 1; + *p_nat = 1; + *p_min_bl = -1; + *p_nat_bl = -1; +} + +static void +ggaze_viewer_snapshot(GtkWidget *p_widget, GtkSnapshot *p_snap) { + GgazeViewer *p_v = GGAZE_VIEWER(p_widget); + int i_w = gtk_widget_get_width(p_widget); + int i_h = gtk_widget_get_height(p_widget); + + /* Dark background (configurable via settings in M10). */ + static const GdkRGBA BG = {0.07f, 0.07f, 0.07f, 1.0f}; + graphene_rect_t bg_rect = + GRAPHENE_RECT_INIT(0.f, 0.f, (float)i_w, (float)i_h); + gtk_snapshot_append_color(p_snap, &BG, &bg_rect); + + if (p_v->p_texture == NULL) { + return; + } + + gdouble x, y, dw, dh; + _compute_geom(p_v, i_w, i_h, NULL, &x, &y, &dw, &dh); + if (dw <= 0.0 || dh <= 0.0) { + return; + } + graphene_rect_t rect = + GRAPHENE_RECT_INIT((float)x, (float)y, (float)dw, (float)dh); + gtk_snapshot_append_texture(p_snap, p_v->p_texture, &rect); +} + +static void +ggaze_viewer_dispose(GObject *p_obj) { + GgazeViewer *p_v = GGAZE_VIEWER(p_obj); + g_clear_object(&p_v->p_texture); + G_OBJECT_CLASS(ggaze_viewer_parent_class)->dispose(p_obj); +} + +static void +ggaze_viewer_class_init(GgazeViewerClass *p_klass) { + GtkWidgetClass *p_wc = GTK_WIDGET_CLASS(p_klass); + GObjectClass *p_oc = G_OBJECT_CLASS(p_klass); + p_wc->measure = ggaze_viewer_measure; + p_wc->snapshot = ggaze_viewer_snapshot; + p_oc->dispose = ggaze_viewer_dispose; + gtk_widget_class_set_css_name(p_wc, "ggazeviewer"); +} + +/* --- controllers ---------------------------------------------------------- */ + +static void +drag_begin_cb(GtkGestureDrag *p_gesture, gdouble d_x, gdouble d_y, + gpointer p_data) { + GgazeViewer *p_v = GGAZE_VIEWER(p_data); + (void)p_gesture; + (void)d_x; + (void)d_y; + p_v->d_drag_start_pan_x = p_v->d_pan_x; + p_v->d_drag_start_pan_y = p_v->d_pan_y; +} + +static void +drag_update_cb(GtkGestureDrag *p_gesture, gdouble d_dx, gdouble d_dy, + gpointer p_data) { + GgazeViewer *p_v = GGAZE_VIEWER(p_data); + (void)p_gesture; + p_v->d_pan_x = p_v->d_drag_start_pan_x + d_dx; + p_v->d_pan_y = p_v->d_drag_start_pan_y + d_dy; + gtk_widget_queue_draw(GTK_WIDGET(p_v)); +} + +static gboolean +scroll_cb(GtkEventControllerScroll *p_scroll, gdouble d_dx, gdouble d_dy, + gpointer p_data) { + GgazeViewer *p_v = GGAZE_VIEWER(p_data); + if (p_v->p_texture == NULL) { + return (FALSE); + } + (void)d_dx; + + gdouble d_cx = (gdouble)gtk_widget_get_width(GTK_WIDGET(p_v)) / 2.0; + gdouble d_cy = (gdouble)gtk_widget_get_height(GTK_WIDGET(p_v)) / 2.0; + GdkEvent *p_event = + gtk_event_controller_get_current_event(GTK_EVENT_CONTROLLER(p_scroll)); + if (p_event != NULL) { + gdk_event_get_position(p_event, &d_cx, &d_cy); + } + + gdouble d_factor = + (d_dy < 0.0) ? GGAZE_ZOOM_FACTOR : 1.0 / GGAZE_ZOOM_FACTOR; + _zoom_at(p_v, d_cx, d_cy, _current_scale(p_v) * d_factor); + return (TRUE); +} + +static gboolean +key_cb(GtkEventControllerKey *p_key, guint u_keyval, guint u_keycode, + GdkModifierType e_state, gpointer p_data) { + GgazeViewer *p_v = GGAZE_VIEWER(p_data); + (void)p_key; + (void)u_keycode; + (void)e_state; + gboolean b_handled = TRUE; + + switch (u_keyval) { + case GDK_KEY_plus: + case GDK_KEY_equal: + ggaze_viewer_zoom_in(p_v); + break; + case GDK_KEY_minus: + case GDK_KEY_underscore: + ggaze_viewer_zoom_out(p_v); + break; + case GDK_KEY_0: + ggaze_viewer_toggle_fit_100(p_v); + break; + case GDK_KEY_j: + ggaze_viewer_pan(p_v, 0.0, GGAZE_PAN_STEP); + break; + case GDK_KEY_k: + ggaze_viewer_pan(p_v, 0.0, -GGAZE_PAN_STEP); + break; + case GDK_KEY_H: + ggaze_viewer_pan(p_v, -GGAZE_PAN_STEP, 0.0); + break; + case GDK_KEY_L: + ggaze_viewer_pan(p_v, GGAZE_PAN_STEP, 0.0); + break; + default: + b_handled = FALSE; + break; + } + return (b_handled); +} + +static void +ggaze_viewer_init(GgazeViewer *p_v) { + p_v->p_texture = NULL; + p_v->b_fit = TRUE; + p_v->d_zoom = 1.0; + p_v->d_pan_x = 0.0; + p_v->d_pan_y = 0.0; + + gtk_widget_set_focusable(GTK_WIDGET(p_v), TRUE); + + GtkGesture *p_drag = gtk_gesture_drag_new(); + gtk_widget_add_controller(GTK_WIDGET(p_v), GTK_EVENT_CONTROLLER(p_drag)); + g_signal_connect(p_drag, "drag-begin", G_CALLBACK(drag_begin_cb), p_v); + g_signal_connect(p_drag, "drag-update", G_CALLBACK(drag_update_cb), p_v); + + GtkEventController *p_scroll = + gtk_event_controller_scroll_new(GTK_EVENT_CONTROLLER_SCROLL_VERTICAL); + gtk_widget_add_controller(GTK_WIDGET(p_v), p_scroll); + g_signal_connect(p_scroll, "scroll", G_CALLBACK(scroll_cb), p_v); + + GtkEventController *p_key = gtk_event_controller_key_new(); + gtk_widget_add_controller(GTK_WIDGET(p_v), p_key); + g_signal_connect(p_key, "key-pressed", G_CALLBACK(key_cb), p_v); +} + +/* --- public API ----------------------------------------------------------- */ + +GtkWidget * +ggaze_viewer_new(void) { + return (GTK_WIDGET(g_object_new(GGAZE_TYPE_VIEWER, NULL))); +} + +void +ggaze_viewer_set_texture(GgazeViewer *p_viewer, GdkTexture *p_texture) { + g_return_if_fail(GGAZE_IS_VIEWER(p_viewer)); + g_set_object(&p_viewer->p_texture, p_texture); + p_viewer->b_fit = TRUE; + p_viewer->d_zoom = 1.0; + p_viewer->d_pan_x = 0.0; + p_viewer->d_pan_y = 0.0; + gtk_widget_queue_draw(GTK_WIDGET(p_viewer)); +} + +GdkTexture * +ggaze_viewer_get_texture(GgazeViewer *p_viewer) { + g_return_val_if_fail(GGAZE_IS_VIEWER(p_viewer), NULL); + return (p_viewer->p_texture); /* (transfer none) */ +} + +void +ggaze_viewer_zoom_in(GgazeViewer *p_viewer) { + g_return_if_fail(GGAZE_IS_VIEWER(p_viewer)); + _zoom_at(p_viewer, (gdouble)gtk_widget_get_width(GTK_WIDGET(p_viewer)) / 2.0, + (gdouble)gtk_widget_get_height(GTK_WIDGET(p_viewer)) / 2.0, + _current_scale(p_viewer) * GGAZE_ZOOM_FACTOR); +} + +void +ggaze_viewer_zoom_out(GgazeViewer *p_viewer) { + g_return_if_fail(GGAZE_IS_VIEWER(p_viewer)); + _zoom_at(p_viewer, (gdouble)gtk_widget_get_width(GTK_WIDGET(p_viewer)) / 2.0, + (gdouble)gtk_widget_get_height(GTK_WIDGET(p_viewer)) / 2.0, + _current_scale(p_viewer) / GGAZE_ZOOM_FACTOR); +} + +void +ggaze_viewer_toggle_fit_100(GgazeViewer *p_viewer) { + g_return_if_fail(GGAZE_IS_VIEWER(p_viewer)); + if (p_viewer->b_fit) { + p_viewer->b_fit = FALSE; + p_viewer->d_zoom = 1.0; + p_viewer->d_pan_x = 0.0; + p_viewer->d_pan_y = 0.0; + } else { + p_viewer->b_fit = TRUE; + } + gtk_widget_queue_draw(GTK_WIDGET(p_viewer)); +} + +void +ggaze_viewer_fit(GgazeViewer *p_viewer) { + g_return_if_fail(GGAZE_IS_VIEWER(p_viewer)); + p_viewer->b_fit = TRUE; + p_viewer->d_pan_x = 0.0; + p_viewer->d_pan_y = 0.0; + gtk_widget_queue_draw(GTK_WIDGET(p_viewer)); +} + +void +ggaze_viewer_pan(GgazeViewer *p_viewer, gdouble d_dx, gdouble d_dy) { + g_return_if_fail(GGAZE_IS_VIEWER(p_viewer)); + p_viewer->d_pan_x += d_dx; + p_viewer->d_pan_y += d_dy; + gtk_widget_queue_draw(GTK_WIDGET(p_viewer)); +} \ No newline at end of file diff --git a/src/viewer.h b/src/viewer.h new file mode 100644 index 0000000..81bac79 --- /dev/null +++ b/src/viewer.h @@ -0,0 +1,42 @@ +#ifndef GGAZE_VIEWER_H +#define GGAZE_VIEWER_H + +/*:* + * ggaze — large single-image viewer + * + * GgazeViewer : GtkWidget is the custom large-view canvas (decision #31, not + * GtkPicture). It owns a GdkTexture plus zoom/pan/fit state and draws via GTK4 + * render nodes. Zoom is cursor-centered (mouse/pinch) or window-centered + * (keys); panning clamps so the image can't drift off-screen. See + * docs/ui-and-interactions.md "Zoom behavior" and docs/architecture.md + * "Responsibilities / viewer". + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include +#include + +G_BEGIN_DECLS + +#define GGAZE_TYPE_VIEWER (ggaze_viewer_get_type()) +G_DECLARE_FINAL_TYPE(GgazeViewer, ggaze_viewer, GGAZE, VIEWER, GtkWidget) + +GtkWidget *ggaze_viewer_new(void); + +/* Take p_texture (refs it; the caller still owns its own ref and should unref + * when done). Resets to fit-to-window, clears pan. NULL clears the display. */ +void ggaze_viewer_set_texture(GgazeViewer *p_viewer, GdkTexture *p_texture); +GdkTexture *ggaze_viewer_get_texture(GgazeViewer *p_viewer); /* (transfer none) */ + +/* Zoom + pan actions (also reachable via the on-widget controllers). */ +void ggaze_viewer_zoom_in(GgazeViewer *p_viewer); +void ggaze_viewer_zoom_out(GgazeViewer *p_viewer); +void ggaze_viewer_toggle_fit_100(GgazeViewer *p_viewer); +void ggaze_viewer_fit(GgazeViewer *p_viewer); +void ggaze_viewer_pan(GgazeViewer *p_viewer, gdouble d_dx, gdouble d_dy); + +G_END_DECLS + +#endif /* GGAZE_VIEWER_H */ \ No newline at end of file diff --git a/src/window.c b/src/window.c index 049be4e..88df578 100644 --- a/src/window.c +++ b/src/window.c @@ -2,8 +2,10 @@ * ggaze — main window * * Implements GgazeWindow. Builds an AdwHeaderBar + a GtkStack with two named - * placeholder children ("grid", "large"); the real views arrive in M1 (large) - * and M7 (grid). Tracks the current GFile for later milestones. + * children: "grid" (placeholder until M7) and "large" (the GgazeViewer from + * M1). ggaze_window_open() loads the file via the loader and shows it in the + * viewer. Real file-vs-folder resolution lands in M2 (navigator); M1 just loads + * the one file. See docs/architecture.md "Responsibilities / window". * * Copyright (c) 2026 ggaze contributors * SPDX-License-Identifier: GPL-3.0-or-later @@ -14,10 +16,14 @@ #include #include +#include "viewer.h" +#include "loader/loader.h" + struct _GgazeWindow { GtkApplicationWindow parent_instance; - GFile *p_file; /* current file/folder, remembered for later use */ - GtkWidget *p_stack; /* GtkStack: grid/large placeholder children */ + GFile *p_file; /* current file/folder, remembered for later use */ + GtkWidget *p_stack; /* GtkStack: grid (placeholder) / large (viewer) */ + GtkWidget *p_viewer; /* GgazeViewer — the large view */ }; G_DEFINE_TYPE(GgazeWindow, ggaze_window, GTK_TYPE_APPLICATION_WINDOW) @@ -26,6 +32,8 @@ static void ggaze_window_dispose(GObject *p_obj) { GgazeWindow *p_win = GGAZE_WINDOW(p_obj); g_clear_object(&p_win->p_file); + /* p_stack/p_viewer are GtkWidgets parented to the window; GTK releases them. + */ G_OBJECT_CLASS(ggaze_window_parent_class)->dispose(p_obj); } @@ -41,7 +49,8 @@ ggaze_window_init(GgazeWindow *p_win) { GtkWidget *p_header = adw_header_bar_new(); gtk_window_set_titlebar(GTK_WINDOW(p_win), p_header); - /* Two-view stack. Children are placeholders for M1 (large) and M7 (grid). */ + /* Two-view stack: "grid" is a placeholder until M7; "large" is the viewer. + */ p_win->p_stack = gtk_stack_new(); gtk_stack_set_transition_type(GTK_STACK(p_win->p_stack), GTK_STACK_TRANSITION_TYPE_CROSSFADE); @@ -49,10 +58,13 @@ ggaze_window_init(GgazeWindow *p_win) { GtkWidget *p_grid = gtk_label_new("grid"); gtk_widget_add_css_class(p_grid, "dim-label"); - GtkWidget *p_large = gtk_label_new("large"); - gtk_widget_add_css_class(p_large, "dim-label"); gtk_stack_add_named(GTK_STACK(p_win->p_stack), p_grid, "grid"); - gtk_stack_add_named(GTK_STACK(p_win->p_stack), p_large, "large"); + + p_win->p_viewer = ggaze_viewer_new(); + gtk_widget_set_hexpand(p_win->p_viewer, TRUE); + gtk_widget_set_vexpand(p_win->p_viewer, TRUE); + gtk_stack_add_named(GTK_STACK(p_win->p_stack), p_win->p_viewer, "large"); + gtk_stack_set_visible_child_name(GTK_STACK(p_win->p_stack), "grid"); } @@ -68,7 +80,21 @@ ggaze_window_open(GgazeWindow *p_win, GFile *p_file) { g_return_if_fail(GGAZE_IS_WINDOW(p_win)); g_return_if_fail(G_IS_FILE(p_file)); g_set_object(&p_win->p_file, p_file); + char *c_name = g_file_get_basename(p_file); gtk_window_set_title(GTK_WINDOW(p_win), c_name); + + /* Load and display (M1: synchronous; M3 makes this async with cancel). */ + GError *p_err = NULL; + GdkTexture *p_tex = loader_load(p_file, NULL, &p_err); + if (p_tex != NULL) { + 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); /* viewer holds its own ref */ + } else { + const gchar *c_msg = (p_err != NULL) ? p_err->message : "unknown error"; + g_warning("ggaze: failed to load %s: %s", c_name, c_msg); + g_clear_error(&p_err); + } g_free(c_name); } \ No newline at end of file diff --git a/src/window.h b/src/window.h index aeddf64..5261a8f 100644 --- a/src/window.h +++ b/src/window.h @@ -5,10 +5,10 @@ * ggaze — main window * * GgazeWindow : GtkApplicationWindow owns the layout: an AdwHeaderBar and a - * GtkStack with two placeholder children (`grid`, `large`). M0 keeps the - * stack empty of real views; gridview (M7) and viewer (M1) populate it. The - * window remembers the current GFile so later milestones can build on it. - * See docs/architecture.md "Responsibilities / window". + * GtkStack with two children (`grid`, `large`). The grid child is a placeholder + * until M7; the large child is the GgazeViewer (M1). The window remembers the + * current GFile so later milestones can build on it. See + * docs/architecture.md "Responsibilities / window". * * Copyright (c) 2026 ggaze contributors * SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/fixtures/.gitkeep b/tests/fixtures/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/fixtures/gen.py b/tests/fixtures/gen.py new file mode 100644 index 0000000..8ead202 --- /dev/null +++ b/tests/fixtures/gen.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Generate ggaze loader test fixtures (run manually; outputs are committed). + +Produces, under the directory passed as argv[1] (or beside this script): + plain.jpg 6x3 JPEG, EXIF Orientation = 1 -> loader yields 6x3 + rot6.jpg 8x4 JPEG, EXIF Orientation = 6 -> loader yields 4x8 + small.png 5x2 PNG (no orientation) -> loader yields 5x2 + +Uses only the Python stdlib + cjpeg + exiftool. +""" +import os +import struct +import subprocess +import sys +import zlib + + +def write_ppm(path, w, h): + # Distinct-ish RGB so frames are not blank. + data = bytearray() + for y in range(h): + for x in range(w): + data += bytes((x * 30 % 256, y * 60 % 256, (x + y) * 17 % 256)) + with open(path, "wb") as f: + f.write(b"P6\n%d %d\n255\n" % (w, h)) + f.write(bytes(data)) + + +def write_png(path, w, h, alpha=False): + sig = b"\x89PNG\r\n\x1a\n" + color_type = 6 if alpha else 2 # 6 = RGBA, 2 = RGB + channels = 4 if alpha else 3 + + def chunk(typ, data): + return (struct.pack(">I", len(data)) + typ + data + + struct.pack(">I", zlib.crc32(typ + data) & 0xffffffff)) + + ihdr = struct.pack(">IIBBBBB", w, h, 8, color_type, 0, 0, 0) + raw = bytearray() + for y in range(h): + raw.append(0) # filter: none + for x in range(w): + r = (x * 50) % 256 + g = (y * 90) % 256 + b = (x * 7 + y * 11) % 256 + if alpha: + a = (255 - (x + y) * 30) % 256 # varied alpha + raw += bytes((r, g, b, a)) + else: + raw += bytes((r, g, b)) + idat = zlib.compress(bytes(raw)) + with open(path, "wb") as f: + f.write(sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + + chunk(b"IEND", b"")) + + +def main(): + out = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname( + os.path.abspath(__file__)) + + # plain.jpg: 6x3, orientation 1 + write_ppm(os.path.join(out, "_plain.ppm"), 6, 3) + subprocess.run(["cjpeg", "-quality", "90", "-outfile", + os.path.join(out, "plain.jpg"), + os.path.join(out, "_plain.ppm")], check=True) + subprocess.run(["exiftool", "-overwrite_original", "-Orientation#=1", + os.path.join(out, "plain.jpg")], check=True) + + # rot6.jpg: 8x4, orientation 6 (rotate 90 CW -> displayed 4x8) + write_ppm(os.path.join(out, "_rot6.ppm"), 8, 4) + subprocess.run(["cjpeg", "-quality", "90", "-outfile", + os.path.join(out, "rot6.jpg"), + os.path.join(out, "_rot6.ppm")], check=True) + subprocess.run(["exiftool", "-overwrite_original", "-Orientation#=6", + os.path.join(out, "rot6.jpg")], check=True) + + # small.png: 5x2 RGB + write_png(os.path.join(out, "small.png"), 5, 2, alpha=False) + + # rgba.png: 5x2 RGBA (exercises the has-alpha branch of texture_from_pixbuf) + write_png(os.path.join(out, "rgba.png"), 5, 2, alpha=True) + + # tidy intermediates + for p in ("_plain.ppm", "_rot6.ppm"): + try: + os.remove(os.path.join(out, p)) + except FileNotFoundError: + pass + print("fixtures generated in", out) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/fixtures/plain.jpg b/tests/fixtures/plain.jpg new file mode 100644 index 0000000..9f3022c Binary files /dev/null and b/tests/fixtures/plain.jpg differ diff --git a/tests/fixtures/rgba.png b/tests/fixtures/rgba.png new file mode 100644 index 0000000..fe8a3f4 Binary files /dev/null and b/tests/fixtures/rgba.png differ diff --git a/tests/fixtures/rot6.jpg b/tests/fixtures/rot6.jpg new file mode 100644 index 0000000..be69ba6 Binary files /dev/null and b/tests/fixtures/rot6.jpg differ diff --git a/tests/fixtures/small.png b/tests/fixtures/small.png new file mode 100644 index 0000000..6c6656d Binary files /dev/null and b/tests/fixtures/small.png differ diff --git a/tests/meson.build b/tests/meson.build index 4c02ccd..76d4962 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -9,6 +9,17 @@ # Coverage: meson setup -Db_coverage=true build # meson test -C build # ninja -C build coverage (needs lcov + genhtml) +# +# Fixture dirs are passed to tests via env vars: +# GGAZE_FIXTURES_DIR committed tests/fixtures/ (CI-portable baseline) +# GGAZE_SAMPLE_DIR local ./sample-images (not git-tracked; tests skip +# cleanly when absent so CI stays green) + +fixtures_env = environment() +fixtures_env.set('GGAZE_FIXTURES_DIR', + join_paths(meson.project_source_root(), 'tests', 'fixtures')) +fixtures_env.set('GGAZE_SAMPLE_DIR', + join_paths(meson.project_source_root(), 'sample-images')) # --- unit track --- test_bootstrap = executable( @@ -34,6 +45,26 @@ test('app', test_app, depends : ggaze_exe, ) +test_detect = executable( + 'test_detect', + ['test_detect.c', ggaze_conf_h], + include_directories : [inc, src_inc], + dependencies : [glib_dep], + link_with : ggaze_lib, + install : false, +) +test('detect', test_detect, suite : 'unit', env : fixtures_env) + +test_loader_pixbuf = executable( + 'test_loader_pixbuf', + ['test_loader_pixbuf.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_pixbuf', test_loader_pixbuf, suite : 'unit', env : fixtures_env) + # --- integration track (needs a display; CI uses xvfb-run) --- test_window = executable( 'test_window', @@ -45,4 +76,16 @@ test_window = executable( ) test('window', test_window, suite : 'integration') +test_open_and_show = executable( + 'test_open_and_show', + ['test_open_and_show.c', ggaze_conf_h], + include_directories : [inc, src_inc], + dependencies : ggaze_deps, + link_with : ggaze_lib, + install : false, +) +test('open_and_show', test_open_and_show, + suite : 'integration', + env : fixtures_env) + subdir('integration') \ No newline at end of file diff --git a/tests/test_detect.c b/tests/test_detect.c new file mode 100644 index 0000000..b12bf70 --- /dev/null +++ b/tests/test_detect.c @@ -0,0 +1,111 @@ +/*:* + * ggaze — format detection unit test + * + * Feeds magic-byte buffers to detect_format() and asserts the result. No I/O, + * no display. Covers every format plus edge cases (empty, too-short, garbage). + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include "loader/detect.h" + +#include + +static void +test_jpeg(void) { + const guint8 h[] = {0xFF, 0xD8, 0xFF, 0xE0, 0x10, 0x00}; + g_assert_cmpint(detect_format(h, G_N_ELEMENTS(h)), ==, GGAZE_FMT_JPEG); +} + +static void +test_png(void) { + const guint8 h[] = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0}; + g_assert_cmpint(detect_format(h, G_N_ELEMENTS(h)), ==, GGAZE_FMT_PNG); +} + +static void +test_gif(void) { + const guint8 h[] = {'G', 'I', 'F', '8', '9', 'a'}; + g_assert_cmpint(detect_format(h, G_N_ELEMENTS(h)), ==, GGAZE_FMT_GIF); +} + +static void +test_webp(void) { + const guint8 h[] = "RIFF\x00\x00\x00\x00WEBP"; + g_assert_cmpint(detect_format(h, 12), ==, GGAZE_FMT_WEBP); +} + +static void +test_tiff_le(void) { + const guint8 h[] = {'I', 'I', 0x2A, 0x00}; + g_assert_cmpint(detect_format(h, 4), ==, GGAZE_FMT_TIFF); +} + +static void +test_tiff_be(void) { + const guint8 h[] = {'M', 'M', 0x00, 0x2A}; + g_assert_cmpint(detect_format(h, 4), ==, GGAZE_FMT_TIFF); +} + +static void +test_ico(void) { + const guint8 h[] = {0x00, 0x00, 0x01, 0x00}; + g_assert_cmpint(detect_format(h, 4), ==, GGAZE_FMT_ICO); +} + +static void +test_jxl_codestream(void) { + const guint8 h[] = {0xFF, 0x0A, 0, 0}; + g_assert_cmpint(detect_format(h, 2), ==, GGAZE_FMT_JXL); +} + +static void +test_jxl_container(void) { + const guint8 h[] = {0x00, 0x00, 0x00, 0x0C, 'J', 'X', 'L', ' ', 0, 0, 0, 0}; + g_assert_cmpint(detect_format(h, 12), ==, GGAZE_FMT_JXL); +} + +static void +test_avif(void) { + const guint8 h[] = {0, 0, 0, 0, 'f', 't', 'y', 'p', 'a', 'v', 'i', 'f'}; + g_assert_cmpint(detect_format(h, 12), ==, GGAZE_FMT_AVIF); +} + +static void +test_heif(void) { + const guint8 h[] = {0, 0, 0, 0, 'f', 't', 'y', 'p', 'h', 'e', 'i', 'c'}; + g_assert_cmpint(detect_format(h, 12), ==, GGAZE_FMT_HEIF); +} + +static void +test_unknown_garbage(void) { + const guint8 h[] = {'h', 'e', 'l', 'l', 'o'}; + g_assert_cmpint(detect_format(h, G_N_ELEMENTS(h)), ==, GGAZE_FMT_UNKNOWN); +} + +static void +test_empty_and_short(void) { + g_assert_cmpint(detect_format(NULL, 0), ==, GGAZE_FMT_UNKNOWN); + const guint8 h[] = {0xFF}; + g_assert_cmpint(detect_format(h, 1), ==, GGAZE_FMT_UNKNOWN); +} + +int +main(int i_argc, char **c_argv) { + g_test_init(&i_argc, &c_argv, NULL); + g_test_add_func("/detect/jpeg", test_jpeg); + g_test_add_func("/detect/png", test_png); + g_test_add_func("/detect/gif", test_gif); + g_test_add_func("/detect/webp", test_webp); + g_test_add_func("/detect/tiff_le", test_tiff_le); + g_test_add_func("/detect/tiff_be", test_tiff_be); + g_test_add_func("/detect/ico", test_ico); + g_test_add_func("/detect/jxl_codestream", test_jxl_codestream); + g_test_add_func("/detect/jxl_container", test_jxl_container); + g_test_add_func("/detect/avif", test_avif); + g_test_add_func("/detect/heif", test_heif); + g_test_add_func("/detect/unknown_garbage", test_unknown_garbage); + g_test_add_func("/detect/empty_and_short", test_empty_and_short); + return (g_test_run()); +} \ No newline at end of file diff --git a/tests/test_loader_pixbuf.c b/tests/test_loader_pixbuf.c new file mode 100644 index 0000000..ba37522 --- /dev/null +++ b/tests/test_loader_pixbuf.c @@ -0,0 +1,163 @@ +/*:* + * ggaze — GdkPixbuf loader backend unit test + * + * Loads committed fixtures via loader_load() and asserts the resulting + * GdkTexture dimensions, including the rotated-EXIF case (decision #26): an + * 8x4 JPEG with Orientation=6 must load as 4x8 after + * gdk_pixbuf_apply_embedded_orientation. No display needed (texture creation + * from a pixbuf is headless). Fixture dir comes from $GGAZE_FIXTURES_DIR + * (set by meson). See ./sample-images for the optional realistic corpus. + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include "loader/loader.h" + +#include +#include +#include +#include + +static GdkTexture * +load_fixture(const gchar *c_name) { + const gchar *c_dir = g_getenv("GGAZE_FIXTURES_DIR"); + g_assert_nonnull(c_dir); + gchar *c_path = g_build_filename(c_dir, c_name, NULL); + GFile *p_file = g_file_new_for_path(c_path); + GError *p_err = NULL; + GdkTexture *p_tex = loader_load(p_file, NULL, &p_err); + g_assert_no_error(p_err); + g_object_unref(p_file); + g_free(c_path); + return (p_tex); +} + +static void +test_plain_jpeg(void) { + /* 6x3, Orientation = 1 -> 6x3 (no rotation applied). */ + GdkTexture *p_tex = load_fixture("plain.jpg"); + g_assert_cmpint(gdk_texture_get_width(p_tex), ==, 6); + g_assert_cmpint(gdk_texture_get_height(p_tex), ==, 3); + g_object_unref(p_tex); +} + +static void +test_rotated_exif_jpeg(void) { + /* 8x4, Orientation = 6 (rotate 90 CW) -> upright 4x8 (decision #26). */ + GdkTexture *p_tex = load_fixture("rot6.jpg"); + g_assert_cmpint(gdk_texture_get_width(p_tex), ==, 4); + g_assert_cmpint(gdk_texture_get_height(p_tex), ==, 8); + g_object_unref(p_tex); +} + +static void +test_png(void) { + /* 5x2 PNG, no orientation. */ + GdkTexture *p_tex = load_fixture("small.png"); + g_assert_cmpint(gdk_texture_get_width(p_tex), ==, 5); + g_assert_cmpint(gdk_texture_get_height(p_tex), ==, 2); + g_object_unref(p_tex); +} + +static void +test_missing_file_errors(void) { + const gchar *c_dir = g_getenv("GGAZE_FIXTURES_DIR"); + g_assert_nonnull(c_dir); + gchar *c_path = g_build_filename(c_dir, "does-not-exist.jpg", NULL); + GFile *p_file = g_file_new_for_path(c_path); + GError *p_err = NULL; + GdkTexture *p_tex = loader_load(p_file, NULL, &p_err); + g_assert_null(p_tex); + g_assert_nonnull(p_err); + g_error_free(p_err); + g_object_unref(p_file); + g_free(c_path); +} + +/* Write raw bytes to a temp file and load them (magic-byte / corrupt cases). */ +static GdkTexture * +load_bytes(const guint8 *p_buf, gsize u_len, GError **p_err) { + gchar *c_path = NULL; + GError *p_sub = NULL; + gint i_fd = g_file_open_tmp("ggaze-XXXXXX", &c_path, &p_sub); + g_assert_no_error(p_sub); + g_assert_cmpint(i_fd, >=, 0); + gsize u_off = 0; + while (u_off < u_len) { + gssize n = write(i_fd, p_buf + u_off, u_len - u_off); + g_assert_cmpint(n, >, 0); + u_off += (gsize)n; + } + close(i_fd); + GFile *p_file = g_file_new_for_path(c_path); + GdkTexture *p_tex = loader_load(p_file, NULL, p_err); + g_object_unref(p_file); + unlink(c_path); + g_free(c_path); + return (p_tex); +} + +static void +assert_unsupported(const guint8 *p_buf, gsize u_len) { + GError *p_err = NULL; + GdkTexture *p_tex = load_bytes(p_buf, u_len, &p_err); + g_assert_null(p_tex); + g_assert_nonnull(p_err); + g_assert_cmpint(p_err->code, ==, G_IO_ERROR_NOT_SUPPORTED); + g_error_free(p_err); +} + +static void +test_unsupported_jxl(void) { + const guint8 h[] = {0xFF, 0x0A, 0x10, 0x00}; /* JXL codestream magic */ + assert_unsupported(h, G_N_ELEMENTS(h)); +} + +static void +test_unsupported_avif(void) { + const guint8 h[] = {0, 0, 0, 0, 'f', 't', 'y', 'p', 'a', 'v', 'i', 'f'}; + assert_unsupported(h, G_N_ELEMENTS(h)); +} + +static void +test_unsupported_heif(void) { + const guint8 h[] = {0, 0, 0, 0, 'f', 't', 'y', 'p', 'h', 'e', 'i', 'c'}; + assert_unsupported(h, G_N_ELEMENTS(h)); +} + +static void +test_corrupt_jpeg(void) { + /* Truncated JPEG: SOI + APP0 marker, no image data. GdkPixbuf fails. */ + const guint8 h[] = {0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', + 'I', 'F', 0, 1, 1, 0, 0, 0}; + GError *p_err = NULL; + GdkTexture *p_tex = load_bytes(h, G_N_ELEMENTS(h), &p_err); + g_assert_null(p_tex); + g_assert_nonnull(p_err); + g_error_free(p_err); +} + +static void +test_rgba_png(void) { + /* 5x2 RGBA PNG -> exercises the has-alpha branch of texture_from_pixbuf. */ + GdkTexture *p_tex = load_fixture("rgba.png"); + g_assert_cmpint(gdk_texture_get_width(p_tex), ==, 5); + g_assert_cmpint(gdk_texture_get_height(p_tex), ==, 2); + g_object_unref(p_tex); +} + +int +main(int i_argc, char **c_argv) { + g_test_init(&i_argc, &c_argv, NULL); + g_test_add_func("/loader/pixbuf/plain_jpeg", test_plain_jpeg); + g_test_add_func("/loader/pixbuf/rotated_exif", test_rotated_exif_jpeg); + g_test_add_func("/loader/pixbuf/png", test_png); + g_test_add_func("/loader/pixbuf/missing_file", test_missing_file_errors); + g_test_add_func("/loader/pixbuf/unsupported_jxl", test_unsupported_jxl); + g_test_add_func("/loader/pixbuf/unsupported_avif", test_unsupported_avif); + g_test_add_func("/loader/pixbuf/unsupported_heif", test_unsupported_heif); + g_test_add_func("/loader/pixbuf/corrupt_jpeg", test_corrupt_jpeg); + g_test_add_func("/loader/pixbuf/rgba_png", test_rgba_png); + return (g_test_run()); +} \ No newline at end of file diff --git a/tests/test_open_and_show.c b/tests/test_open_and_show.c new file mode 100644 index 0000000..e707555 --- /dev/null +++ b/tests/test_open_and_show.c @@ -0,0 +1,138 @@ +/*:* + * ggaze — open-and-show integration test + * + * Exercises the window wiring: ggaze_window_open() loads a fixture via the + * loader, sets the viewer texture, and switches the stack to "large". Asserts + * the stack is on "large" and the viewer holds a texture of the right size. + * + * An optional second test opens an image from ./sample-images (the realistic + * local corpus, not git-tracked) and is skipped if $GGAZE_SAMPLE_DIR is unset + * or the directory is absent — so CI (which only has tests/fixtures/) stays + * green. Needs a display (integration suite; CI runs under xvfb). + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include "window.h" +#include "viewer.h" + +#include +#include +#include +#include + +static GFile * +fixture_file(const gchar *c_name) { + const gchar *c_dir = g_getenv("GGAZE_FIXTURES_DIR"); + g_assert_nonnull(c_dir); + gchar *c_path = g_build_filename(c_dir, c_name, NULL); + GFile *p_file = g_file_new_for_path(c_path); + g_free(c_path); + return (p_file); +} + +static GgazeWindow * +new_window(void) { + return (GGAZE_WINDOW(g_object_new(GGAZE_TYPE_WINDOW, NULL))); +} + +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)); + 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); +} + +static void +test_open_fixture_shows_large(void) { + GgazeWindow *p_win = new_window(); + GFile *p_file = fixture_file("plain.jpg"); + ggaze_window_open(p_win, p_file); + assert_shown_large_with_dims(p_win, 6, 3); + g_object_unref(p_file); + g_object_unref(p_win); +} + +static void +test_open_rotated_fixture(void) { + GgazeWindow *p_win = new_window(); + GFile *p_file = fixture_file("rot6.jpg"); + ggaze_window_open(p_win, p_file); + assert_shown_large_with_dims(p_win, 4, 8); + g_object_unref(p_file); + g_object_unref(p_win); +} + +static void +test_open_sample_image(void) { + const gchar *c_dir = g_getenv("GGAZE_SAMPLE_DIR"); + if (c_dir == NULL || *c_dir == '\0') { + g_test_skip("GGAZE_SAMPLE_DIR unset (./sample-images not available)"); + return; + } + if (!g_file_test(c_dir, G_FILE_TEST_IS_DIR)) { + g_test_skip("GGAZE_SAMPLE_DIR is not a directory"); + return; + } + /* Pick the first JPEG in the corpus. */ + GError *p_err = NULL; + GDir *p_dir = g_dir_open(c_dir, 0, &p_err); + g_assert_no_error(p_err); + const gchar *c_name = NULL; + while ((c_name = g_dir_read_name(p_dir)) != NULL) { + if (g_str_has_suffix(c_name, ".jpg") || + g_str_has_suffix(c_name, ".JPG") || + g_str_has_suffix(c_name, ".png")) { + break; + } + } + if (c_name == NULL) { + g_test_skip("no sample image found in corpus"); + g_dir_close(p_dir); + return; + } + gchar *c_path = g_build_filename(c_dir, c_name, NULL); + g_dir_close(p_dir); + + GgazeWindow *p_win = new_window(); + 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)); + g_assert_nonnull(p_tex); /* loaded, whatever its dims */ + + g_object_unref(p_file); + g_object_unref(p_win); + g_free(c_path); +} + +int +main(int i_argc, char **c_argv) { + g_test_init(&i_argc, &c_argv, NULL); + /* Tolerate host GTK WARNINGs; keep CRITICALs fatal. */ + 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("/open/fixture_shows_large", test_open_fixture_shows_large); + g_test_add_func("/open/rotated_fixture", test_open_rotated_fixture); + g_test_add_func("/open/sample_image", test_open_sample_image); + return (g_test_run()); +} \ No newline at end of file diff --git a/tests/test_window.c b/tests/test_window.c index 0570397..0f3fa74 100644 --- a/tests/test_window.c +++ b/tests/test_window.c @@ -43,6 +43,7 @@ test_stack_has_two_views(void) { g_assert_nonnull(gtk_stack_get_child_by_name(p_stack, "grid")); g_assert_nonnull(gtk_stack_get_child_by_name(p_stack, "large")); g_assert_cmpstr(gtk_stack_get_visible_child_name(p_stack), ==, "grid"); + g_object_unref(p_pages); g_object_unref(p_win); } -- cgit v1.2.3