From 89727590e718d3628a0847689182e896df646b8a Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 12 Jul 2026 21:13:45 +0300 Subject: walkdir: navigator, shortcuts, window wiring, drop target, tests (ht0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M2 (task ht0): walk the directory. - src/navigator.{c,h}: GObject (no GtkWidget) holding the folder listing — image-MIME filter + extension fallback, hide-RAW-sidecars (decision #33), sort name/time/size, cursor + prev/next/wrap, path-based marks (survive re-sort, pruned on rescan/monitor, cleared on remove), nearest-fallback, GFileMonitor debounced 250ms (decision #28), 'changed' signal. 92% gcov. - src/shortcuts.{c,h}: GtkShortcutController (MANAGED) binding h/l/Left/Right/ g/G/o/q to win.* actions; one table all milestones add to. - src/window.{c,h}: open file->parent+current / folder->first; h/l/g/G actions -> nav -> load -> viewer; single GCancellable last-write-wins (g_file_equal guard for M3 async); header title 'filename · n/total'; GtkDropTarget (GDK_TYPE_FILE_LIST, many->first, decision Z); GtkFileDialog open. - data/gschema: add hide-raw-sidecars key. - tests: unit test_navigator (12 subtests), integration test_walk_folder (temp fixtures walk + ./sample-images skip-if-absent). 8/8 green; ASan clean (GIO_USE_VFS=local in fixtures_env). Fixed during this milestone: a UAF (borrowed c_name used after unref), a cancellable double-ref leak, time::modified UINT64 vs INT64, and several test-harness leaks. Sub-agent review issues addressed (mark pruning on rescan/monitor, remove-before-cursor test, recursive temp-dir cleanup, callback _cb naming, connect-before-mutate). --- data/org.buetow.ggaze.gschema.xml | 6 + meson.build | 2 + src/loader/detect.h | 18 +- src/loader/loader.h | 3 +- src/navigator.c | 691 ++++++++++++++++++++++++++++++++++++++ src/navigator.h | 99 ++++++ src/shortcuts.c | 49 +++ src/shortcuts.h | 26 ++ src/viewer.h | 3 +- src/window.c | 299 +++++++++++++++-- src/window.h | 21 +- tests/meson.build | 24 +- tests/test_navigator.c | 483 ++++++++++++++++++++++++++ tests/test_walk_folder.c | 196 +++++++++++ tests/test_window.c | 18 +- 15 files changed, 1886 insertions(+), 52 deletions(-) create mode 100644 src/navigator.c create mode 100644 src/navigator.h create mode 100644 src/shortcuts.c create mode 100644 src/shortcuts.h create mode 100644 tests/test_navigator.c create mode 100644 tests/test_walk_folder.c diff --git a/data/org.buetow.ggaze.gschema.xml b/data/org.buetow.ggaze.gschema.xml index 70ddfa4..9487ef8 100644 --- a/data/org.buetow.ggaze.gschema.xml +++ b/data/org.buetow.ggaze.gschema.xml @@ -73,6 +73,12 @@ Hide ./Trash/deleted items from the grid entirely. + + true + Hide RAW sidecars + Hide RAW files (.RAF/.CR3/.NEF/...) when a JPEG with the same stem exists (toggle to reveal). + + (800, 600, 0, false) Window geometry diff --git a/meson.build b/meson.build index b496e25..6fcd4cc 100644 --- a/meson.build +++ b/meson.build @@ -57,6 +57,8 @@ ggaze_lib = static_library('ggaze', 'src/app.c', 'src/window.c', 'src/viewer.c', + 'src/navigator.c', + 'src/shortcuts.c', 'src/loader/loader.c', 'src/loader/detect.c', 'src/loader/backends/pixbuf.c', diff --git a/src/loader/detect.h b/src/loader/detect.h index 95e549c..77c64da 100644 --- a/src/loader/detect.h +++ b/src/loader/detect.h @@ -19,15 +19,15 @@ 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 */ + 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 diff --git a/src/loader/loader.h b/src/loader/loader.h index 0795097..5b5c5e6 100644 --- a/src/loader/loader.h +++ b/src/loader/loader.h @@ -27,8 +27,7 @@ G_BEGIN_DECLS * 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); + GdkTexture *(*load)(GFile *p_file, GCancellable *p_cancel, GError **p_err); } GgazeLoaderBackend; /* Backends register a const instance; the dispatcher (loader.c) iterates diff --git a/src/navigator.c b/src/navigator.c new file mode 100644 index 0000000..cb47575 --- /dev/null +++ b/src/navigator.c @@ -0,0 +1,691 @@ +/*:* + * ggaze — directory navigator + * + * GObject (no GtkWidget) holding the current folder listing: filter to + * image MIME, sort (name/time/size), cursor + path-based marks, GFileMonitor + * with debounce, nearest-fallback on current-file removal. Emits "changed". + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include "navigator.h" + +#include +#include +#include +#include + +/* Extensions (lowercase, no dot). */ +static const char *IMAGE_EXTS[] = {"jpg", "jpeg", "png", "gif", "webp", + "tif", "tiff", "ico", "jxl", "avif", + "heif", "heic", "bmp", "svg", NULL}; +static const char *RAW_EXTS[] = {"raf", "cr2", "cr3", "nef", "arw", + "dng", "orf", "rw2", "pef", "srw", + "3fr", "x3f", NULL}; + +#define GGAZE_DEFAULT_DEBOUNCE_MS 250 + +typedef struct { + GFile *file; /* owned ref */ + gchar *name; /* owned (basename) */ + gint64 mtime; + gint64 size; +} Entry; + +struct _Navigator { + GObject parent_instance; + GFile *p_dir; /* owned */ + GPtrArray *p_files; /* GFile* (owned refs), sorted/filtered */ + gint i_current; /* -1 if empty */ + GgazeSort e_sort; + gboolean b_wrap; + gboolean b_hide_raw; + GHashTable *p_marks; /* GFile* (owned refs) -> presence */ + GFileMonitor *p_monitor; + guint u_debounce_ms; + guint u_debounce_id; /* 0 = none pending */ +}; + +G_DEFINE_TYPE(Navigator, navigator, G_TYPE_OBJECT) + +static guint u_changed_signal = 0; + +/* --- helpers -------------------------------------------------------------- */ + +static const char * +_ext_of(const char *c_name) { + const char *c_dot = strrchr(c_name, '.'); + if (c_dot == NULL || c_dot == c_name) { + return (NULL); + } + return (c_dot + 1); +} + +static gboolean +_str_in_set(const char *c_s, const char **set) { + if (c_s == NULL) { + return (FALSE); + } + for (gsize u_i = 0; set[u_i] != NULL; u_i++) { + if (g_ascii_strcasecmp(c_s, set[u_i]) == 0) { + return (TRUE); + } + } + return (FALSE); +} + +static gboolean +_is_raw_ext(const char *c_ext) { + return (_str_in_set(c_ext, RAW_EXTS)); +} + +static gboolean +_is_image_file(const char *c_name, const char *c_ct) { + if (c_ct != NULL && g_str_has_prefix(c_ct, "image/")) { + return (TRUE); + } + const char *c_ext = _ext_of(c_name); + return (_str_in_set(c_ext, IMAGE_EXTS) || _str_in_set(c_ext, RAW_EXTS)); +} + +static gboolean +_is_jpeg_ext(const char *c_ext) { + return (c_ext != NULL && (g_ascii_strcasecmp(c_ext, "jpg") == 0 || + g_ascii_strcasecmp(c_ext, "jpeg") == 0)); +} + +/* Owned stem (basename without extension, lowercased for compare). */ +static char * +_stem_lower(const char *c_name) { + const char *c_dot = strrchr(c_name, '.'); + gsize u_len = (c_dot != NULL && c_dot != c_name) ? (gsize)(c_dot - c_name) + : strlen(c_name); + char *c_stem = g_strndup(c_name, u_len); + for (char *p = c_stem; *p != '\0'; p++) { + *p = (char)g_ascii_tolower((gint)*p); + } + return (c_stem); +} + +static void +_entry_free(gpointer p_void) { + Entry *p_e = (Entry *)p_void; + g_clear_object(&p_e->file); + g_free(p_e->name); + g_free(p_e); +} + +static gint +_compare_entries(gconstpointer p_a, gconstpointer p_b, gpointer p_data) { + const Entry *p_ea = *(Entry *const *)p_a; + const Entry *p_eb = *(Entry *const *)p_b; + GgazeSort e_sort = (GgazeSort)GPOINTER_TO_INT(p_data); + + switch (e_sort) { + case GGAZE_SORT_TIME: + if (p_ea->mtime < p_eb->mtime) { + return (-1); + } + if (p_ea->mtime > p_eb->mtime) { + return (1); + } + break; + case GGAZE_SORT_SIZE: + if (p_ea->size < p_eb->size) { + return (-1); + } + if (p_ea->size > p_eb->size) { + return (1); + } + break; + case GGAZE_SORT_NAME: + default: + break; + } + /* Tie-break (and the NAME case) by collated basename. */ + return (g_utf8_collate(p_ea->name, p_eb->name)); +} + +static gint +_find_index_by_file(Navigator *p_nav, GFile *p_file) { + if (p_file == NULL) { + return (-1); + } + for (guint u_i = 0; u_i < p_nav->p_files->len; u_i++) { + if (g_file_equal(p_file, + (GFile *)g_ptr_array_index(p_nav->p_files, u_i))) { + return ((gint)u_i); + } + } + return (-1); +} + +/* Re-read the directory, filter, hide-raw, sort, and commit; keep the current + * file by path, falling back to the nearest by position if it is gone. */ +static void +_relist(Navigator *p_nav) { + GError *p_err = NULL; + GFileEnumerator *p_enum = g_file_enumerate_children( + p_nav->p_dir, + "standard::name,standard::type,standard::content-type," + "time::modified,standard::size", + G_FILE_QUERY_INFO_NONE, NULL, &p_err); + if (p_enum == NULL) { + if (p_err != NULL) { + g_warning("navigator: enumerate failed: %s", p_err->message); + g_error_free(p_err); + } + return; + } + + GPtrArray *p_entries = g_ptr_array_new_with_free_func(_entry_free); + GHashTable *p_jpeg_stems = + g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + + GFileInfo *p_info = NULL; + while ((p_info = g_file_enumerator_next_file(p_enum, NULL, NULL)) != NULL) { + const char *c_name = g_file_info_get_name(p_info); + if (c_name == NULL || c_name[0] == '.') { + g_object_unref(p_info); + continue; + } + if (g_file_info_get_file_type(p_info) != G_FILE_TYPE_REGULAR) { + g_object_unref(p_info); + continue; + } + const char *c_ct = g_file_info_get_content_type(p_info); + if (!_is_image_file(c_name, c_ct)) { + g_object_unref(p_info); + continue; + } + Entry *p_e = g_new(Entry, 1); + p_e->file = g_file_get_child(p_nav->p_dir, c_name); + p_e->name = g_strdup(c_name); + p_e->mtime = (gint64)g_file_info_get_attribute_uint64( + p_info, G_FILE_ATTRIBUTE_TIME_MODIFIED); + p_e->size = (gint64)g_file_info_get_size(p_info); + g_object_unref(p_info); + + const char *c_ext = _ext_of(p_e->name); /* p_e->name is owned; c_name + * was borrowed from p_info */ + if (_is_jpeg_ext(c_ext)) { + char *c_stem = _stem_lower(p_e->name); + g_hash_table_add(p_jpeg_stems, c_stem); + } + g_ptr_array_add(p_entries, p_e); + } + g_object_unref(p_enum); + + /* Drop RAW sidecars that have a JPEG twin (decision #33). */ + if (p_nav->b_hide_raw) { + for (gsize u_i = p_entries->len; u_i > 0; u_i--) { + Entry *p_e = (Entry *)g_ptr_array_index(p_entries, u_i - 1); + const char *c_ext = _ext_of(p_e->name); + if (_is_raw_ext(c_ext)) { + char *c_stem = _stem_lower(p_e->name); + if (g_hash_table_contains(p_jpeg_stems, c_stem)) { + g_ptr_array_remove_index(p_entries, u_i - 1); + } + g_free(c_stem); + } + } + } + g_hash_table_unref(p_jpeg_stems); + + g_ptr_array_sort_with_data(p_entries, _compare_entries, + GINT_TO_POINTER((gint)p_nav->e_sort)); + + /* Remember current by path, then commit. */ + GFile *p_keep = NULL; + if (p_nav->i_current >= 0 && (guint)p_nav->i_current < p_nav->p_files->len) { + p_keep = g_object_ref( + g_ptr_array_index(p_nav->p_files, (guint)p_nav->i_current)); + } + gint i_old = p_nav->i_current; + + g_ptr_array_set_size(p_nav->p_files, 0); + for (gsize u_i = 0; u_i < p_entries->len; u_i++) { + Entry *p_e = (Entry *)g_ptr_array_index(p_entries, u_i); + g_ptr_array_add(p_nav->p_files, g_object_ref(p_e->file)); + } + + if (p_keep != NULL) { + gint i_found = _find_index_by_file(p_nav, p_keep); + if (i_found >= 0) { + p_nav->i_current = i_found; + } else if (p_nav->p_files->len == 0) { + p_nav->i_current = -1; + } else { + p_nav->i_current = CLAMP(i_old, 0, (gint)p_nav->p_files->len - 1); + } + g_object_unref(p_keep); + } else if (p_nav->p_files->len == 0) { + p_nav->i_current = -1; + } else if (p_nav->i_current < 0) { + p_nav->i_current = 0; + } + + /* Prune marks whose file is no longer in the listing (external delete via + * the monitor, or a rescan). Keeps marks consistent with the live folder. */ + GHashTableIter iter; + gpointer p_key; + g_hash_table_iter_init(&iter, p_nav->p_marks); + while (g_hash_table_iter_next(&iter, &p_key, NULL)) { + if (_find_index_by_file(p_nav, (GFile *)p_key) < 0) { + g_hash_table_iter_remove(&iter); + } + } + + g_ptr_array_unref(p_entries); +} + +static void +_emit_changed(Navigator *p_nav) { + g_signal_emit(p_nav, u_changed_signal, 0); +} + +/* --- monitor / debounce -------------------------------------------------- */ + +static gboolean +_debounce_fire(gpointer p_data) { + Navigator *p_nav = (Navigator *)p_data; + p_nav->u_debounce_id = 0; + _relist(p_nav); + _emit_changed(p_nav); + return (G_SOURCE_REMOVE); +} + +static void +_schedule_debounce(Navigator *p_nav) { + if (p_nav->u_debounce_id != 0) { + return; /* already pending; the single rescan picks up all changes */ + } + p_nav->u_debounce_id = + g_timeout_add(p_nav->u_debounce_ms, _debounce_fire, p_nav); +} + +static void +monitor_changed_cb(GFileMonitor *p_mon, GFile *p_other, GFile *p_src, + GFileMonitorEvent e_ev, gpointer p_data) { + (void)p_mon; + (void)p_other; + (void)p_src; + switch (e_ev) { + case G_FILE_MONITOR_EVENT_CREATED: + case G_FILE_MONITOR_EVENT_DELETED: + case G_FILE_MONITOR_EVENT_MOVED_IN: + case G_FILE_MONITOR_EVENT_MOVED_OUT: + case G_FILE_MONITOR_EVENT_RENAMED: + _schedule_debounce((Navigator *)p_data); + break; + default: + break; + } +} + +/* --- GObject ------------------------------------------------------------- */ + +static void +navigator_dispose(GObject *p_obj) { + Navigator *p_nav = (Navigator *)p_obj; + if (p_nav->u_debounce_id != 0) { + g_source_remove(p_nav->u_debounce_id); + p_nav->u_debounce_id = 0; + } + if (p_nav->p_monitor != NULL) { + g_file_monitor_cancel(p_nav->p_monitor); + g_clear_object(&p_nav->p_monitor); + } + g_clear_pointer(&p_nav->p_marks, g_hash_table_unref); + g_clear_pointer(&p_nav->p_files, g_ptr_array_unref); + g_clear_object(&p_nav->p_dir); + G_OBJECT_CLASS(navigator_parent_class)->dispose(p_obj); +} + +static void +navigator_class_init(NavigatorClass *p_klass) { + GObjectClass *p_oc = G_OBJECT_CLASS(p_klass); + p_oc->dispose = navigator_dispose; + u_changed_signal = + g_signal_new("changed", G_TYPE_FROM_CLASS(p_klass), G_SIGNAL_RUN_LAST, 0, + NULL, NULL, g_cclosure_marshal_generic, G_TYPE_NONE, 0); +} + +static void +navigator_init(Navigator *p_nav) { + p_nav->p_files = + g_ptr_array_new_with_free_func((GDestroyNotify)g_object_unref); + p_nav->p_marks = + g_hash_table_new_full((GHashFunc)g_file_hash, (GEqualFunc)g_file_equal, + (GDestroyNotify)g_object_unref, NULL); + p_nav->i_current = -1; + p_nav->e_sort = GGAZE_SORT_NAME; + p_nav->b_wrap = TRUE; + p_nav->b_hide_raw = TRUE; + p_nav->u_debounce_ms = GGAZE_DEFAULT_DEBOUNCE_MS; +} + +/* --- public API ---------------------------------------------------------- */ + +Navigator * +navigator_new(GFile *p_dir, GgazeSort e_sort, gboolean b_wrap, + gboolean b_hide_raw) { + g_return_val_if_fail(G_IS_FILE(p_dir), NULL); + Navigator *p_nav = (Navigator *)g_object_new(GGAZE_TYPE_NAVIGATOR, NULL); + p_nav->p_dir = (GFile *)g_object_ref(p_dir); + p_nav->e_sort = e_sort; + p_nav->b_wrap = b_wrap; + p_nav->b_hide_raw = b_hide_raw; + _relist(p_nav); + if (p_nav->i_current < 0 && p_nav->p_files->len > 0) { + p_nav->i_current = 0; + } + + GError *p_err = NULL; + p_nav->p_monitor = g_file_monitor_directory( + p_nav->p_dir, G_FILE_MONITOR_WATCH_MOVES, NULL, &p_err); + if (p_nav->p_monitor != NULL) { + g_signal_connect(p_nav->p_monitor, "changed", + G_CALLBACK(monitor_changed_cb), p_nav); + } else { + if (p_err != NULL) { + g_warning("navigator: monitor failed: %s", p_err->message); + g_error_free(p_err); + } + } + return (p_nav); +} + +void +navigator_delete(Navigator *p_nav) { + if (p_nav != NULL) { + g_object_unref(p_nav); + } +} + +GFile * +navigator_get_dir(Navigator *p_nav) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), NULL); + return (p_nav->p_dir); +} + +guint +navigator_get_count(Navigator *p_nav) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), 0); + return (p_nav->p_files->len); +} + +GFile * +navigator_get_file(Navigator *p_nav, guint u_index) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), NULL); + g_return_val_if_fail(u_index < p_nav->p_files->len, NULL); + return ((GFile *)g_ptr_array_index(p_nav->p_files, u_index)); +} + +gint +navigator_get_current_index(Navigator *p_nav) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), -1); + return (p_nav->i_current); +} + +GFile * +navigator_get_current(Navigator *p_nav) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), NULL); + if (p_nav->i_current < 0 || (guint)p_nav->i_current >= p_nav->p_files->len) { + return (NULL); + } + return ((GFile *)g_ptr_array_index(p_nav->p_files, (guint)p_nav->i_current)); +} + +guint +navigator_get_remaining(Navigator *p_nav) { + return (navigator_get_count(p_nav)); +} + +gboolean +navigator_set_current(Navigator *p_nav, guint u_index) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), FALSE); + if (u_index >= p_nav->p_files->len) { + return (FALSE); + } + if (p_nav->i_current == (gint)u_index) { + return (FALSE); + } + p_nav->i_current = (gint)u_index; + _emit_changed(p_nav); + return (TRUE); +} + +gboolean +navigator_set_current_file(Navigator *p_nav, GFile *p_file) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), FALSE); + gint i = _find_index_by_file(p_nav, p_file); + if (i < 0) { + return (FALSE); + } + if (p_nav->i_current == i) { + return (FALSE); + } + p_nav->i_current = i; + _emit_changed(p_nav); + return (TRUE); +} + +static gboolean +_advance(Navigator *p_nav, gint i_delta) { + if (p_nav->p_files->len == 0) { + return (FALSE); + } + gint i_new = p_nav->i_current + i_delta; + if (i_new < 0 || i_new >= (gint)p_nav->p_files->len) { + if (!p_nav->b_wrap) { + return (FALSE); + } + i_new = (i_new < 0) ? (gint)p_nav->p_files->len - 1 : 0; + } + if (i_new == p_nav->i_current) { + return (FALSE); + } + p_nav->i_current = i_new; + _emit_changed(p_nav); + return (TRUE); +} + +gboolean +navigator_prev(Navigator *p_nav) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), FALSE); + return (_advance(p_nav, -1)); +} + +gboolean +navigator_next(Navigator *p_nav) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), FALSE); + return (_advance(p_nav, 1)); +} + +gboolean +navigator_first(Navigator *p_nav) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), FALSE); + return (navigator_set_current(p_nav, 0)); +} + +gboolean +navigator_last(Navigator *p_nav) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), FALSE); + if (p_nav->p_files->len == 0) { + return (FALSE); + } + return (navigator_set_current(p_nav, p_nav->p_files->len - 1)); +} + +GgazeSort +navigator_get_sort(Navigator *p_nav) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), GGAZE_SORT_NAME); + return (p_nav->e_sort); +} + +void +navigator_set_sort(Navigator *p_nav, GgazeSort e_sort) { + g_return_if_fail(GGAZE_IS_NAVIGATOR(p_nav)); + if (p_nav->e_sort == e_sort) { + return; + } + p_nav->e_sort = e_sort; + _relist(p_nav); + _emit_changed(p_nav); +} + +gboolean +navigator_get_wrap(Navigator *p_nav) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), FALSE); + return (p_nav->b_wrap); +} + +void +navigator_set_wrap(Navigator *p_nav, gboolean b_wrap) { + g_return_if_fail(GGAZE_IS_NAVIGATOR(p_nav)); + p_nav->b_wrap = b_wrap; +} + +gboolean +navigator_get_hide_raw(Navigator *p_nav) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), FALSE); + return (p_nav->b_hide_raw); +} + +void +navigator_set_hide_raw(Navigator *p_nav, gboolean b_hide_raw) { + g_return_if_fail(GGAZE_IS_NAVIGATOR(p_nav)); + if (p_nav->b_hide_raw == b_hide_raw) { + return; + } + p_nav->b_hide_raw = b_hide_raw; + _relist(p_nav); + _emit_changed(p_nav); +} + +/* --- marks --------------------------------------------------------------- */ + +gboolean +navigator_is_marked(Navigator *p_nav, GFile *p_file) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), FALSE); + return (g_hash_table_contains(p_nav->p_marks, p_file)); +} + +void +navigator_toggle_mark(Navigator *p_nav, GFile *p_file) { + g_return_if_fail(GGAZE_IS_NAVIGATOR(p_nav)); + g_return_if_fail(G_IS_FILE(p_file)); + if (g_hash_table_contains(p_nav->p_marks, p_file)) { + g_hash_table_remove(p_nav->p_marks, p_file); + } else { + g_hash_table_add(p_nav->p_marks, g_object_ref(p_file)); + } +} + +static void +_mark_index(Navigator *p_nav, guint u_index) { + if (u_index >= p_nav->p_files->len) { + return; + } + GFile *p_file = (GFile *)g_ptr_array_index(p_nav->p_files, u_index); + if (!g_hash_table_contains(p_nav->p_marks, p_file)) { + g_hash_table_add(p_nav->p_marks, g_object_ref(p_file)); + } +} + +void +navigator_mark_range(Navigator *p_nav, GFile *p_from, GFile *p_to) { + g_return_if_fail(GGAZE_IS_NAVIGATOR(p_nav)); + gint i_a = _find_index_by_file(p_nav, p_from); + gint i_b = _find_index_by_file(p_nav, p_to); + if (i_a < 0 || i_b < 0) { + return; + } + gint i_lo = MIN(i_a, i_b); + gint i_hi = MAX(i_a, i_b); + for (gint i = i_lo; i <= i_hi; i++) { + _mark_index(p_nav, (guint)i); + } + _emit_changed(p_nav); +} + +void +navigator_mark_all(Navigator *p_nav) { + g_return_if_fail(GGAZE_IS_NAVIGATOR(p_nav)); + for (guint u_i = 0; u_i < p_nav->p_files->len; u_i++) { + _mark_index(p_nav, u_i); + } + _emit_changed(p_nav); +} + +void +navigator_clear_marks(Navigator *p_nav) { + g_return_if_fail(GGAZE_IS_NAVIGATOR(p_nav)); + g_hash_table_remove_all(p_nav->p_marks); + _emit_changed(p_nav); +} + +guint +navigator_get_mark_count(Navigator *p_nav) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), 0); + return (g_hash_table_size(p_nav->p_marks)); +} + +GList * +navigator_get_marks(Navigator *p_nav) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), NULL); + GList *p_out = NULL; + GList *p_keys = g_hash_table_get_keys(p_nav->p_marks); + for (GList *p_it = p_keys; p_it != NULL; p_it = p_it->next) { + p_out = g_list_prepend(p_out, g_object_ref((GFile *)p_it->data)); + } + g_list_free(p_keys); + return (g_list_reverse(p_out)); +} + +/* --- mutations ----------------------------------------------------------- */ + +void +navigator_rescan(Navigator *p_nav) { + g_return_if_fail(GGAZE_IS_NAVIGATOR(p_nav)); + _relist(p_nav); + _emit_changed(p_nav); +} + +gboolean +navigator_remove(Navigator *p_nav, GFile *p_file) { + g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), FALSE); + g_return_val_if_fail(G_IS_FILE(p_file), FALSE); + gint i = _find_index_by_file(p_nav, p_file); + if (i < 0) { + return (FALSE); + } + /* Clear its mark first (decision Q) while the file ref is still valid. */ + g_hash_table_remove(p_nav->p_marks, p_file); + g_ptr_array_remove_index(p_nav->p_files, (guint)i); + if (p_nav->p_files->len == 0) { + p_nav->i_current = -1; + } else if (p_nav->i_current == i) { + /* Was current: fall back to nearest (stay at this index -> the next file, + * or the new last if we removed the tail). */ + p_nav->i_current = MIN(i, (gint)p_nav->p_files->len - 1); + } else if (p_nav->i_current > i) { + p_nav->i_current--; + } + _emit_changed(p_nav); + return (TRUE); +} + +void +navigator_set_debounce_ms(Navigator *p_nav, guint u_ms) { + g_return_if_fail(GGAZE_IS_NAVIGATOR(p_nav)); + p_nav->u_debounce_ms = u_ms; +} + +void +navigator_emit_changed(Navigator *p_nav) { + g_return_if_fail(GGAZE_IS_NAVIGATOR(p_nav)); + _emit_changed(p_nav); +} \ No newline at end of file diff --git a/src/navigator.h b/src/navigator.h new file mode 100644 index 0000000..b672480 --- /dev/null +++ b/src/navigator.h @@ -0,0 +1,99 @@ +#ifndef GGAZE_NAVIGATOR_H +#define GGAZE_NAVIGATOR_H + +/*:* + * ggaze — directory navigator + * + * Navigator is a GObject (no GtkWidget) that owns the current directory + * listing: it filters to image MIME types, sorts (name/time/size), keeps a + * cursor and a path-based mark set, watches the directory with GFileMonitor + * (debounced) and emits "changed" on structural changes so the views stay in + * sync. If the current file disappears (external delete, or trash/move) it + * falls back to the nearest. Owns no GTK state; unit-testable without a + * display. See docs/architecture.md "Responsibilities / navigator". + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include +#include +#include + +G_BEGIN_DECLS + +#define GGAZE_TYPE_NAVIGATOR (navigator_get_type()) +G_DECLARE_FINAL_TYPE(Navigator, navigator, GGAZE, NAVIGATOR, GObject) + +typedef enum { + GGAZE_SORT_NAME = 0, + GGAZE_SORT_TIME, + GGAZE_SORT_SIZE +} GgazeSort; + +/* Construct a navigator over p_dir (file or folder; a file's parent is used by + * the caller). Refs p_dir. Lists immediately. e_sort is the initial sort; + * b_wrap controls prev/next wrap; b_hide_raw hides RAW sidecars when a JPEG + * with the same stem exists (decision #33). The current cursor is the first + * file. */ +Navigator *navigator_new(GFile *p_dir, GgazeSort e_sort, gboolean b_wrap, + gboolean b_hide_raw); + +/* g_object_unref wrapper for the _new/_delete convention. */ +void navigator_delete(Navigator *p_nav); + +/* --- listing ------------------------------------------------------------- */ +GFile *navigator_get_dir(Navigator *p_nav); /* (transfer none) */ +guint navigator_get_count(Navigator *p_nav); +GFile *navigator_get_file(Navigator *p_nav, + guint u_index); /* (transfer none) */ +gint navigator_get_current_index(Navigator *p_nav); /* -1 if empty */ +GFile *navigator_get_current(Navigator *p_nav); /* (transfer none) */ +guint +navigator_get_remaining(Navigator *p_nav); /* count; M7 excludes trashed */ +gboolean navigator_set_current(Navigator *p_nav, guint u_index); +gboolean navigator_set_current_file(Navigator *p_nav, + GFile *p_file); /* by path */ + +/* --- navigation (honour wrap) ------------------------------------------- */ +gboolean navigator_prev(Navigator *p_nav); +gboolean navigator_next(Navigator *p_nav); +gboolean navigator_first(Navigator *p_nav); +gboolean navigator_last(Navigator *p_nav); + +/* --- sort/filter --------------------------------------------------------- */ +GgazeSort navigator_get_sort(Navigator *p_nav); +void navigator_set_sort(Navigator *p_nav, GgazeSort e_sort); +gboolean navigator_get_wrap(Navigator *p_nav); +void navigator_set_wrap(Navigator *p_nav, gboolean b_wrap); +gboolean navigator_get_hide_raw(Navigator *p_nav); +void navigator_set_hide_raw(Navigator *p_nav, gboolean b_hide_raw); + +/* --- marks (path-based; survive re-sort; cleared on remove) ------------- */ +gboolean navigator_is_marked(Navigator *p_nav, GFile *p_file); +void navigator_toggle_mark(Navigator *p_nav, GFile *p_file); +void navigator_mark_range(Navigator *p_nav, GFile *p_from, GFile *p_to); +void navigator_mark_all(Navigator *p_nav); +void navigator_clear_marks(Navigator *p_nav); +guint navigator_get_mark_count(Navigator *p_nav); +GList *navigator_get_marks(Navigator *p_nav); /* (transfer full) GFile* refs */ + +/* --- mutations ---------------------------------------------------------- */ +/* Re-read the directory; if the current file is gone, fall back to nearest; + * emit "changed". */ +void navigator_rescan(Navigator *p_nav); +/* Remove p_file from the listing (used by trash/move); clear its mark; if it + * was current, fall back to nearest; emit "changed". Returns TRUE if removed. + */ +gboolean navigator_remove(Navigator *p_nav, GFile *p_file); + +/* GFileMonitor debounce in ms (default 250; decision #28). Tests may lower it. + */ +void navigator_set_debounce_ms(Navigator *p_nav, guint u_ms); + +/* "changed" signal: emitted on sort/filter/rescan/remove/monitor event. */ +void navigator_emit_changed(Navigator *p_nav); + +G_END_DECLS + +#endif /* GGAZE_NAVIGATOR_H */ \ No newline at end of file diff --git a/src/shortcuts.c b/src/shortcuts.c new file mode 100644 index 0000000..958af22 --- /dev/null +++ b/src/shortcuts.c @@ -0,0 +1,49 @@ +/*:* + * ggaze — keybinding -> GAction map + * + * Default keybinding table bound to "win.*" actions. Milestones add rows here + * as new actions land. See docs/ui-and-interactions.md keybindings table. + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include "shortcuts.h" + +#include + +typedef struct { + guint u_keyval; + GdkModifierType e_mods; + const char *c_action; /* e.g. "win.next" */ +} ShortcutEntry; + +static const ShortcutEntry SHORTCUTS[] = { + /* navigation (decision #7: vi-style + cursor keys) */ + {GDK_KEY_h, 0, "win.prev"}, + {GDK_KEY_Left, 0, "win.prev"}, + {GDK_KEY_l, 0, "win.next"}, + {GDK_KEY_Right, 0, "win.next"}, + {GDK_KEY_g, 0, "win.first"}, + {GDK_KEY_G, 0, "win.last"}, + /* open / quit / back */ + {GDK_KEY_o, 0, "win.open"}, + {GDK_KEY_q, 0, "win.quit"}, +}; + +void +shortcuts_install(GtkWidget *p_widget) { + g_return_if_fail(GTK_IS_WIDGET(p_widget)); + GtkEventController *p_ctrl = gtk_shortcut_controller_new(); + gtk_shortcut_controller_set_scope(GTK_SHORTCUT_CONTROLLER(p_ctrl), + GTK_SHORTCUT_SCOPE_MANAGED); + for (gsize u_i = 0; u_i < G_N_ELEMENTS(SHORTCUTS); u_i++) { + GtkShortcut *p_s = + gtk_shortcut_new(GTK_SHORTCUT_TRIGGER(gtk_keyval_trigger_new( + SHORTCUTS[u_i].u_keyval, SHORTCUTS[u_i].e_mods)), + gtk_named_action_new(SHORTCUTS[u_i].c_action)); + gtk_shortcut_controller_add_shortcut(GTK_SHORTCUT_CONTROLLER(p_ctrl), + p_s); + } + gtk_widget_add_controller(p_widget, GTK_EVENT_CONTROLLER(p_ctrl)); +} \ No newline at end of file diff --git a/src/shortcuts.h b/src/shortcuts.h new file mode 100644 index 0000000..8392ea0 --- /dev/null +++ b/src/shortcuts.h @@ -0,0 +1,26 @@ +#ifndef GGAZE_SHORTCUTS_H +#define GGAZE_SHORTCUTS_H + +/*:* + * ggaze — keybinding -> GAction map + * + * Installs a GtkShortcutController on a window, binding keys to named actions + * ("win.*"). One table all milestones add to; see docs/ui-and-interactions.md + * for the full keybinding set. + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include + +G_BEGIN_DECLS + +/* Build a shortcut controller for the default keybinding set and attach it to + * p_widget (the window). The actions ("win.prev", "win.next", ...) must be + * installed on p_widget's GActionMap by the caller. */ +void shortcuts_install(GtkWidget *p_widget); + +G_END_DECLS + +#endif /* GGAZE_SHORTCUTS_H */ \ No newline at end of file diff --git a/src/viewer.h b/src/viewer.h index 81bac79..bc4fb63 100644 --- a/src/viewer.h +++ b/src/viewer.h @@ -28,7 +28,8 @@ 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) */ +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); diff --git a/src/window.c b/src/window.c index 88df578..7ce7b38 100644 --- a/src/window.c +++ b/src/window.c @@ -1,11 +1,11 @@ /*:* * ggaze — main window * - * Implements GgazeWindow. Builds an AdwHeaderBar + a GtkStack with two named - * children: "grid" (placeholder until M7) and "large" (the GgazeViewer from - * M1). ggaze_window_open() loads the file via the loader and shows it in the - * viewer. Real file-vs-folder resolution lands in M2 (navigator); M1 just loads - * the one file. See docs/architecture.md "Responsibilities / window". + * GgazeWindow : GtkApplicationWindow owns an AdwHeaderBar + a GtkStack with two + * children ("grid" placeholder until M7, "large" = the GgazeViewer). M2 adds a + * Navigator over the current folder, a single GCancellable (last-write-wins), + * keybinding->action shortcuts, and a file/folder drop target. The header + * title carries "filename · n/total". See docs/architecture.md. * * Copyright (c) 2026 ggaze contributors * SPDX-License-Identifier: GPL-3.0-or-later @@ -15,23 +15,210 @@ #include #include +#include -#include "viewer.h" #include "loader/loader.h" +#include "navigator.h" +#include "shortcuts.h" +#include "viewer.h" struct _GgazeWindow { GtkApplicationWindow parent_instance; - GFile *p_file; /* current file/folder, remembered for later use */ - GtkWidget *p_stack; /* GtkStack: grid (placeholder) / large (viewer) */ - GtkWidget *p_viewer; /* GgazeViewer — the large view */ + Navigator *p_nav; /* current folder listing (NULL until open) */ + GCancellable *p_cancel; /* single in-flight load; cancelled on each nav */ + GtkWidget *p_stack; /* GtkStack: grid (placeholder) / large (viewer) */ + GtkWidget *p_viewer; /* GgazeViewer — the large view */ }; G_DEFINE_TYPE(GgazeWindow, ggaze_window, GTK_TYPE_APPLICATION_WINDOW) +/* --- forward decls ------------------------------------------------------- */ +static void _load_current(GgazeWindow *p_win); +static void _update_header(GgazeWindow *p_win); + +/* --- actions ------------------------------------------------------------- */ + +static void +_action_prev(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) { + (void)p_a; + (void)p_v; + ggaze_window_prev(GGAZE_WINDOW(p_data)); +} + +static void +_action_next(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) { + (void)p_a; + (void)p_v; + ggaze_window_next(GGAZE_WINDOW(p_data)); +} + +static void +_action_first(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) { + (void)p_a; + (void)p_v; + ggaze_window_first(GGAZE_WINDOW(p_data)); +} + +static void +_action_last(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) { + (void)p_a; + (void)p_v; + ggaze_window_last(GGAZE_WINDOW(p_data)); +} + +static void +_action_quit(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) { + (void)p_a; + (void)p_v; + gtk_window_close(GTK_WINDOW(p_data)); +} + +static void +_open_dialog_cb(GObject *p_src, GAsyncResult *p_res, gpointer p_data) { + GtkFileDialog *p_dlg = GTK_FILE_DIALOG(p_src); + GError *p_err = NULL; + GFile *p_file = gtk_file_dialog_open_finish(p_dlg, p_res, &p_err); + if (p_file != NULL) { + ggaze_window_open(GGAZE_WINDOW(p_data), p_file); + g_object_unref(p_file); + } else if (p_err != NULL) { + if (!g_error_matches(p_err, GTK_DIALOG_ERROR, + GTK_DIALOG_ERROR_DISMISSED)) { + g_warning("ggaze: open dialog failed: %s", p_err->message); + } + g_error_free(p_err); + } + g_object_unref(p_data); +} + +static void +_action_open(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) { + (void)p_a; + (void)p_v; + GgazeWindow *p_win = GGAZE_WINDOW(p_data); + GtkFileDialog *p_dlg = gtk_file_dialog_new(); + gtk_file_dialog_set_title(p_dlg, "Open image"); + gtk_file_dialog_open(p_dlg, GTK_WINDOW(p_win), NULL, _open_dialog_cb, + g_object_ref(p_win)); + g_object_unref(p_dlg); +} + +static const GActionEntry ACTIONS[] = { + {.name = "prev", .activate = _action_prev}, + {.name = "next", .activate = _action_next}, + {.name = "first", .activate = _action_first}, + {.name = "last", .activate = _action_last}, + {.name = "open", .activate = _action_open}, + {.name = "quit", .activate = _action_quit}, +}; + +/* --- drop target --------------------------------------------------------- */ + +static gboolean +drop_cb(GtkDropTarget *p_t, const GValue *p_val, gdouble d_x, gdouble d_y, + gpointer p_data) { + (void)p_t; + (void)d_x; + (void)d_y; + GgazeWindow *p_win = GGAZE_WINDOW(p_data); + if (!G_VALUE_HOLDS(p_val, GDK_TYPE_FILE_LIST)) { + return (FALSE); + } + GdkFileList *p_fl = (GdkFileList *)g_value_get_boxed(p_val); + GSList *p_files = gdk_file_list_get_files(p_fl); + /* Decision Z: many files -> first file's folder with the first current. */ + if (p_files != NULL) { + ggaze_window_open(p_win, G_FILE(p_files->data)); + return (TRUE); + } + return (FALSE); +} + +/* --- navigator changed -> reload ----------------------------------------- */ + +static void +nav_changed_cb(Navigator *p_nav, gpointer p_data) { + (void)p_nav; + _load_current(GGAZE_WINDOW(p_data)); +} + +/* --- load current into the viewer ---------------------------------------- */ + +static void +_load_current(GgazeWindow *p_win) { + if (p_win->p_nav == NULL) { + return; + } + /* Single in-flight load: cancel the previous and start a new one. + * M1/M2 use synchronous load; M3 swaps this for a GTask. */ + g_cancellable_cancel(p_win->p_cancel); + g_clear_object(&p_win->p_cancel); + p_win->p_cancel = g_cancellable_new(); + + GFile *p_cur = navigator_get_current(p_win->p_nav); + if (p_cur == NULL) { + ggaze_viewer_set_texture(GGAZE_VIEWER(p_win->p_viewer), NULL); + _update_header(p_win); + return; + } + + GError *p_err = NULL; + GdkTexture *p_tex = loader_load(p_cur, p_win->p_cancel, &p_err); + if (p_tex != NULL) { + /* Last-write-wins: only show if this is still the current file. Sync + * load makes this trivially true; the guard is here for M3's async path. + */ + GFile *p_now = navigator_get_current(p_win->p_nav); + if (p_now != NULL && g_file_equal(p_now, p_cur)) { + ggaze_viewer_set_texture(GGAZE_VIEWER(p_win->p_viewer), p_tex); + gtk_stack_set_visible_child_name(GTK_STACK(p_win->p_stack), "large"); + } + g_object_unref(p_tex); + } else { + const gchar *c_msg = (p_err != NULL) ? p_err->message : "unknown error"; + char *c_name = g_file_get_basename(p_cur); + g_warning("ggaze: failed to load %s: %s", c_name, c_msg); + g_free(c_name); + g_clear_error(&p_err); + } + _update_header(p_win); +} + +static void +_update_header(GgazeWindow *p_win) { + gchar *c_title = NULL; + if (p_win->p_nav != NULL) { + GFile *p_cur = navigator_get_current(p_win->p_nav); + guint u_total = navigator_get_count(p_win->p_nav); + gint i_idx = navigator_get_current_index(p_win->p_nav); + if (p_cur != NULL) { + char *c_name = g_file_get_basename(p_cur); + if (u_total > 0 && i_idx >= 0) { + c_title = + g_strdup_printf("%s \u00b7 %d/%u", c_name, i_idx + 1, u_total); + } else { + c_title = g_strdup(c_name); + } + g_free(c_name); + } + } + if (c_title == NULL) { + c_title = g_strdup("ggaze"); + } + gtk_window_set_title(GTK_WINDOW(p_win), c_title); + g_free(c_title); +} + +/* --- GObject ------------------------------------------------------------- */ + static void ggaze_window_dispose(GObject *p_obj) { GgazeWindow *p_win = GGAZE_WINDOW(p_obj); - g_clear_object(&p_win->p_file); + if (p_win->p_nav != NULL) { + g_signal_handlers_disconnect_by_data(p_win->p_nav, p_win); + g_clear_object(&p_win->p_nav); + } + g_clear_object(&p_win->p_cancel); /* p_stack/p_viewer are GtkWidgets parented to the window; GTK releases them. */ G_OBJECT_CLASS(ggaze_window_parent_class)->dispose(p_obj); @@ -45,6 +232,8 @@ ggaze_window_class_init(GgazeWindowClass *p_klass) { static void ggaze_window_init(GgazeWindow *p_win) { + p_win->p_cancel = g_cancellable_new(); + /* Header bar (libadwaita, decision #29). */ GtkWidget *p_header = adw_header_bar_new(); gtk_window_set_titlebar(GTK_WINDOW(p_win), p_header); @@ -66,8 +255,21 @@ ggaze_window_init(GgazeWindow *p_win) { gtk_stack_add_named(GTK_STACK(p_win->p_stack), p_win->p_viewer, "large"); gtk_stack_set_visible_child_name(GTK_STACK(p_win->p_stack), "grid"); + + /* Actions + keybindings (decision #10/#12). */ + g_action_map_add_action_entries(G_ACTION_MAP(p_win), ACTIONS, + G_N_ELEMENTS(ACTIONS), p_win); + shortcuts_install(GTK_WIDGET(p_win)); + + /* File/folder drag-and-drop (decision #27). */ + GtkDropTarget *p_drop = + gtk_drop_target_new(GDK_TYPE_FILE_LIST, GDK_ACTION_COPY); + g_signal_connect(p_drop, "drop", G_CALLBACK(drop_cb), p_win); + gtk_widget_add_controller(GTK_WIDGET(p_win), GTK_EVENT_CONTROLLER(p_drop)); } +/* --- public -------------------------------------------------------------- */ + GgazeWindow * ggaze_window_new(GgazeApp *p_app) { return (GGAZE_WINDOW(g_object_new(GGAZE_TYPE_WINDOW, "application", p_app, @@ -76,25 +278,70 @@ ggaze_window_new(GgazeApp *p_app) { } void -ggaze_window_open(GgazeWindow *p_win, GFile *p_file) { +ggaze_window_open(GgazeWindow *p_win, GFile *p_arg) { g_return_if_fail(GGAZE_IS_WINDOW(p_win)); - g_return_if_fail(G_IS_FILE(p_file)); - g_set_object(&p_win->p_file, p_file); + g_return_if_fail(G_IS_FILE(p_arg)); - char *c_name = g_file_get_basename(p_file); - gtk_window_set_title(GTK_WINDOW(p_win), c_name); + if (p_win->p_nav != NULL) { + g_signal_handlers_disconnect_by_data(p_win->p_nav, p_win); + g_clear_object(&p_win->p_nav); + } - /* Load and display (M1: synchronous; M3 makes this async with cancel). */ - GError *p_err = NULL; - GdkTexture *p_tex = loader_load(p_file, NULL, &p_err); - if (p_tex != NULL) { - ggaze_viewer_set_texture(GGAZE_VIEWER(p_win->p_viewer), p_tex); - gtk_stack_set_visible_child_name(GTK_STACK(p_win->p_stack), "large"); - g_object_unref(p_tex); /* viewer holds its own ref */ + GFile *p_dir = NULL; + GFile *p_start = NULL; + GFileType e_type = + g_file_query_file_type(p_arg, G_FILE_QUERY_INFO_NONE, NULL); + if (e_type == G_FILE_TYPE_DIRECTORY) { + p_dir = (GFile *)g_object_ref(p_arg); } else { - const gchar *c_msg = (p_err != NULL) ? p_err->message : "unknown error"; - g_warning("ggaze: failed to load %s: %s", c_name, c_msg); - g_clear_error(&p_err); + p_dir = g_file_get_parent(p_arg); + p_start = (GFile *)g_object_ref(p_arg); + } + if (p_dir == NULL) { + g_clear_object(&p_start); + return; + } + + p_win->p_nav = navigator_new(p_dir, GGAZE_SORT_NAME, TRUE, TRUE); + g_clear_object(&p_dir); + g_signal_connect(p_win->p_nav, "changed", G_CALLBACK(nav_changed_cb), p_win); + if (p_start != NULL) { + navigator_set_current_file(p_win->p_nav, p_start); + g_clear_object(&p_start); + } + + gtk_stack_set_visible_child_name(GTK_STACK(p_win->p_stack), "large"); + _load_current(p_win); +} + +void +ggaze_window_prev(GgazeWindow *p_win) { + g_return_if_fail(GGAZE_IS_WINDOW(p_win)); + if (p_win->p_nav != NULL) { + navigator_prev(p_win->p_nav); /* emits "changed" -> _load_current */ + } +} + +void +ggaze_window_next(GgazeWindow *p_win) { + g_return_if_fail(GGAZE_IS_WINDOW(p_win)); + if (p_win->p_nav != NULL) { + navigator_next(p_win->p_nav); + } +} + +void +ggaze_window_first(GgazeWindow *p_win) { + g_return_if_fail(GGAZE_IS_WINDOW(p_win)); + if (p_win->p_nav != NULL) { + navigator_first(p_win->p_nav); + } +} + +void +ggaze_window_last(GgazeWindow *p_win) { + g_return_if_fail(GGAZE_IS_WINDOW(p_win)); + if (p_win->p_nav != NULL) { + navigator_last(p_win->p_nav); } - g_free(c_name); } \ No newline at end of file diff --git a/src/window.h b/src/window.h index 5261a8f..10612db 100644 --- a/src/window.h +++ b/src/window.h @@ -6,9 +6,9 @@ * * GgazeWindow : GtkApplicationWindow owns the layout: an AdwHeaderBar and a * GtkStack with two children (`grid`, `large`). The grid child is a placeholder - * until M7; the large child is the GgazeViewer (M1). The window remembers the - * current GFile so later milestones can build on it. See - * docs/architecture.md "Responsibilities / window". + * until M7; the large child is the GgazeViewer (M1). M2 adds a Navigator over + * the current folder, a single GCancellable (last-write-wins), keybinding + * shortcuts, and a file/folder drop target. See docs/architecture.md. * * Copyright (c) 2026 ggaze contributors * SPDX-License-Identifier: GPL-3.0-or-later @@ -27,10 +27,17 @@ G_DECLARE_FINAL_TYPE(GgazeWindow, ggaze_window, GGAZE, WINDOW, /* Construct a new window attached to p_app. */ GgazeWindow *ggaze_window_new(GgazeApp *p_app); -/* Remember p_file as the current file (or folder) and title the window with - * its basename. The GFile reference is held until replaced or the window is - * destroyed. Real file-vs-folder handling lands in M2. */ -void ggaze_window_open(GgazeWindow *p_win, GFile *p_file); +/* Open p_arg (a file or a folder): for a file, list its parent folder with + * p_arg current; for a folder, list it with the first image current. Loads the + * current image into the viewer and switches the stack to "large". */ +void ggaze_window_open(GgazeWindow *p_win, GFile *p_arg); + +/* 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); +void ggaze_window_next(GgazeWindow *p_win); +void ggaze_window_first(GgazeWindow *p_win); +void ggaze_window_last(GgazeWindow *p_win); G_END_DECLS diff --git a/tests/meson.build b/tests/meson.build index 76d4962..bd45863 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -14,8 +14,10 @@ # GGAZE_FIXTURES_DIR committed tests/fixtures/ (CI-portable baseline) # GGAZE_SAMPLE_DIR local ./sample-images (not git-tracked; tests skip # cleanly when absent so CI stays green) +# GIO_USE_VFS=local avoid gvfs daemon-VFS internal leaks under ASan fixtures_env = environment() +fixtures_env.set('GIO_USE_VFS', 'local') fixtures_env.set('GGAZE_FIXTURES_DIR', join_paths(meson.project_source_root(), 'tests', 'fixtures')) fixtures_env.set('GGAZE_SAMPLE_DIR', @@ -65,6 +67,16 @@ test_loader_pixbuf = executable( ) test('loader_pixbuf', test_loader_pixbuf, suite : 'unit', env : fixtures_env) +test_navigator = executable( + 'test_navigator', + ['test_navigator.c', ggaze_conf_h], + include_directories : [inc, src_inc], + dependencies : [glib_dep, gio_dep], + link_with : ggaze_lib, + install : false, +) +test('navigator', test_navigator, suite : 'unit', env : fixtures_env) + # --- integration track (needs a display; CI uses xvfb-run) --- test_window = executable( 'test_window', @@ -74,7 +86,7 @@ test_window = executable( link_with : ggaze_lib, install : false, ) -test('window', test_window, suite : 'integration') +test('window', test_window, suite : 'integration', env : fixtures_env) test_open_and_show = executable( 'test_open_and_show', @@ -88,4 +100,14 @@ test('open_and_show', test_open_and_show, suite : 'integration', env : fixtures_env) +test_walk_folder = executable( + 'test_walk_folder', + ['test_walk_folder.c', ggaze_conf_h], + include_directories : [inc, src_inc], + dependencies : ggaze_deps, + link_with : ggaze_lib, + install : false, +) +test('walk_folder', test_walk_folder, suite : 'integration', env : fixtures_env) + subdir('integration') \ No newline at end of file diff --git a/tests/test_navigator.c b/tests/test_navigator.c new file mode 100644 index 0000000..e8528b1 --- /dev/null +++ b/tests/test_navigator.c @@ -0,0 +1,483 @@ +/*:* + * ggaze — navigator unit test + * + * Exercises the Navigator over temp directories (no display, no GTK widgets): + * listing + image filter, RAW-sidecar hiding, sort (name/time/size), cursor + + * prev/next/wrap, marks (toggle/range/all/clear/survive-resort), rescan + + * nearest-fallback, navigator_remove, the GFileMonitor debounce path, the + * "changed" signal, and empty-dir accessors. + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include "navigator.h" + +#include +#include +#include + +/* --- helpers ------------------------------------------------------------- */ +/* write_file/touch_file create a file on disk and return nothing (no GFile to + * leak). file_ref() returns a fresh GFile handle for tests that need to keep a + * reference (to pass to the navigator, delete, or set attributes). */ + +static char * +make_temp_dir(void) { + GError *p_err = NULL; + char *c_dir = g_dir_make_tmp("ggaze-nav-XXXXXX", &p_err); + g_assert_no_error(p_err); + return (c_dir); +} + +/* Recursively remove a flat temp dir tree, then free the path string. */ +static void +cleanup_temp_dir(char *c_dir) { + GFile *p_dir = g_file_new_for_path(c_dir); + GFileEnumerator *p_e = + g_file_enumerate_children(p_dir, "standard::name,standard::type", + G_FILE_QUERY_INFO_NONE, NULL, NULL); + if (p_e != NULL) { + GFileInfo *p_info; + while ((p_info = g_file_enumerator_next_file(p_e, NULL, NULL)) != NULL) { + GFile *p_child = g_file_get_child(p_dir, g_file_info_get_name(p_info)); + g_file_delete(p_child, NULL, NULL); + g_object_unref(p_child); + g_object_unref(p_info); + } + g_object_unref(p_e); + } + g_file_delete(p_dir, NULL, NULL); + g_object_unref(p_dir); + g_free(c_dir); +} + +static void +write_file(const char *c_dir, const char *c_name, const char *c_body, + gssize i_len) { + char *c_path = g_build_filename(c_dir, c_name, NULL); + GFile *p_file = g_file_new_for_path(c_path); + GError *p_err = NULL; + GOutputStream *p_out = (GOutputStream *)g_file_replace( + p_file, NULL, FALSE, G_FILE_CREATE_REPLACE_DESTINATION, NULL, &p_err); + g_assert_no_error(p_err); + if (i_len < 0) { + i_len = (gssize)strlen(c_body); + } + gsize u_written = 0; + g_assert_true(g_output_stream_write_all(p_out, c_body, (gsize)i_len, + &u_written, NULL, &p_err)); + g_assert_no_error(p_err); + g_assert_true(g_output_stream_close(p_out, NULL, &p_err)); + g_assert_no_error(p_err); + g_object_unref(p_out); + g_object_unref(p_file); + g_free(c_path); +} + +static void +touch_file(const char *c_dir, const char *c_name) { + write_file(c_dir, c_name, "x", 1); +} + +static GFile * +file_ref(const char *c_dir, const char *c_name) { + 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); + return (p_file); /* (transfer full) */ +} + +/* Get the basename, assert it equals c_expected, and free it (LSan-clean). */ +static void +assert_name(GFile *p_file, const char *c_expected) { + char *c_name = g_file_get_basename(p_file); + g_assert_cmpstr(c_name, ==, c_expected); + g_free(c_name); +} + +/* pump the default main context for up to u_ms, returning TRUE if p_pred + * became TRUE. */ +static gboolean +pump_until(gboolean (*p_pred)(gpointer), gpointer p_data, guint u_ms) { + GMainContext *p_ctx = g_main_context_default(); + for (guint u = 0; u < u_ms; u++) { + if (p_pred != NULL && p_pred(p_data)) { + return (TRUE); + } + g_main_context_iteration(p_ctx, FALSE); + g_usleep(1000); /* 1 ms */ + } + return (p_pred != NULL && p_pred(p_data)); +} + +static gboolean +_count_is(gpointer p_data) { + return (navigator_get_count((Navigator *)p_data) == + GPOINTER_TO_UINT(g_object_get_data(G_OBJECT(p_data), "want"))); +} + +/* --- tests --------------------------------------------------------------- */ + +static void +test_filter_and_listing(void) { + char *c_dir = make_temp_dir(); + write_file(c_dir, "a.jpg", "xx", 2); + write_file(c_dir, "b.png", "yyy", 3); + write_file(c_dir, "c.txt", "z", 1); /* excluded: not an image */ + write_file(c_dir, ".hidden.jpg", "h", 1); /* excluded: dotfile */ + write_file(c_dir, "d.gif", "yy", 2); + + GFile *p_dirf = g_file_new_for_path(c_dir); + Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE); + g_assert_cmpint(navigator_get_count(p_nav), ==, 3); /* a, b, d */ + assert_name(navigator_get_file(p_nav, 0), "a.jpg"); + assert_name(navigator_get_file(p_nav, 1), "b.png"); + assert_name(navigator_get_file(p_nav, 2), "d.gif"); + + navigator_delete(p_nav); + g_object_unref(p_dirf); + cleanup_temp_dir(c_dir); +} + +static void +test_hide_raw_sidecars(void) { + char *c_dir = make_temp_dir(); + write_file(c_dir, "img.jpg", "x", 1); + write_file(c_dir, "img.raf", "x", 1); /* RAW twin of img.jpg */ + write_file(c_dir, "lonely.nef", "x", 1); /* no JPEG twin -> kept */ + + GFile *p_dirf = g_file_new_for_path(c_dir); + Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE); + g_assert_cmpint(navigator_get_count(p_nav), ==, 2); /* img.jpg, lonely.nef */ + g_assert_true(navigator_get_hide_raw(p_nav)); + + navigator_set_hide_raw(p_nav, FALSE); + g_assert_cmpint(navigator_get_count(p_nav), ==, 3); /* all three */ + + navigator_delete(p_nav); + g_object_unref(p_dirf); + cleanup_temp_dir(c_dir); +} + +static void +test_sort_time_and_size(void) { + char *c_dir = make_temp_dir(); + write_file(c_dir, "a.jpg", "12345", 5); + write_file(c_dir, "b.jpg", "0123456789ABCDEF", 16); + GFile *p_small = file_ref(c_dir, "a.jpg"); + GFile *p_big = file_ref(c_dir, "b.jpg"); + + GFile *p_dirf = g_file_new_for_path(c_dir); + Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE); + g_assert_cmpint(navigator_get_sort(p_nav), ==, GGAZE_SORT_NAME); + assert_name(navigator_get_current(p_nav), "a.jpg"); + + navigator_set_sort(p_nav, GGAZE_SORT_SIZE); + assert_name(navigator_get_file(p_nav, 0), "a.jpg"); /* size 5 < 16 */ + + /* Give a.jpg a newer mtime than b.jpg via a direct attribute set. */ + guint64 u_t_old = (guint64)1000000; + guint64 u_t_new = (guint64)9000000; + GError *p_seterr = NULL; + g_assert_true(g_file_set_attribute(p_big, "time::modified", + G_FILE_ATTRIBUTE_TYPE_UINT64, &u_t_old, + G_FILE_QUERY_INFO_NONE, NULL, &p_seterr)); + g_assert_no_error(p_seterr); + g_assert_true(g_file_set_attribute(p_small, "time::modified", + G_FILE_ATTRIBUTE_TYPE_UINT64, &u_t_new, + G_FILE_QUERY_INFO_NONE, NULL, &p_seterr)); + g_assert_no_error(p_seterr); + + navigator_set_sort(p_nav, GGAZE_SORT_TIME); + /* ascending: b.jpg (older) first, a.jpg (newer) last. */ + assert_name(navigator_get_file(p_nav, 0), "b.jpg"); + assert_name(navigator_get_file(p_nav, navigator_get_count(p_nav) - 1), + "a.jpg"); + + navigator_delete(p_nav); + g_object_unref(p_small); + g_object_unref(p_big); + g_object_unref(p_dirf); + cleanup_temp_dir(c_dir); +} + +static void +test_prev_next_wrap(void) { + char *c_dir = make_temp_dir(); + write_file(c_dir, "a.jpg", "x", 1); + write_file(c_dir, "b.jpg", "x", 1); + write_file(c_dir, "c.jpg", "x", 1); + + GFile *p_dirf = g_file_new_for_path(c_dir); + Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE); + g_assert_cmpint(navigator_get_current_index(p_nav), ==, 0); + + g_assert_true(navigator_next(p_nav)); + g_assert_cmpint(navigator_get_current_index(p_nav), ==, 1); + g_assert_true(navigator_next(p_nav)); + g_assert_cmpint(navigator_get_current_index(p_nav), ==, 2); + g_assert_true(navigator_next(p_nav)); /* wrap to 0 */ + g_assert_cmpint(navigator_get_current_index(p_nav), ==, 0); + g_assert_true(navigator_prev(p_nav)); /* wrap to last */ + g_assert_cmpint(navigator_get_current_index(p_nav), ==, 2); + + navigator_set_wrap(p_nav, FALSE); + g_assert_cmpint(navigator_get_wrap(p_nav), ==, FALSE); + navigator_last(p_nav); + g_assert_cmpint(navigator_get_current_index(p_nav), ==, 2); + g_assert_false(navigator_next(p_nav)); /* no wrap */ + navigator_first(p_nav); + g_assert_cmpint(navigator_get_current_index(p_nav), ==, 0); + g_assert_false(navigator_prev(p_nav)); /* no wrap */ + + navigator_delete(p_nav); + g_object_unref(p_dirf); + cleanup_temp_dir(c_dir); +} + +static void +test_set_current_file(void) { + char *c_dir = make_temp_dir(); + write_file(c_dir, "a.jpg", "x", 1); + write_file(c_dir, "b.jpg", "x", 1); + GFile *p_b = file_ref(c_dir, "b.jpg"); + + GFile *p_dirf = g_file_new_for_path(c_dir); + Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE); + g_assert_cmpint(navigator_get_current_index(p_nav), ==, 0); /* a.jpg */ + + g_assert_true(navigator_set_current_file(p_nav, p_b)); /* b.jpg at idx 1 */ + g_assert_cmpint(navigator_get_current_index(p_nav), ==, 1); + assert_name(navigator_get_current(p_nav), "b.jpg"); + + navigator_delete(p_nav); + g_object_unref(p_b); + g_object_unref(p_dirf); + cleanup_temp_dir(c_dir); +} + +static void +test_marks(void) { + char *c_dir = make_temp_dir(); + write_file(c_dir, "a.jpg", "x", 1); + write_file(c_dir, "b.jpg", "x", 1); + write_file(c_dir, "c.jpg", "x", 1); + GFile *p_a = file_ref(c_dir, "a.jpg"); + GFile *p_b = file_ref(c_dir, "b.jpg"); + GFile *p_c = file_ref(c_dir, "c.jpg"); + + GFile *p_dirf = g_file_new_for_path(c_dir); + Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE); + + g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 0); + navigator_toggle_mark(p_nav, p_b); + g_assert_true(navigator_is_marked(p_nav, p_b)); + g_assert_false(navigator_is_marked(p_nav, p_a)); + g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 1); + navigator_toggle_mark(p_nav, p_b); /* untoggle */ + g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 0); + + navigator_mark_range(p_nav, p_a, p_c); /* mark a, b, c */ + g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 3); + + /* marks survive a re-sort */ + navigator_set_sort(p_nav, GGAZE_SORT_SIZE); + g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 3); + g_assert_true(navigator_is_marked(p_nav, p_a)); + + GList *p_marks = navigator_get_marks(p_nav); + g_assert_cmpint(g_list_length(p_marks), ==, 3); + g_list_free_full(p_marks, (GDestroyNotify)g_object_unref); + + navigator_clear_marks(p_nav); + g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 0); + + navigator_mark_all(p_nav); + g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 3); + + navigator_delete(p_nav); + g_object_unref(p_a); + g_object_unref(p_b); + g_object_unref(p_c); + g_object_unref(p_dirf); + cleanup_temp_dir(c_dir); +} + +static void +test_rescan_and_nearest_fallback(void) { + char *c_dir = make_temp_dir(); + write_file(c_dir, "a.jpg", "x", 1); + write_file(c_dir, "b.jpg", "x", 1); + write_file(c_dir, "c.jpg", "x", 1); + GFile *p_b = file_ref(c_dir, "b.jpg"); + + GFile *p_dirf = g_file_new_for_path(c_dir); + Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE); + navigator_set_current_file(p_nav, p_b); /* current = b.jpg (idx 1) */ + g_assert_cmpint(navigator_get_current_index(p_nav), ==, 1); + + /* Delete b.jpg from disk and rescan: current gone -> nearest fallback. */ + g_assert_true(g_file_delete(p_b, NULL, NULL)); + navigator_rescan(p_nav); + g_assert_cmpint(navigator_get_count(p_nav), ==, 2); + /* nearest by position: old idx 1 -> stays at idx 1 (now c.jpg). */ + assert_name(navigator_get_current(p_nav), "c.jpg"); + + /* Adding a file via rescan keeps the current. */ + touch_file(c_dir, "d.jpg"); + navigator_rescan(p_nav); + g_assert_cmpint(navigator_get_count(p_nav), ==, 3); + assert_name(navigator_get_current(p_nav), "c.jpg"); + + navigator_delete(p_nav); + g_object_unref(p_b); + g_object_unref(p_dirf); + cleanup_temp_dir(c_dir); +} + +static void +test_remove_clears_mark_and_falls_back(void) { + char *c_dir = make_temp_dir(); + write_file(c_dir, "a.jpg", "x", 1); + write_file(c_dir, "b.jpg", "x", 1); + write_file(c_dir, "c.jpg", "x", 1); + GFile *p_b = file_ref(c_dir, "b.jpg"); + + GFile *p_dirf = g_file_new_for_path(c_dir); + Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE); + navigator_set_current_file(p_nav, p_b); + navigator_mark_all(p_nav); + g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 3); + + g_assert_true(navigator_remove(p_nav, p_b)); /* remove current */ + g_assert_cmpint(navigator_get_count(p_nav), ==, 2); + g_assert_cmpint(navigator_get_mark_count(p_nav), ==, + 2); /* b's mark cleared */ + /* nearest fallback: was idx 1 -> now c.jpg at idx 1. */ + assert_name(navigator_get_current(p_nav), "c.jpg"); + + g_assert_false(navigator_remove(p_nav, p_b)); /* already gone */ + + navigator_delete(p_nav); + g_object_unref(p_b); + g_object_unref(p_dirf); + cleanup_temp_dir(c_dir); +} + +static void +test_monitor_add(void) { + char *c_dir = make_temp_dir(); + write_file(c_dir, "a.jpg", "x", 1); + + GFile *p_dirf = g_file_new_for_path(c_dir); + Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE); + navigator_set_debounce_ms(p_nav, 5); /* fast for the test */ + g_assert_cmpint(navigator_get_count(p_nav), ==, 1); + + touch_file(c_dir, "b.jpg"); + g_object_set_data(G_OBJECT(p_nav), "want", GUINT_TO_POINTER(2)); + g_assert_true(pump_until(_count_is, p_nav, 2000)); + + navigator_delete(p_nav); + g_object_unref(p_dirf); + cleanup_temp_dir(c_dir); +} + +static void +on_changed_cb(Navigator *p_nav, gpointer p_data) { + (void)p_nav; + (*(gint *)p_data)++; +} + +static void +test_changed_signal(void) { + char *c_dir = make_temp_dir(); + touch_file(c_dir, "a.jpg"); + touch_file(c_dir, "b.jpg"); + GFile *p_dirf = g_fil