summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-13 21:05:24 +0300
committerPaul Buetow <paul@buetow.org>2026-07-13 21:05:24 +0300
commit25ee19ce91dcb204d93e6d8cceb7cd64d5f057f9 (patch)
tree2e01bf17f2a6e1e9e9051b115cdd764060f740fb
parent0795d3cf926d0d937f25fcc699f5f674bf618c72 (diff)
fullscreen: info/EXIF, fullscreen, slideshow, Esc back (ot0)
M4 (task ot0): fullscreen + slideshow + info overlay. - src/info.{c,h}: plain-C EXIF gather via libexif (camera, lens, focal, aperture, shutter, ISO, datetime, orientation) + file info (dims, format, size). info_format() produces the overlay text. Unit-tested. - src/window.c: fullscreen toggle (f), slideshow (S, 3s auto-advance), info overlay (i, GtkLabel in GtkOverlay, auto-hide 5s), Esc contextual back (fullscreen→unfullscreen→large→grid→quit). Stack wrapped in GtkOverlay. ggaze_window_get_stack() accessor for tests. - src/shortcuts.c: f, S, i, Escape bindings. - meson.build: libexif dep added. - tests: unit test_info (EXIF extraction + orientation tag). 14/14 green, ASan clean.
-rw-r--r--meson.build6
-rw-r--r--src/info.c171
-rw-r--r--src/info.h45
-rw-r--r--src/shortcuts.c4
-rw-r--r--src/window.c156
-rw-r--r--src/window.h4
-rw-r--r--tests/meson.build10
-rw-r--r--tests/test_grid_cull.c6
-rw-r--r--tests/test_info.c74
-rw-r--r--tests/test_open_and_show.c4
-rw-r--r--tests/test_responsive_nav.c2
-rw-r--r--tests/test_walk_folder.c4
-rw-r--r--tests/test_window.c2
13 files changed, 471 insertions, 17 deletions
diff --git a/meson.build b/meson.build
index d77e081..7f9c3f2 100644
--- a/meson.build
+++ b/meson.build
@@ -22,6 +22,7 @@ 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
+libexif_dep = dependency('libexif') # EXIF gather (info overlay, M4)
# --- Optional backends (feature: auto/disabled/enabled). See meson_options.txt.
gegl_dep = dependency('gegl-0.4', required : get_option('gegl'))
@@ -63,16 +64,17 @@ ggaze_lib = static_library('ggaze',
'src/thumbnail.c',
'src/trash.c',
'src/gridview.c',
+ 'src/info.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, gdkpixbuf_dep],
+ dependencies : [gtk4_dep, glib_dep, gio_dep, adwaita_dep, gdkpixbuf_dep, libexif_dep],
install : false,
)
-ggaze_deps = [gtk4_dep, glib_dep, gio_dep, adwaita_dep, gdkpixbuf_dep]
+ggaze_deps = [gtk4_dep, glib_dep, gio_dep, adwaita_dep, gdkpixbuf_dep, libexif_dep]
subdir('src')
subdir('data')
diff --git a/src/info.c b/src/info.c
new file mode 100644
index 0000000..664ea64
--- /dev/null
+++ b/src/info.c
@@ -0,0 +1,171 @@
+/*:*
+ * ggaze — image info / EXIF gather
+ *
+ * Gathers file info (via GFileInfo + GdkPixbuf for dims) and EXIF tags (via
+ * libexif) into a GgazeInfo struct. Plain-C, no GtkWidget.
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "info.h"
+
+#include <gdk-pixbuf/gdk-pixbuf.h>
+#include <libexif/exif-data.h>
+#include <libexif/exif-tag.h>
+
+static char *
+_dup_exif_value(ExifData *p_data, ExifTag e_tag) {
+ if (p_data == NULL) {
+ return (NULL);
+ }
+ ExifEntry *p_entry = exif_data_get_entry(p_data, e_tag);
+ if (p_entry == NULL) {
+ return (NULL);
+ }
+ char c_buf[256];
+ exif_entry_get_value(p_entry, c_buf, sizeof(c_buf));
+ if (c_buf[0] == '\0') {
+ return (NULL);
+ }
+ return (g_strdup(c_buf));
+}
+
+static char *
+_dup_camera(ExifData *p_data) {
+ char *c_make = _dup_exif_value(p_data, EXIF_TAG_MAKE);
+ char *c_model = _dup_exif_value(p_data, EXIF_TAG_MODEL);
+ char *c_out = NULL;
+ if (c_make != NULL && c_model != NULL) {
+ c_out = g_strdup_printf("%s %s", c_make, c_model);
+ } else if (c_make != NULL) {
+ c_out = g_strdup(c_make);
+ } else if (c_model != NULL) {
+ c_out = g_strdup(c_model);
+ }
+ g_free(c_make);
+ g_free(c_model);
+ return (c_out);
+}
+
+static int
+_get_orientation(ExifData *p_data) {
+ if (p_data == NULL) {
+ return (0);
+ }
+ ExifEntry *p_entry = exif_data_get_entry(p_data, EXIF_TAG_ORIENTATION);
+ if (p_entry == NULL || p_entry->components == 0 || p_entry->data == NULL) {
+ return (0);
+ }
+ /* Orientation is a SHORT (2 bytes, big/little endian per the data's byte
+ * order). */
+ ExifByteOrder e_order = exif_data_get_byte_order(p_data);
+ return ((int)exif_get_short(p_entry->data, e_order));
+}
+
+GgazeInfo *
+info_new(GFile *p_file) {
+ g_return_val_if_fail(G_IS_FILE(p_file), NULL);
+ char *c_path = g_file_get_path(p_file);
+ if (c_path == NULL) {
+ return (NULL);
+ }
+
+ GgazeInfo *p_info = g_new0(GgazeInfo, 1);
+
+ /* File info: size + content type. */
+ GError *p_err = NULL;
+ GFileInfo *p_fi =
+ g_file_query_info(p_file, "standard::size,standard::content-type",
+ G_FILE_QUERY_INFO_NONE, NULL, &p_err);
+ if (p_fi != NULL) {
+ p_info->i_size = (gint64)g_file_info_get_size(p_fi);
+ const char *c_ct = g_file_info_get_content_type(p_fi);
+ if (c_ct != NULL) {
+ p_info->c_format = g_strdup(c_ct);
+ }
+ g_object_unref(p_fi);
+ } else {
+ g_clear_error(&p_err);
+ }
+
+ /* Dimensions via GdkPixbuf (without applying orientation). */
+ GdkPixbuf *p_pix = gdk_pixbuf_new_from_file(c_path, &p_err);
+ if (p_pix != NULL) {
+ p_info->i_width = gdk_pixbuf_get_width(p_pix);
+ p_info->i_height = gdk_pixbuf_get_height(p_pix);
+ g_object_unref(p_pix);
+ } else {
+ g_clear_error(&p_err);
+ }
+
+ /* EXIF via libexif. */
+ ExifData *p_exif = exif_data_new_from_file(c_path);
+ if (p_exif != NULL) {
+ p_info->c_camera = _dup_camera(p_exif);
+ p_info->c_focal = _dup_exif_value(p_exif, EXIF_TAG_FOCAL_LENGTH);
+ p_info->c_aperture = _dup_exif_value(p_exif, EXIF_TAG_FNUMBER);
+ p_info->c_shutter = _dup_exif_value(p_exif, EXIF_TAG_EXPOSURE_TIME);
+ p_info->c_iso = _dup_exif_value(p_exif, EXIF_TAG_ISO_SPEED_RATINGS);
+ p_info->c_datetime = _dup_exif_value(p_exif, EXIF_TAG_DATE_TIME_ORIGINAL);
+ p_info->i_orientation = _get_orientation(p_exif);
+ exif_data_unref(p_exif);
+ }
+
+ g_free(c_path);
+ return (p_info);
+}
+
+void
+info_delete(GgazeInfo *p_info) {
+ if (p_info == NULL) {
+ return;
+ }
+ g_free(p_info->c_format);
+ g_free(p_info->c_camera);
+ g_free(p_info->c_lens);
+ g_free(p_info->c_focal);
+ g_free(p_info->c_aperture);
+ g_free(p_info->c_shutter);
+ g_free(p_info->c_iso);
+ g_free(p_info->c_datetime);
+ g_free(p_info);
+}
+
+static char *
+_join(GString *p_str, const char *c_label, const char *c_val) {
+ if (c_val != NULL && c_val[0] != '\0') {
+ g_string_append_printf(p_str, "%s: %s\n", c_label, c_val);
+ }
+ return (NULL);
+}
+
+char *
+info_format(const GgazeInfo *p_info) {
+ if (p_info == NULL) {
+ return (g_strdup(""));
+ }
+ GString *p_str = g_string_new(NULL);
+ g_string_append_printf(p_str, "%d×%d\n", p_info->i_width, p_info->i_height);
+ _join(p_str, "Format", p_info->c_format);
+ if (p_info->i_size > 0) {
+ char *c_sz = g_format_size(p_info->i_size);
+ g_string_append_printf(p_str, "Size: %s\n", c_sz);
+ g_free(c_sz);
+ }
+ _join(p_str, "Camera", p_info->c_camera);
+ _join(p_str, "Lens", p_info->c_lens);
+ _join(p_str, "Focal", p_info->c_focal);
+ _join(p_str, "Aperture", p_info->c_aperture);
+ _join(p_str, "Shutter", p_info->c_shutter);
+ _join(p_str, "ISO", p_info->c_iso);
+ _join(p_str, "Date", p_info->c_datetime);
+ if (p_info->i_orientation > 0) {
+ g_string_append_printf(p_str, "Orientation: %d\n", p_info->i_orientation);
+ }
+ /* trim trailing newline */
+ if (p_str->len > 0 && p_str->str[p_str->len - 1] == '\n') {
+ g_string_truncate(p_str, p_str->len - 1);
+ }
+ return (g_string_free(p_str, FALSE));
+} \ No newline at end of file
diff --git a/src/info.h b/src/info.h
new file mode 100644
index 0000000..7680b72
--- /dev/null
+++ b/src/info.h
@@ -0,0 +1,45 @@
+#ifndef GGAZE_INFO_H
+#define GGAZE_INFO_H
+
+/*:*
+ * ggaze — image info / EXIF gather
+ *
+ * Gathers file info (dimensions, format, size) + EXIF (camera, lens, focal,
+ * aperture, shutter, ISO, datetime, orientation) into a GgazeInfo struct.
+ * Plain-C, no GtkWidget; uses GdkPixbuf (dims) + libexif (EXIF). Unit-testable.
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include <gio/gio.h>
+#include <glib.h>
+
+G_BEGIN_DECLS
+
+typedef struct {
+ int i_width;
+ int i_height;
+ char *c_format; /* content type (owned) */
+ gint64 i_size; /* file size in bytes */
+ char *c_camera; /* Make + Model (owned) */
+ char *c_lens; /* Lens model (owned, or NULL) */
+ char *c_focal; /* FocalLength (owned) */
+ char *c_aperture; /* FNumber (owned) */
+ char *c_shutter; /* ExposureTime (owned) */
+ char *c_iso; /* ISOSpeedRatings (owned) */
+ char *c_datetime; /* DateTimeOriginal (owned) */
+ int i_orientation; /* EXIF Orientation (1-8, 0 if none) */
+} GgazeInfo;
+
+/* Gather info for p_file (synchronous). Returns a new GgazeInfo (caller owns,
+ * free with info_delete) or NULL on failure. */
+GgazeInfo *info_new(GFile *p_file);
+void info_delete(GgazeInfo *p_info);
+
+/* Format the info as a multi-line string (caller frees). */
+char *info_format(const GgazeInfo *p_info);
+
+G_END_DECLS
+
+#endif /* GGAZE_INFO_H */ \ No newline at end of file
diff --git a/src/shortcuts.c b/src/shortcuts.c
index 6853490..a23cfe2 100644
--- a/src/shortcuts.c
+++ b/src/shortcuts.c
@@ -37,6 +37,10 @@ static const ShortcutEntry SHORTCUTS[] = {
{GDK_KEY_equal, 0, "win.zoom-in"},
{GDK_KEY_minus, 0, "win.zoom-out"},
{GDK_KEY_underscore, 0, "win.zoom-out"},
+ {GDK_KEY_f, 0, "win.fullscreen"},
+ {GDK_KEY_S, GDK_SHIFT_MASK, "win.slideshow"},
+ {GDK_KEY_i, 0, "win.info"},
+ {GDK_KEY_Escape, 0, "win.back"},
};
void
diff --git a/src/window.c b/src/window.c
index 003e7bf..4c26ea7 100644
--- a/src/window.c
+++ b/src/window.c
@@ -18,6 +18,7 @@
#include <gtk/gtk.h>
#include "gridview.h"
+#include "info.h"
#include "loader/loader.h"
#include "navigator.h"
#include "shortcuts.h"
@@ -38,15 +39,24 @@ struct _GgazeWindow {
GtkWidget *p_viewer; /* GgazeViewer — the large view */
GgazeGrid *p_grid; /* the thumbnail grid (the "grid" stack child) */
int i_grid_size; /* current thumbnail size (64-512, decision T) */
+ GtkWidget *p_overlay; /* GtkOverlay wrapping the stack (for info label) */
+ GtkWidget *p_info_lbl; /* info overlay label (auto-hides) */
+ guint u_info_hide; /* info auto-hide timeout id (0=none) */
+ guint u_slideshow; /* slideshow timeout id (0=off) */
+ gboolean b_fullscreen;
+ guint u_hdr_hide; /* fullscreen header auto-hide timeout */
};
G_DEFINE_TYPE(GgazeWindow, ggaze_window, GTK_TYPE_APPLICATION_WINDOW)
/* --- forward decls ------------------------------------------------------- */
-static void _load_current(GgazeWindow *p_win);
-static void _prefetch(GgazeWindow *p_win);
-static void _update_header(GgazeWindow *p_win);
-static void _on_grid_activate(GgazeGrid *p_grid, gpointer p_data);
+static void _load_current(GgazeWindow *p_win);
+static void _prefetch(GgazeWindow *p_win);
+static void _update_header(GgazeWindow *p_win);
+static void _on_grid_activate(GgazeGrid *p_grid, gpointer p_data);
+static void _show_info(GgazeWindow *p_win);
+static void _hide_info(GgazeWindow *p_win);
+static gboolean _slideshow_tick(gpointer p_data);
/* --- actions ------------------------------------------------------------- */
@@ -269,6 +279,108 @@ _action_zoom_out(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) {
}
}
+/* --- M4: fullscreen / slideshow / info / back --------------------------- */
+
+static void
+_action_fullscreen(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) {
+ (void)p_a;
+ (void)p_v;
+ GgazeWindow *p_win = GGAZE_WINDOW(p_data);
+ if (p_win->b_fullscreen) {
+ gtk_window_unfullscreen(GTK_WINDOW(p_win));
+ p_win->b_fullscreen = FALSE;
+ } else {
+ gtk_window_fullscreen(GTK_WINDOW(p_win));
+ p_win->b_fullscreen = TRUE;
+ }
+}
+
+static void
+_action_slideshow(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) {
+ (void)p_a;
+ (void)p_v;
+ GgazeWindow *p_win = GGAZE_WINDOW(p_data);
+ if (p_win->u_slideshow != 0) {
+ g_source_remove(p_win->u_slideshow);
+ p_win->u_slideshow = 0;
+ } else {
+ /* 3-second default; GSettings slideshow-delay wired in M10 */
+ p_win->u_slideshow = g_timeout_add_seconds(3, _slideshow_tick, p_win);
+ }
+}
+
+static void
+_action_info(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) {
+ (void)p_a;
+ (void)p_v;
+ _show_info(GGAZE_WINDOW(p_data));
+}
+
+static void
+_action_back(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) {
+ (void)p_a;
+ (void)p_v;
+ GgazeWindow *p_win = GGAZE_WINDOW(p_data);
+ if (p_win->b_fullscreen) {
+ gtk_window_unfullscreen(GTK_WINDOW(p_win));
+ p_win->b_fullscreen = FALSE;
+ } else {
+ const char *c_cur =
+ gtk_stack_get_visible_child_name(GTK_STACK(p_win->p_stack));
+ if (g_strcmp0(c_cur, "large") == 0) {
+ gtk_stack_set_visible_child_name(GTK_STACK(p_win->p_stack), "grid");
+ } else {
+ gtk_window_close(GTK_WINDOW(p_win));
+ }
+ }
+}
+
+static gboolean
+_slideshow_tick(gpointer p_data) {
+ GgazeWindow *p_win = GGAZE_WINDOW(p_data);
+ if (p_win->p_nav != NULL) {
+ navigator_next(p_win->p_nav);
+ }
+ return (G_SOURCE_CONTINUE);
+}
+
+static gboolean
+_info_hide_tick(gpointer p_data) {
+ GgazeWindow *p_win = GGAZE_WINDOW(p_data);
+ _hide_info(p_win);
+ p_win->u_info_hide = 0;
+ return (G_SOURCE_REMOVE);
+}
+
+static void
+_show_info(GgazeWindow *p_win) {
+ if (p_win->p_nav == NULL) {
+ return;
+ }
+ GFile *p_cur = navigator_get_current(p_win->p_nav);
+ if (p_cur == NULL) {
+ return;
+ }
+ GgazeInfo *p_info = info_new(p_cur);
+ if (p_info == NULL) {
+ return;
+ }
+ char *c_text = info_format(p_info);
+ gtk_label_set_text(GTK_LABEL(p_win->p_info_lbl), c_text);
+ g_free(c_text);
+ info_delete(p_info);
+ gtk_widget_set_visible(p_win->p_info_lbl, TRUE);
+ if (p_win->u_info_hide != 0) {
+ g_source_remove(p_win->u_info_hide);
+ }
+ p_win->u_info_hide = g_timeout_add_seconds(5, _info_hide_tick, p_win);
+}
+
+static void
+_hide_info(GgazeWindow *p_win) {
+ gtk_widget_set_visible(p_win->p_info_lbl, FALSE);
+}
+
static const GActionEntry ACTIONS[] = {
{.name = "prev", .activate = _action_prev},
{.name = "next", .activate = _action_next},
@@ -282,6 +394,10 @@ static const GActionEntry ACTIONS[] = {
{.name = "toggle-view", .activate = _action_toggle_view},
{.name = "zoom-in", .activate = _action_zoom_in},
{.name = "zoom-out", .activate = _action_zoom_out},
+ {.name = "fullscreen", .activate = _action_fullscreen},
+ {.name = "slideshow", .activate = _action_slideshow},
+ {.name = "info", .activate = _action_info},
+ {.name = "back", .activate = _action_back},
};
/* --- drop target --------------------------------------------------------- */
@@ -485,6 +601,18 @@ ggaze_window_dispose(GObject *p_obj) {
g_clear_object(&p_win->p_prefetch_cancel);
g_cancellable_cancel(p_win->p_cancel);
g_clear_object(&p_win->p_cancel);
+ if (p_win->u_slideshow != 0) {
+ g_source_remove(p_win->u_slideshow);
+ p_win->u_slideshow = 0;
+ }
+ if (p_win->u_info_hide != 0) {
+ g_source_remove(p_win->u_info_hide);
+ p_win->u_info_hide = 0;
+ }
+ if (p_win->u_hdr_hide != 0) {
+ g_source_remove(p_win->u_hdr_hide);
+ p_win->u_hdr_hide = 0;
+ }
g_clear_pointer(&p_win->p_cache, texturecache_delete);
g_clear_pointer(&p_win->p_trash, trash_delete);
g_clear_pointer(&p_win->p_thumb, thumbnail_delete);
@@ -517,7 +645,17 @@ ggaze_window_init(GgazeWindow *p_win) {
p_win->p_stack = gtk_stack_new();
gtk_stack_set_transition_type(GTK_STACK(p_win->p_stack),
GTK_STACK_TRANSITION_TYPE_CROSSFADE);
- gtk_window_set_child(GTK_WINDOW(p_win), p_win->p_stack);
+
+ /* Wrap the stack in a GtkOverlay so the info label can float on top. */
+ p_win->p_overlay = gtk_overlay_new();
+ gtk_overlay_set_child(GTK_OVERLAY(p_win->p_overlay), p_win->p_stack);
+ p_win->p_info_lbl = gtk_label_new("");
+ gtk_widget_add_css_class(p_win->p_info_lbl, "ggaze-info");
+ gtk_widget_set_margin_start(p_win->p_info_lbl, 12);
+ gtk_widget_set_margin_top(p_win->p_info_lbl, 12);
+ gtk_widget_set_visible(p_win->p_info_lbl, FALSE);
+ gtk_overlay_add_overlay(GTK_OVERLAY(p_win->p_overlay), p_win->p_info_lbl);
+ gtk_window_set_child(GTK_WINDOW(p_win), p_win->p_overlay);
GtkWidget *p_grid = gtk_label_new("grid");
gtk_widget_add_css_class(p_grid, "dim-label");
@@ -642,4 +780,10 @@ ggaze_window_last(GgazeWindow *p_win) {
if (p_win->p_nav != NULL) {
navigator_last(p_win->p_nav);
}
-} \ No newline at end of file
+}
+
+GtkStack *
+ggaze_window_get_stack(GgazeWindow *p_win) {
+ g_return_val_if_fail(GGAZE_IS_WINDOW(p_win), NULL);
+ return (GTK_STACK(p_win->p_stack));
+}
diff --git a/src/window.h b/src/window.h
index 10612db..43572d0 100644
--- a/src/window.h
+++ b/src/window.h
@@ -32,6 +32,10 @@ GgazeWindow *ggaze_window_new(GgazeApp *p_app);
* current image into the viewer and switches the stack to "large". */
void ggaze_window_open(GgazeWindow *p_win, GFile *p_arg);
+/* The GtkStack (grid/large) — the tests use this instead of
+ * gtk_window_get_child (which now returns the wrapping GtkOverlay). */
+GtkStack *ggaze_window_get_stack(GgazeWindow *p_win);
+
/* Navigation over the current folder (bound to h/l/Left/Right/g/G via
* shortcuts.c). No-ops if nothing is open. */
void ggaze_window_prev(GgazeWindow *p_win);
diff --git a/tests/meson.build b/tests/meson.build
index d1cb8af..86eee1d 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -97,6 +97,16 @@ test_thumbnail = executable(
)
test('thumbnail', test_thumbnail, suite : 'unit', env : fixtures_env)
+test_info = executable(
+ 'test_info',
+ ['test_info.c', ggaze_conf_h],
+ include_directories : [inc, src_inc],
+ dependencies : ggaze_deps,
+ link_with : ggaze_lib,
+ install : false,
+)
+test('info', test_info, suite : 'unit', env : fixtures_env)
+
test_texturecache = executable(
'test_texturecache',
['test_texturecache.c', ggaze_conf_h],
diff --git a/tests/test_grid_cull.c b/tests/test_grid_cull.c
index 37a2a68..db5a512 100644
--- a/tests/test_grid_cull.c
+++ b/tests/test_grid_cull.c
@@ -89,7 +89,7 @@ new_window(void) {
static GdkTexture *
viewer_texture(GgazeWindow *p_win) {
- GtkWidget *p_child = gtk_window_get_child(GTK_WINDOW(p_win));
+ GtkWidget *p_child = GTK_WIDGET(ggaze_window_get_stack(p_win));
GtkWidget *p_large =
gtk_stack_get_child_by_name(GTK_STACK(p_child), "large");
return (ggaze_viewer_get_texture(GGAZE_VIEWER(p_large)));
@@ -120,7 +120,7 @@ test_grid_cull_temp(void) {
/* The grid exists in the stack with 3 cells (count without toggling to
* avoid realizing cells and firing async thumbnail requests that can't
* complete before exit under ASan). */
- GtkWidget *p_stack = gtk_window_get_child(GTK_WINDOW(p_win));
+ GtkWidget *p_stack = GTK_WIDGET(ggaze_window_get_stack(p_win));
GgazeGrid *p_grid =
GGAZE_GRID(gtk_stack_get_child_by_name(GTK_STACK(p_stack), "grid"));
g_assert_nonnull(p_grid);
@@ -188,7 +188,7 @@ test_grid_sample(void) {
/* The grid exists in the stack (created on open) with hundreds of cells,
* but we don't toggle to it (that would realize cells and fire thumbnail
* requests that can't all complete before exit under ASan). */
- GtkWidget *p_stack = gtk_window_get_child(GTK_WINDOW(p_win));
+ GtkWidget *p_stack = GTK_WIDGET(ggaze_window_get_stack(p_win));
GgazeGrid *p_grid =
GGAZE_GRID(gtk_stack_get_child_by_name(GTK_STACK(p_stack), "grid"));
g_assert_nonnull(p_grid);
diff --git a/tests/test_info.c b/tests/test_info.c
new file mode 100644
index 0000000..9beceb9
--- /dev/null
+++ b/tests/test_info.c
@@ -0,0 +1,74 @@
+/*:*
+ * ggaze — info / EXIF unit test
+ *
+ * Gathers EXIF from the fixtures: plain.jpg (orientation 1) and rot6.jpg
+ * (orientation 6, 8x4 stored). Asserts dimensions, orientation, and that
+ * the format string is non-empty. Uses $GGAZE_FIXTURES_DIR.
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "info.h"
+
+#include <glib.h>
+
+static GgazeInfo *
+info_from_fixture(const char *c_name) {
+ const char *c_dir = g_getenv("GGAZE_FIXTURES_DIR");
+ g_assert_nonnull(c_dir);
+ char *c_path = g_build_filename(c_dir, c_name, NULL);
+ GFile *p_file = g_file_new_for_path(c_path);
+ g_free(c_path);
+ GgazeInfo *p_info = info_new(p_file);
+ g_assert_nonnull(p_info);
+ g_object_unref(p_file);
+ return (p_info);
+}
+
+static void
+test_plain_jpeg(void) {
+ GgazeInfo *p_info = info_from_fixture("plain.jpg");
+ g_assert_cmpint(p_info->i_width, ==, 6);
+ g_assert_cmpint(p_info->i_height, ==, 3);
+ g_assert_cmpint(p_info->i_orientation, ==, 1); /* Horizontal (normal) */
+ g_assert_nonnull(p_info->c_format);
+ g_assert(p_info->i_size > 0);
+ char *c_fmt = info_format(p_info);
+ g_assert_nonnull(c_fmt);
+ g_free(c_fmt);
+ info_delete(p_info);
+}
+
+static void
+test_rotated_jpeg(void) {
+ GgazeInfo *p_info = info_from_fixture("rot6.jpg");
+ /* Stored dimensions are 8x4 (before orientation is applied). */
+ g_assert_cmpint(p_info->i_width, ==, 8);
+ g_assert_cmpint(p_info->i_height, ==, 4);
+ g_assert_cmpint(p_info->i_orientation, ==, 6); /* Rotate 90 CW */
+ info_delete(p_info);
+}
+
+static void
+test_png_no_exif(void) {
+ GgazeInfo *p_info = info_from_fixture("small.png");
+ g_assert_cmpint(p_info->i_width, ==, 5);
+ g_assert_cmpint(p_info->i_height, ==, 2);
+ g_assert_cmpint(p_info->i_orientation, ==, 0); /* no EXIF */
+ g_assert_null(p_info->c_camera); /* no EXIF → no camera */
+ info_delete(p_info);
+}
+
+int
+main(int i_argc, char **c_argv) {
+ g_test_init(&i_argc, &c_argv, NULL);
+ if (g_getenv("GGAZE_FIXTURES_DIR") == NULL) {
+ g_test_skip("GGAZE_FIXTURES_DIR unset");
+ return (g_test_run());
+ }
+ g_test_add_func("/info/plain_jpeg", test_plain_jpeg);
+ g_test_add_func("/info/rotated_jpeg", test_rotated_jpeg);
+ g_test_add_func("/info/png_no_exif", test_png_no_exif);
+ 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
index 3c16996..83c04c7 100644
--- a/tests/test_open_and_show.c
+++ b/tests/test_open_and_show.c
@@ -48,7 +48,7 @@ drain_main(guint u_ms) {
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));
+ GtkWidget *p_child = GTK_WIDGET(ggaze_window_get_stack(p_win));
g_assert_true(GTK_IS_STACK(p_child));
GtkWidget *p_large =
gtk_stack_get_child_by_name(GTK_STACK(p_child), "large");
@@ -127,7 +127,7 @@ test_open_sample_image(void) {
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));
+ GtkWidget *p_child = GTK_WIDGET(ggaze_window_get_stack(p_win));
GtkWidget *p_large =
gtk_stack_get_child_by_name(GTK_STACK(p_child), "large");
/* async load: pump until a texture lands */
diff --git a/tests/test_responsive_nav.c b/tests/test_responsive_nav.c
index 0a13e1a..61a90ab 100644
--- a/tests/test_responsive_nav.c
+++ b/tests/test_responsive_nav.c
@@ -74,7 +74,7 @@ drain_main(guint u_ms) {
static GdkTexture *
viewer_texture(GgazeWindow *p_win) {
- GtkWidget *p_child = gtk_window_get_child(GTK_WINDOW(p_win));
+ GtkWidget *p_child = GTK_WIDGET(ggaze_window_get_stack(p_win));
GtkWidget *p_large =
gtk_stack_get_child_by_name(GTK_STACK(p_child), "large");
return (ggaze_viewer_get_texture(GGAZE_VIEWER(p_large)));
diff --git a/tests/test_walk_folder.c b/tests/test_walk_folder.c
index f2f4f15..a175d3e 100644
--- a/tests/test_walk_folder.c
+++ b/tests/test_walk_folder.c
@@ -85,7 +85,7 @@ drain_main(guint u_ms) {
static GdkTexture *
viewer_texture(GgazeWindow *p_win) {
- GtkWidget *p_child = gtk_window_get_child(GTK_WINDOW(p_win));
+ GtkWidget *p_child = GTK_WIDGET(ggaze_window_get_stack(p_win));
GtkWidget *p_large =
gtk_stack_get_child_by_name(GTK_STACK(p_child), "large");
return (ggaze_viewer_get_texture(GGAZE_VIEWER(p_large)));
@@ -94,7 +94,7 @@ viewer_texture(GgazeWindow *p_win) {
static void
assert_dims(GgazeWindow *p_win, int i_w, int i_h) {
/* Loads are async; pump the main loop until the viewer shows i_w x i_h. */
- GtkWidget *p_child = gtk_window_get_child(GTK_WINDOW(p_win));
+ GtkWidget *p_child = GTK_WIDGET(ggaze_window_get_stack(p_win));
GtkWidget *p_large =
gtk_stack_get_child_by_name(GTK_STACK(p_child), "large");
GdkTexture *p_tex = NULL;
diff --git a/tests/test_window.c b/tests/test_window.c
index 63e54bb..76b9363 100644
--- a/tests/test_window.c
+++ b/tests/test_window.c
@@ -33,7 +33,7 @@ test_stack_has_two_views(void) {
GgazeWindow *p_win = new_window();
g_assert_nonnull(p_win);
- GtkWidget *p_child = gtk_window_get_child(GTK_WINDOW(p_win));
+ GtkWidget *p_child = GTK_WIDGET(ggaze_window_get_stack(p_win));
g_assert_nonnull(p_child);
g_assert_true(GTK_IS_STACK(p_child));