summaryrefslogtreecommitdiff
path: root/src
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 /src
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.
Diffstat (limited to 'src')
-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
5 files changed, 374 insertions, 6 deletions
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);