summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-12 17:51:22 +0300
committerPaul Buetow <paul@buetow.org>2026-07-12 17:51:22 +0300
commit6b0f2de39c2d8b9e8d346abd8548024da4ae8185 (patch)
tree29e67e6a75488b4fdd34756be3944c4262e0b85a
parent51b47a0130005bd19039422d027da23ff46dd6b8 (diff)
showimage: loader, detect, viewer widget, EXIF orientation, tests (gt0)
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.
-rw-r--r--AGENTS.md8
-rw-r--r--meson.build22
-rw-r--r--src/loader/backends/pixbuf.c121
-rw-r--r--src/loader/detect.c82
-rw-r--r--src/loader/detect.h40
-rw-r--r--src/loader/loader.c67
-rw-r--r--src/loader/loader.h45
-rw-r--r--src/viewer.c382
-rw-r--r--src/viewer.h42
-rw-r--r--src/window.c42
-rw-r--r--src/window.h8
-rw-r--r--tests/fixtures/.gitkeep0
-rw-r--r--tests/fixtures/gen.py93
-rw-r--r--tests/fixtures/plain.jpgbin0 -> 769 bytes
-rw-r--r--tests/fixtures/rgba.pngbin0 -> 108 bytes
-rw-r--r--tests/fixtures/rot6.jpgbin0 -> 768 bytes
-rw-r--r--tests/fixtures/small.pngbin0 -> 97 bytes
-rw-r--r--tests/meson.build43
-rw-r--r--tests/test_detect.c111
-rw-r--r--tests/test_loader_pixbuf.c163
-rw-r--r--tests/test_open_and_show.c138
-rw-r--r--tests/test_window.c1
22 files changed, 1389 insertions, 19 deletions
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 <gdk-pixbuf/gdk-pixbuf.h>
+#include <gdk/gdk.h>
+#include <gio/gio.h>
+
+#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 <string.h>
+
+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 <glib.h>
+
+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 <gio/gio.h>
+#include <glib.h>
+
+#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 <gdk/gdk.h>
+#include <gio/gio.h>
+#include <glib.h>
+
+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 <glib.h>
+#include <graphene.h>
+
+#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 <gdk/gdk.h>
+#include <gtk/gtk.h>
+
+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 GgazeWi