From 25ee19ce91dcb204d93e6d8cceb7cd64d5f057f9 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 13 Jul 2026 21:05:24 +0300 Subject: fullscreen: info/EXIF, fullscreen, slideshow, Esc back (ot0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/info.c | 171 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/info.h | 45 +++++++++++++++ src/shortcuts.c | 4 ++ src/window.c | 156 +++++++++++++++++++++++++++++++++++++++++++++++++-- src/window.h | 4 ++ 5 files changed, 374 insertions(+), 6 deletions(-) create mode 100644 src/info.c create mode 100644 src/info.h (limited to 'src') 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 +#include +#include + +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 +#include + +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 #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); -- cgit v1.2.3