summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-12 21:13:45 +0300
committerPaul Buetow <paul@buetow.org>2026-07-12 21:13:45 +0300
commit89727590e718d3628a0847689182e896df646b8a (patch)
tree3106397d1b041d95ecfc04762548261f9d660d62
parent198b0e8112905bc003f954fc9159cc3da2dfce09 (diff)
walkdir: navigator, shortcuts, window wiring, drop target, tests (ht0)
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).
-rw-r--r--data/org.buetow.ggaze.gschema.xml6
-rw-r--r--meson.build2
-rw-r--r--src/loader/detect.h18
-rw-r--r--src/loader/loader.h3
-rw-r--r--src/navigator.c691
-rw-r--r--src/navigator.h99
-rw-r--r--src/shortcuts.c49
-rw-r--r--src/shortcuts.h26
-rw-r--r--src/viewer.h3
-rw-r--r--src/window.c299
-rw-r--r--src/window.h21
-rw-r--r--tests/meson.build24
-rw-r--r--tests/test_navigator.c483
-rw-r--r--tests/test_walk_folder.c196
-rw-r--r--tests/test_window.c18
15 files changed, 1886 insertions, 52 deletions
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 @@
<description>Hide ./Trash/deleted items from the grid entirely.</description>
</key>
+ <key name="hide-raw-sidecars" type="b">
+ <default>true</default>
+ <summary>Hide RAW sidecars</summary>
+ <description>Hide RAW files (.RAF/.CR3/.NEF/...) when a JPEG with the same stem exists (toggle to reveal).</description>
+ </key>
+
<key name="window-geometry" type="(iiib)">
<default>(800, 600, 0, false)</default>
<summary>Window geometry</summary>
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 <gio/gio.h>
+#include <glib-object.h>
+#include <glib.h>
+#include <string.h>
+
+/* 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 <gio/gio.h>
+#include <glib-object.h>
+#include <glib.h>
+
+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 <gtk/gtk.h>
+
+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 se