diff options
| author | Paul Buetow <paul@buetow.org> | 2026-07-12 17:51:22 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-07-12 17:51:22 +0300 |
| commit | 6b0f2de39c2d8b9e8d346abd8548024da4ae8185 (patch) | |
| tree | 29e67e6a75488b4fdd34756be3944c4262e0b85a | |
| parent | 51b47a0130005bd19039422d027da23ff46dd6b8 (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.md | 8 | ||||
| -rw-r--r-- | meson.build | 22 | ||||
| -rw-r--r-- | src/loader/backends/pixbuf.c | 121 | ||||
| -rw-r--r-- | src/loader/detect.c | 82 | ||||
| -rw-r--r-- | src/loader/detect.h | 40 | ||||
| -rw-r--r-- | src/loader/loader.c | 67 | ||||
| -rw-r--r-- | src/loader/loader.h | 45 | ||||
| -rw-r--r-- | src/viewer.c | 382 | ||||
| -rw-r--r-- | src/viewer.h | 42 | ||||
| -rw-r--r-- | src/window.c | 42 | ||||
| -rw-r--r-- | src/window.h | 8 | ||||
| -rw-r--r-- | tests/fixtures/.gitkeep | 0 | ||||
| -rw-r--r-- | tests/fixtures/gen.py | 93 | ||||
| -rw-r--r-- | tests/fixtures/plain.jpg | bin | 0 -> 769 bytes | |||
| -rw-r--r-- | tests/fixtures/rgba.png | bin | 0 -> 108 bytes | |||
| -rw-r--r-- | tests/fixtures/rot6.jpg | bin | 0 -> 768 bytes | |||
| -rw-r--r-- | tests/fixtures/small.png | bin | 0 -> 97 bytes | |||
| -rw-r--r-- | tests/meson.build | 43 | ||||
| -rw-r--r-- | tests/test_detect.c | 111 | ||||
| -rw-r--r-- | tests/test_loader_pixbuf.c | 163 | ||||
| -rw-r--r-- | tests/test_open_and_show.c | 138 | ||||
| -rw-r--r-- | tests/test_window.c | 1 |
22 files changed, 1389 insertions, 19 deletions
@@ -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 |
