diff options
| author | Paul Buetow <paul@buetow.org> | 2026-07-12 21:13:45 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-07-12 21:13:45 +0300 |
| commit | 89727590e718d3628a0847689182e896df646b8a (patch) | |
| tree | 3106397d1b041d95ecfc04762548261f9d660d62 /src | |
| parent | 198b0e8112905bc003f954fc9159cc3da2dfce09 (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).
Diffstat (limited to 'src')
| -rw-r--r-- | src/loader/detect.h | 18 | ||||
| -rw-r--r-- | src/loader/loader.h | 3 | ||||
| -rw-r--r-- | src/navigator.c | 691 | ||||
| -rw-r--r-- | src/navigator.h | 99 | ||||
| -rw-r--r-- | src/shortcuts.c | 49 | ||||
| -rw-r--r-- | src/shortcuts.h | 26 | ||||
| -rw-r--r-- | src/viewer.h | 3 | ||||
| -rw-r--r-- | src/window.c | 299 | ||||
| -rw-r--r-- | src/window.h | 21 |
9 files changed, 1164 insertions, 45 deletions
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 set. + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include <gtk/gtk.h> + +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 <adwaita.h> #include <glib.h> +#include <gtk/gtk.h> -#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/folde |
