summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--meson.build3
-rw-r--r--src/gridview.c383
-rw-r--r--src/gridview.h49
-rw-r--r--src/navigator.c55
-rw-r--r--src/navigator.h9
-rw-r--r--src/shortcuts.c8
-rw-r--r--src/thumbnail.c241
-rw-r--r--src/thumbnail.h39
-rw-r--r--src/trash.c142
-rw-r--r--src/trash.h43
-rw-r--r--src/window.c231
-rw-r--r--tests/meson.build30
-rw-r--r--tests/test_grid_cull.c212
-rw-r--r--tests/test_texturecache.c5
-rw-r--r--tests/test_thumbnail.c178
-rw-r--r--tests/test_trash.c180
16 files changed, 1789 insertions, 19 deletions
diff --git a/meson.build b/meson.build
index f27b935..d77e081 100644
--- a/meson.build
+++ b/meson.build
@@ -60,6 +60,9 @@ ggaze_lib = static_library('ggaze',
'src/navigator.c',
'src/shortcuts.c',
'src/texturecache.c',
+ 'src/thumbnail.c',
+ 'src/trash.c',
+ 'src/gridview.c',
'src/loader/loader.c',
'src/loader/detect.c',
'src/loader/backends/pixbuf.c',
diff --git a/src/gridview.c b/src/gridview.c
new file mode 100644
index 0000000..c8336e8
--- /dev/null
+++ b/src/gridview.c
@@ -0,0 +1,383 @@
+/*:*
+ * ggaze — thumbnail grid view
+ *
+ * GgazeGrid wraps a GtkFlowBox (in a GtkScrolledWindow): one cell per
+ * navigator file, thumbnails loaded lazily (async, on realize) from the
+ * thumbnail cache. Removed (trashed/deleted) cells are dimmed; marked cells
+ * get a check badge. Resize (+/-) updates the cell size and reflows. Enter or
+ * double-click emits "activate". Cursor follows navigator.current.
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "gridview.h"
+
+#include <glib.h>
+
+/* Per-cell data, attached to the GtkPicture via qdata. */
+typedef struct {
+ GFile *p_file; /* owned ref */
+ GFile *p_expected; /* the file this thumbnail request is for (owned) */
+} CellData;
+
+static const char *CELL_DATA_KEY = "ggaze-cell-data";
+
+struct _GgazeGrid {
+ GtkWidget parent_instance;
+ Navigator *p_nav;
+ Thumbnail *p_thumb;
+ GtkWidget *p_flow; /* GtkFlowBox */
+ GtkWidget *p_scrolled; /* GtkScrolledWindow */
+ int i_size;
+ gboolean b_hide_trashed;
+ guint u_nav_handler;
+ GCancellable *p_cancel; /* cancels pending thumbnails on dispose */
+};
+
+G_DEFINE_TYPE(GgazeGrid, ggaze_grid, GTK_TYPE_WIDGET)
+
+static guint u_activate_signal = 0;
+
+static void
+_cell_data_free(gpointer p_void) {
+ CellData *p_d = (CellData *)p_void;
+ g_clear_object(&p_d->p_file);
+ g_clear_object(&p_d->p_expected);
+ g_free(p_d);
+}
+
+static CellData *
+_cell_data(GtkWidget *p_pic) {
+ return ((CellData *)g_object_get_data(G_OBJECT(p_pic), CELL_DATA_KEY));
+}
+
+/* --- thumbnail request --------------------------------------------------- */
+
+static void
+_thumb_finish_cb(GObject *p_src, GAsyncResult *p_res, gpointer p_data) {
+ (void)p_src;
+ GtkWidget *p_pic = (GtkWidget *)p_data;
+ CellData *p_d = _cell_data(p_pic);
+ if (p_d == NULL) {
+ g_object_unref(p_pic);
+ return;
+ }
+ GError *p_err = NULL;
+ GdkTexture *p_tex = thumbnail_get_finish(NULL, p_res, &p_err);
+ if (p_tex != NULL) {
+ /* Only apply if this request is still for this cell's file. */
+ if (p_d->p_expected != NULL &&
+ g_file_equal(p_d->p_expected, p_d->p_file)) {
+ gtk_picture_set_paintable(GTK_PICTURE(p_pic), (GdkPaintable *)p_tex);
+ }
+ g_object_unref(p_tex);
+ } else {
+ g_clear_error(&p_err);
+ }
+ g_clear_object(&p_d->p_expected);
+ g_object_unref(p_pic);
+}
+
+static void
+_request_thumbnail(GtkWidget *p_pic) {
+ CellData *p_d = _cell_data(p_pic);
+ if (p_d == NULL || p_d->p_file == NULL) {
+ return;
+ }
+ if (p_d->p_expected != NULL) {
+ return; /* already in flight */
+ }
+ p_d->p_expected = (GFile *)g_object_ref(p_d->p_file);
+ GgazeGrid *p_grid =
+ GGAZE_GRID(gtk_widget_get_ancestor(p_pic, GGAZE_TYPE_GRID));
+ if (p_grid == NULL || p_grid->p_thumb == NULL) {
+ g_clear_object(&p_d->p_expected);
+ return;
+ }
+ thumbnail_get_async(p_grid->p_thumb, p_d->p_file, p_grid->i_size,
+ p_grid->p_cancel, _thumb_finish_cb, g_object_ref(p_pic));
+}
+
+static void
+_on_pic_map(GtkWidget *p_pic, gpointer p_data) {
+ (void)p_data;
+ _request_thumbnail(p_pic);
+}
+
+/* --- cell construction --------------------------------------------------- */
+
+static GtkWidget *
+_make_picture(GgazeGrid *p_grid, GFile *p_file) {
+ GtkWidget *p_pic = gtk_picture_new();
+ gtk_widget_set_size_request(p_pic, p_grid->i_size, p_grid->i_size);
+ gtk_picture_set_content_fit(GTK_PICTURE(p_pic), GTK_CONTENT_FIT_CONTAIN);
+ CellData *p_d = g_new(CellData, 1);
+ p_d->p_file = (GFile *)g_object_ref(p_file);
+ p_d->p_expected = NULL;
+ g_object_set_data_full(G_OBJECT(p_pic), CELL_DATA_KEY, p_d, _cell_data_free);
+ g_signal_connect(p_pic, "map", G_CALLBACK(_on_pic_map), NULL);
+ return (p_pic);
+}
+
+static GtkWidget *
+_make_cell(GgazeGrid *p_grid, GFile *p_file) {
+ GtkWidget *p_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
+ gtk_widget_set_size_request(p_box, p_grid->i_size + 4, p_grid->i_size + 18);
+ GtkWidget *p_pic = _make_picture(p_grid, p_file);
+ gtk_box_append(GTK_BOX(p_box), p_pic);
+ char *c_name = g_file_get_basename(p_file);
+ GtkWidget *p_label = gtk_label_new(c_name);
+ g_free(c_name);
+ gtk_label_set_max_width_chars(GTK_LABEL(p_label), 20);
+ gtk_label_set_ellipsize(GTK_LABEL(p_label), PANGO_ELLIPSIZE_END);
+ gtk_widget_add_css_class(p_label, "caption");
+ gtk_box_append(GTK_BOX(p_box), p_label);
+
+ /* mark badge */
+ if (navigator_is_marked(p_grid->p_nav, p_file)) {
+ gtk_widget_add_css_class(p_box, "ggaze-marked");
+ }
+ /* dim removed */
+ if (navigator_is_removed(p_grid->p_nav, p_file)) {
+ gtk_widget_set_opacity(p_box, 0.35);
+ gtk_widget_add_css_class(p_box, "ggaze-removed");
+ }
+
+ GtkWidget *p_child = gtk_flow_box_child_new();
+ gtk_flow_box_child_set_child(GTK_FLOW_BOX_CHILD(p_child), p_box);
+ g_object_set_data_full(G_OBJECT(p_child), "file", g_object_ref(p_file),
+ (GDestroyNotify)g_object_unref);
+ return (p_child);
+}
+
+static void
+_on_child_activated(GtkFlowBox *p_flow, GtkFlowBoxChild *p_child,
+ gpointer p_data) {
+ (void)p_flow;
+ GgazeGrid *p_grid = GGAZE_GRID(p_data);
+ GFile *p_file = (GFile *)g_object_get_data(G_OBJECT(p_child), "file");
+ if (p_file != NULL) {
+ navigator_set_current_file(p_grid->p_nav, p_file);
+ }
+ g_signal_emit(p_grid, u_activate_signal, 0);
+}
+
+static gboolean
+_on_flow_key(GtkEventControllerKey *p_key, guint u_kv, guint u_kc,
+ GdkModifierType e_st, gpointer p_data) {
+ (void)p_key;
+ (void)u_kc;
+ (void)e_st;
+ GgazeGrid *p_grid = GGAZE_GRID(p_data);
+ if (u_kv == GDK_KEY_Return || u_kv == GDK_KEY_KP_Enter) {
+ g_signal_emit(p_grid, u_activate_signal, 0);
+ return (TRUE);
+ }
+ if (u_kv == GDK_KEY_d) {
+ /* `d` in the grid trashes the current file (window handles via action).
+ */
+ return (FALSE);
+ }
+ return (FALSE);
+}
+
+/* --- refresh / rebuild --------------------------------------------------- */
+
+static void
+_clear_flow(GgazeGrid *p_grid) {
+ GtkWidget *p_child = gtk_widget_get_first_child(p_grid->p_flow);
+ while (p_child != NULL) {
+ GtkWidget *p_next = gtk_widget_get_next_sibling(p_child);
+ gtk_flow_box_remove(GTK_FLOW_BOX(p_grid->p_flow), p_child);
+ p_child = p_next;
+ }
+}
+
+static void
+_select_current(GgazeGrid *p_grid) {
+ GFile *p_cur = navigator_get_current(p_grid->p_nav);
+ if (p_cur == NULL) {
+ return;
+ }
+ GtkWidget *p_child = gtk_widget_get_first_child(p_grid->p_flow);
+ while (p_child != NULL) {
+ GFile *p_f = (GFile *)g_object_get_data(G_OBJECT(p_child), "file");
+ if (p_f != NULL && g_file_equal(p_f, p_cur)) {
+ GtkFlowBoxChild *p_fc = GTK_FLOW_BOX_CHILD(p_child);
+ gtk_flow_box_select_child(GTK_FLOW_BOX(p_grid->p_flow), p_fc);
+ gtk_widget_grab_focus(p_child);
+ return;
+ }
+ p_child = gtk_widget_get_next_sibling(p_child);
+ }
+}
+
+void
+ggaze_grid_refresh(GgazeGrid *p_grid) {
+ g_return_if_fail(GGAZE_IS_GRID(p_grid));
+ _clear_flow(p_grid);
+
+ guint u_n = navigator_get_count(p_grid->p_nav);
+ for (guint u_i = 0; u_i < u_n; u_i++) {
+ GFile *p_file = navigator_get_file(p_grid->p_nav, u_i);
+ if (p_file == NULL) {
+ continue;
+ }
+ if (p_grid->b_hide_trashed &&
+ navigator_is_removed(p_grid->p_nav, p_file)) {
+ continue;
+ }
+ GtkWidget *p_child = _make_cell(p_grid, p_file);
+ gtk_flow_box_append(GTK_FLOW_BOX(p_grid->p_flow), p_child);
+ }
+ _select_current(p_grid);
+}
+
+static void
+_on_nav_changed(Navigator *p_nav, gpointer p_data) {
+ (void)p_nav;
+ ggaze_grid_refresh(GGAZE_GRID(p_data));
+}
+
+/* --- GObject ------------------------------------------------------------- */
+
+static void
+ggaze_grid_dispose(GObject *p_obj) {
+ GgazeGrid *p_grid = GGAZE_GRID(p_obj);
+ if (p_grid->p_cancel != NULL) {
+ g_cancellable_cancel(p_grid->p_cancel);
+ g_clear_object(&p_grid->p_cancel);
+ }
+ if (p_grid->p_nav != NULL && p_grid->u_nav_handler != 0) {
+ g_signal_handler_disconnect(p_grid->p_nav, p_grid->u_nav_handler);
+ p_grid->u_nav_handler = 0;
+ }
+ p_grid->p_nav = NULL; /* detached; don't touch it after this */
+ g_clear_pointer(&p_grid->p_scrolled, gtk_widget_unparent);
+ G_OBJECT_CLASS(ggaze_grid_parent_class)->dispose(p_obj);
+}
+
+static void
+ggaze_grid_finalize(GObject *p_obj) {
+ GgazeGrid *p_grid = GGAZE_GRID(p_obj);
+ /* p_nav/p_thumb are borrowed (owned by the window). */
+ (void)p_grid;
+ G_OBJECT_CLASS(ggaze_grid_parent_class)->finalize(p_obj);
+}
+
+static void
+ggaze_grid_class_init(GgazeGridClass *p_klass) {
+ GObjectClass *p_oc = G_OBJECT_CLASS(p_klass);
+ p_oc->dispose = ggaze_grid_dispose;
+ p_oc->finalize = ggaze_grid_finalize;
+ u_activate_signal =
+ g_signal_new("activate", G_TYPE_FROM_CLASS(p_klass), G_SIGNAL_RUN_LAST, 0,
+ NULL, NULL, g_cclosure_marshal_generic, G_TYPE_NONE, 0);
+}
+
+static void
+ggaze_grid_init(GgazeGrid *p_grid) {
+ p_grid->i_size = 128;
+ p_grid->b_hide_trashed = FALSE;
+ p_grid->p_scrolled = gtk_scrolled_window_new();
+ gtk_widget_set_parent(p_grid->p_scrolled, GTK_WIDGET(p_grid));
+ gtk_widget_set_hexpand(p_grid->p_scrolled, TRUE);
+ gtk_widget_set_vexpand(p_grid->p_scrolled, TRUE);
+ p_grid->p_flow = gtk_flow_box_new();
+ gtk_flow_box_set_homogeneous(GTK_FLOW_BOX(p_grid->p_flow), TRUE);
+ gtk_flow_box_set_activate_on_single_click(GTK_FLOW_BOX(p_grid->p_flow),
+ FALSE);
+ g_signal_connect(p_grid->p_flow, "child-activated",
+ G_CALLBACK(_on_child_activated), p_grid);
+ GtkEventController *p_key = gtk_event_controller_key_new();
+ g_signal_connect(p_key, "key-pressed", G_CALLBACK(_on_flow_key), p_grid);
+ gtk_widget_add_controller(p_grid->p_flow, p_key);
+ gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(p_grid->p_scrolled),
+ p_grid->p_flow);
+}
+
+/* --- public ------------------------------------------------------------- */
+
+GtkWidget *
+ggaze_grid_new(Navigator *p_nav, Thumbnail *p_thumb, int i_size,
+ gboolean b_hide_trashed) {
+ g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), NULL);
+ GgazeGrid *p_grid = GGAZE_GRID(g_object_new(GGAZE_TYPE_GRID, NULL));
+ p_grid->p_nav = p_nav;
+ p_grid->p_thumb = p_thumb;
+ p_grid->i_size = (i_size <= 0) ? 128 : i_size;
+ p_grid->b_hide_trashed = b_hide_trashed;
+ p_grid->u_nav_handler =
+ g_signal_connect(p_nav, "changed", G_CALLBACK(_on_nav_changed), p_grid);
+ ggaze_grid_refresh(p_grid);
+ return (GTK_WIDGET(p_grid));
+}
+
+void
+ggaze_grid_set_thumbnail_size(GgazeGrid *p_grid, int i_size) {
+ g_return_if_fail(GGAZE_IS_GRID(p_grid));
+ if (i_size <= 0) {
+ i_size = 128;
+ }
+ if (p_grid->i_size == i_size) {
+ return;
+ }
+ p_grid->i_size = i_size;
+ /* Update the size request of every cell's picture and reflow. */
+ GtkWidget *p_child = gtk_widget_get_first_child(p_grid->p_flow);
+ while (p_child != NULL) {
+ GtkWidget *p_box =
+ gtk_flow_box_child_get_child(GTK_FLOW_BOX_CHILD(p_child));
+ if (p_box != NULL) {
+ GtkWidget *p_pic = gtk_widget_get_first_child(p_box);
+ if (p_pic != NULL) {
+ gtk_widget_set_size_request(p_pic, i_size, i_size);
+ }
+ gtk_widget_set_size_request(p_box, i_size + 4, i_size + 18);
+ }
+ p_child = gtk_widget_get_next_sibling(p_child);
+ }
+ gtk_widget_queue_resize(p_grid->p_flow);
+}
+
+void
+ggaze_grid_set_hide_trashed(GgazeGrid *p_grid, gboolean b_hide) {
+ g_return_if_fail(GGAZE_IS_GRID(p_grid));
+ if (p_grid->b_hide_trashed == b_hide) {
+ return;
+ }
+ p_grid->b_hide_trashed = b_hide;
+ ggaze_grid_refresh(p_grid);
+}
+
+void
+ggaze_grid_detach(GgazeGrid *p_grid) {
+ g_return_if_fail(GGAZE_IS_GRID(p_grid));
+ if (p_grid->p_cancel != NULL) {
+ g_cancellable_cancel(p_grid->p_cancel);
+ g_clear_object(&p_grid->p_cancel);
+ }
+ if (p_grid->p_nav != NULL && p_grid->u_nav_handler != 0) {
+ g_signal_handler_disconnect(p_grid->p_nav, p_grid->u_nav_handler);
+ p_grid->u_nav_handler = 0;
+ }
+ p_grid->p_nav = NULL;
+}
+
+guint
+ggaze_grid_get_count(GgazeGrid *p_grid) {
+ g_return_val_if_fail(GGAZE_IS_GRID(p_grid), 0);
+ guint u_count = 0;
+ GtkWidget *p_child = gtk_widget_get_first_child(p_grid->p_flow);
+ while (p_child != NULL) {
+ u_count++;
+ p_child = gtk_widget_get_next_sibling(p_child);
+ }
+ return (u_count);
+}
+
+guint
+ggaze_grid_activate_signal(void) {
+ return (u_activate_signal);
+} \ No newline at end of file
diff --git a/src/gridview.h b/src/gridview.h
new file mode 100644
index 0000000..582ada8
--- /dev/null
+++ b/src/gridview.h
@@ -0,0 +1,49 @@
+#ifndef GGAZE_GRIDVIEW_H
+#define GGAZE_GRIDVIEW_H
+
+/*:*
+ * ggaze — thumbnail grid view
+ *
+ * GgazeGrid : GtkWidget is the folder-overview grid: one cell per navigator
+ * file, thumbnails decoded lazily (async, on realize) from the thumbnail cache,
+ * resizable (+/-), trashed/deleted cells dimmed, marked cells badged,
+ * Enter/double-click emits "activate" (grid->large). Cursor follows the
+ * navigator. See docs/architecture.md "gridview" + docs/ui-and-interactions.md
+ * "Grid view behavior".
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "navigator.h"
+#include "thumbnail.h"
+
+#include <gtk/gtk.h>
+
+G_BEGIN_DECLS
+
+#define GGAZE_TYPE_GRID (ggaze_grid_get_type())
+G_DECLARE_FINAL_TYPE(GgazeGrid, ggaze_grid, GGAZE, GRID, GtkWidget)
+
+GtkWidget *ggaze_grid_new(Navigator *p_nav, Thumbnail *p_thumb, int i_size,
+ gboolean b_hide_trashed);
+
+void ggaze_grid_set_thumbnail_size(GgazeGrid *p_grid, int i_size);
+void ggaze_grid_set_hide_trashed(GgazeGrid *p_grid, gboolean b_hide);
+
+/* Rebuild the cells from the navigator (call after structural changes). */
+void ggaze_grid_refresh(GgazeGrid *p_grid);
+
+/* Number of cells currently in the grid. */
+guint ggaze_grid_get_count(GgazeGrid *p_grid);
+
+/* Disconnect from the navigator (call before the navigator is freed). */
+void ggaze_grid_detach(GgazeGrid *p_grid);
+
+/* "activate": emitted when the user presses Enter or double-clicks a cell (the
+ * window switches to the large view on the current file). */
+guint ggaze_grid_activate_signal(void);
+
+G_END_DECLS
+
+#endif /* GGAZE_GRIDVIEW_H */ \ No newline at end of file
diff --git a/src/navigator.c b/src/navigator.c
index cb47575..8752e2d 100644
--- a/src/navigator.c
+++ b/src/navigator.c
@@ -41,7 +41,8 @@ struct _Navigator {
GgazeSort e_sort;
gboolean b_wrap;
gboolean b_hide_raw;
- GHashTable *p_marks; /* GFile* (owned refs) -> presence */
+ GHashTable *p_marks; /* GFile* (owned refs) -> presence */
+ GHashTable *p_removed; /* GFile* (owned refs): trashed/deleted, dimmed */
GFileMonitor *p_monitor;
guint u_debounce_ms;
guint u_debounce_id; /* 0 = none pending */
@@ -277,6 +278,23 @@ _relist(Navigator *p_nav) {
}
}
+ /* Preserve removed (dimmed) items: keep them in the listing even if absent
+ * from disk (trashed/deleted this session); un-remove any that reappeared.
+ */
+ {
+ GHashTableIter riter;
+ gpointer rkey;
+ g_hash_table_iter_init(&riter, p_nav->p_removed);
+ while (g_hash_table_iter_next(&riter, &rkey, NULL)) {
+ GFile *p_rf = (GFile *)rkey;
+ if (_find_index_by_file(p_nav, p_rf) < 0) {
+ g_ptr_array_add(p_nav->p_files, g_object_ref(p_rf));
+ } else {
+ g_hash_table_iter_remove(&riter);
+ }
+ }
+ }
+
g_ptr_array_unref(p_entries);
}
@@ -338,6 +356,7 @@ navigator_dispose(GObject *p_obj) {
g_clear_object(&p_nav->p_monitor);
}
g_clear_pointer(&p_nav->p_marks, g_hash_table_unref);
+ g_clear_pointer(&p_nav->p_removed, 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);
@@ -359,6 +378,9 @@ navigator_init(Navigator *p_nav) {
p_nav->p_marks =
g_hash_table_new_full((GHashFunc)g_file_hash, (GEqualFunc)g_file_equal,
(GDestroyNotify)g_object_unref, NULL);
+ p_nav->p_removed =
+ 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;
@@ -440,7 +462,36 @@ navigator_get_current(Navigator *p_nav) {
guint
navigator_get_remaining(Navigator *p_nav) {
- return (navigator_get_count(p_nav));
+ g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), 0);
+ return (navigator_get_count(p_nav) - g_hash_table_size(p_nav->p_removed));
+}
+
+gboolean
+navigator_is_removed(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_removed, p_file));
+}
+
+void
+navigator_mark_removed(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_removed, p_file)) {
+ g_hash_table_add(p_nav->p_removed, g_object_ref(p_file));
+ _emit_changed(p_nav);
+ }
+}
+
+gboolean
+navigator_unmark_removed(Navigator *p_nav, GFile *p_file) {
+ g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), FALSE);
+ return (g_hash_table_remove(p_nav->p_removed, p_file));
+}
+
+guint
+navigator_get_removed_count(Navigator *p_nav) {
+ g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), 0);
+ return (g_hash_table_size(p_nav->p_removed));
}
gboolean
diff --git a/src/navigator.h b/src/navigator.h
index b672480..db781ab 100644
--- a/src/navigator.h
+++ b/src/navigator.h
@@ -50,7 +50,7 @@ GFile *navigator_get_file(Navigator *p_nav,
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 */
+navigator_get_remaining(Navigator *p_nav); /* remaining = count - removed */
gboolean navigator_set_current(Navigator *p_nav, guint u_index);
gboolean navigator_set_current_file(Navigator *p_nav,
GFile *p_file); /* by path */
@@ -91,6 +91,13 @@ gboolean navigator_remove(Navigator *p_nav, GFile *p_file);
*/
void navigator_set_debounce_ms(Navigator *p_nav, guint u_ms);
+/* --- removed/dimmed set (M7: trashed/deleted items stay listed but dimmed) --
+ */
+gboolean navigator_is_removed(Navigator *p_nav, GFile *p_file);
+void navigator_mark_removed(Navigator *p_nav, GFile *p_file);
+gboolean navigator_unmark_removed(Navigator *p_nav, GFile *p_file);
+guint navigator_get_removed_count(Navigator *p_nav);
+
/* "changed" signal: emitted on sort/filter/rescan/remove/monitor event. */
void navigator_emit_changed(Navigator *p_nav);
diff --git a/src/shortcuts.c b/src/shortcuts.c
index d946d30..6853490 100644
--- a/src/shortcuts.c
+++ b/src/shortcuts.c
@@ -29,6 +29,14 @@ static const ShortcutEntry SHORTCUTS[] = {
/* open / quit / back */
{GDK_KEY_o, 0, "win.open"},
{GDK_KEY_q, 0, "win.quit"},
+ {GDK_KEY_d, 0, "win.trash"},
+ {GDK_KEY_D, GDK_SHIFT_MASK, "win.delete"},
+ {GDK_KEY_u, 0, "win.undo"},
+ {GDK_KEY_t, 0, "win.toggle-view"},
+ {GDK_KEY_plus, 0, "win.zoom-in"},
+ {GDK_KEY_equal, 0, "win.zoom-in"},
+ {GDK_KEY_minus, 0, "win.zoom-out"},
+ {GDK_KEY_underscore, 0, "win.zoom-out"},
};
void
diff --git a/src/thumbnail.c b/src/thumbnail.c
new file mode 100644
index 0000000..67c1c89
--- /dev/null
+++ b/src/thumbnail.c
@@ -0,0 +1,241 @@
+/*:*
+ * ggaze — thumbnail cache
+ *
+ * freedesktop TMS: ~/.cache/thumbnails/{normal(128), large(256)} + a custom
+ * x-large bucket (512) for >256 (decision #37/T). Caches PNG keyed by md5(URI),
+ * stores Thumb::URI/MTime/Size, verifies mtime. Async decode via GTask.
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "thumbnail.h"
+
+#include <gdk-pixbuf/gdk-pixbuf.h>
+#include <gdk/gdk.h>
+#include <gio/gio.h>
+#include <glib.h>
+
+#define GGAZE_TMS_NORMAL 128
+#define GGAZE_TMS_LARGE 256
+#define GGAZE_TMS_XLARGE 512
+
+struct Thumbnail {
+ int i_unused;
+};
+
+typedef struct {
+ Thumbnail *p_t;
+ GFile *p_file; /* owned */
+ int i_size; /* requested size */
+ int i_bucket; /* bucket size actually cached */
+ char *c_cache_path; /* owned */
+} ThumbTask;
+
+/* --- helpers ------------------------------------------------------------- */
+
+static int
+_bucket_for(int i_size) {
+ if (i_size <= GGAZE_TMS_NORMAL) {
+ return (GGAZE_TMS_NORMAL);
+ }
+ if (i_size <= GGAZE_TMS_LARGE) {
+ return (GGAZE_TMS_LARGE);
+ }
+ return (GGAZE_TMS_XLARGE);
+}
+
+static char *
+_cache_dir_for_bucket(int i_bucket) {
+ const char *c_sub = (i_bucket <= GGAZE_TMS_NORMAL) ? "normal"
+ : (i_bucket <= GGAZE_TMS_LARGE) ? "large"
+ : "x-large";
+ const char *c_cache = g_get_user_cache_dir();
+ char *c_dir = g_build_filename(c_cache, "thumbnails", c_sub, NULL);
+ return (c_dir);
+}
+
+static char *
+_cache_path(Thumbnail *p_t, GFile *p_file, int i_bucket) {
+ (void)p_t;
+ char *c_uri = g_file_get_uri(p_file);
+ GChecksum *p_sum = g_checksum_new(G_CHECKSUM_MD5);
+ g_checksum_update(p_sum, (const guchar *)c_uri, strlen(c_uri));
+ const char *c_hex = g_checksum_get_string(p_sum);
+ char *c_dir = _cache_dir_for_bucket(i_bucket);
+ char *c_path = g_strdup_printf("%s/%s.png", c_dir, c_hex);
+ g_free(c_dir);
+ g_checksum_free(p_sum);
+ g_free(c_uri);
+ return (c_path);
+}
+
+static GdkTexture *
+_texture_from_pixbuf(GdkPixbuf *p_pix) {
+ g_return_val_if_fail(GDK_IS_PIXBUF(p_pix), NULL);
+ int i_w = gdk_pixbuf_get_width(p_pix);
+ int i_h = gdk_pixbuf_get_height(p_pix);
+ GdkPixbuf *p_rgba = gdk_pixbuf_get_has_alpha(p_pix)
+ ? GDK_PIXBUF(g_object_ref(p_pix))
+ : gdk_pixbuf_add_alpha(p_pix, FALSE, 0, 0, 0);
+ if (p_rgba == NULL) {
+ return (NULL);
+ }
+ int i_rowstride = gdk_pixbuf_get_rowstride(p_rgba);
+ guchar *p_pixels = gdk_pixbuf_get_pixels(p_rgba);
+ gsize u_len = (gsize)(i_h - 1) * (gsize)i_rowstride + (gsize)i_w * 4u;
+ GBytes *p_bytes = g_bytes_new_with_free_func(
+ p_pixels, u_len, (GDestroyNotify)g_object_unref, p_rgba);
+ GdkTexture *p_tex = gdk_memory_texture_new(i_w, i_h, GDK_MEMORY_R8G8B8A8,
+ p_bytes, (gsize)i_rowstride);
+ g_bytes_unref(p_bytes);
+ return (p_tex);
+}
+
+/* Load a cached PNG into a texture, verifying Thumb::MTime == i_mtime. */
+static GdkTexture *
+_load_cached(const char *c_path, gint64 i_mtime) {
+ GError *p_err = NULL;
+ GdkPixbuf *p_pix = gdk_pixbuf_new_from_file(c_path, &p_err);
+ if (p_pix == NULL) {
+ g_clear_error(&p_err);
+ return (NULL);
+ }
+ const char *c_m = gdk_pixbuf_get_option(p_pix, "Thumb::MTime");
+ if (c_m == NULL || g_ascii_strtoll(c_m, NULL, 10) != i_mtime) {
+ g_object_unref(p_pix);
+ return (NULL); /* stale or missing mtime */
+ }
+ GdkTexture *p_tex = _texture_from_pixbuf(p_pix);
+ g_object_unref(p_pix);
+ return (p_tex);
+}
+
+/* Decode the image at <= i_bucket px (preserving aspect), apply EXIF
+ * orientation, and write a TMS PNG to c_cache_path. Returns the texture. */
+static GdkTexture *
+_generate(GFile *p_file, int i_bucket, const char *c_cache_path, gint64 i_mtime,
+ gint64 i_size, GError **p_err) {
+ char *c_path = g_file_get_path(p_file);
+ if (c_path == NULL) {
+ g_set_error(p_err, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "cannot thumbnail a non-local file");
+ return (NULL);
+ }
+ GdkPixbuf *p_pix = gdk_pixbuf_new_from_file_at_scale(c_path, i_bucket,
+ i_bucket, TRUE, p_err);
+ g_free(c_path);
+ if (p_pix == NULL) {
+ return (NULL);
+ }
+ GdkPixbuf *p_oriented = gdk_pixbuf_apply_embedded_orientation(p_pix);
+ GdkPixbuf *p_use =
+ (p_oriented != NULL) ? p_oriented : GDK_PIXBUF(g_object_ref(p_pix));
+ g_object_unref(p_pix);
+
+ /* Best-effort write to the cache; failure to write is non-fatal. */
+ char c_mtime[32];
+ char c_size[32];
+ g_snprintf(c_mtime, sizeof(c_mtime), "%" G_GINT64_FORMAT, i_mtime);
+ g_snprintf(c_size, sizeof(c_size), "%" G_GINT64_FORMAT, i_size);
+ char *c_uri = g_file_get_uri(p_file);
+ GError *p_werr = NULL;
+ gdk_pixbuf_save(p_use, c_cache_path, "png", &p_werr, "tEXt::Thumb::URI",
+ c_uri, "tEXt::Thumb::MTime", c_mtime, "tEXt::Thumb::Size",
+ c_size, NULL);
+ if (p_werr != NULL) {
+ g_error_free(p_werr);
+ }
+ g_free(c_uri);
+
+ GdkTexture *p_tex = _texture_from_pixbuf(p_use);
+ g_object_unref(p_use);
+ return (p_tex);
+}
+
+/* --- GTask worker -------------------------------------------------------- */
+
+static void
+_thumb_task_free(gpointer p_void) {
+ ThumbTask *p_tt = (ThumbTask *)p_void;
+ g_clear_object(&p_tt->p_file);
+ g_free(p_tt->c_cache_path);
+ g_free(p_tt);
+}
+
+static void
+_thumb_task_thread(GTask *p_task, gpointer p_src, gpointer p_task_data,
+ GCancellable *p_cancel) {
+ (void)p_src;
+ (void)p_cancel;
+ ThumbTask *p_tt = (ThumbTask *)p_task_data;
+ GError *p_err = NULL;
+
+ /* File mtime + size (for verify + Thumb::Size). */
+ GFileInfo *p_info =
+ g_file_query_info(p_tt->p_file, "standard::size,time::modified",
+ G_FILE_QUERY_INFO_NONE, NULL, &p_err);
+ if (p_info == NULL) {
+ g_task_return_error(p_task, p_err);
+ return;
+ }
+ gint64 i_mtime = (gint64)g_file_info_get_attribute_uint64(
+ p_info, G_FILE_ATTRIBUTE_TIME_MODIFIED);
+ gint64 i_size = (gint64)g_file_info_get_size(p_info);
+ g_object_unref(p_info);
+
+ /* Ensure the cache dir exists (best-effort). */
+ char *c_dir = g_path_get_dirname(p_tt->c_cache_path);
+ g_mkdir_with_parents(c_dir, 0700);
+ g_free(c_dir);
+
+ GdkTexture *p_tex = _load_cached(p_tt->c_cache_path, i_mtime);
+ if (p_tex == NULL) {
+ p_tex = _generate(p_tt->p_file, p_tt->i_bucket, p_tt->c_cache_path,
+ i_mtime, i_size, &p_err);
+ }
+ if (p_tex == NULL) {
+ g_task_return_error(p_task, p_err);
+ } else {
+ g_task_return_pointer(p_task, p_tex, (GDestroyNotify)g_object_unref);
+ }
+}
+
+/* --- public ------------------------------------------------------------- */
+
+Thumbnail *
+thumbnail_new(void) {
+ Thumbnail *p_t = g_new(Thumbnail, 1);
+ return (p_t);
+}
+
+void
+thumbnail_delete(Thumbnail *p_t) {
+ g_free(p_t);
+}
+
+void
+thumbnail_get_async(Thumbnail *p_t, GFile *p_file, int i_size,
+ GCancellable *p_cancel, GAsyncReadyCallback p_cb,
+ gpointer p_data) {
+ g_return_if_fail(p_t != NULL);
+ g_return_if_fail(G_IS_FILE(p_file));
+ int i_bucket = _bucket_for(i_size);
+ ThumbTask *p_tt = g_new(ThumbTask, 1);
+ p_tt->p_t = p_t;
+ p_tt->p_file = (GFile *)g_object_ref(p_file);
+ p_tt->i_size = i_size;
+ p_tt->i_bucket = i_bucket;
+ p_tt->c_cache_path = _cache_path(p_t, p_file, i_bucket);
+ GTask *p_task = g_task_new(p_file, p_cancel, p_cb, p_data);
+ g_task_set_task_data(p_task, p_tt, _thumb_task_free);
+ g_task_run_in_thread(p_task, _thumb_task_thread);
+ g_object_unref(p_task);
+}
+
+GdkTexture *
+thumbnail_get_finish(Thumbnail *p_t, GAsyncResult *p_res, GError **p_err) {
+ (void)p_t;
+ g_return_val_if_fail(G_IS_TASK(p_res), NULL);
+ return ((GdkTexture *)g_task_propagate_pointer((GTask *)p_res, p_err));
+} \ No newline at end of file
diff --git a/src/thumbnail.h b/src/thumbnail.h
new file mode 100644
index 0000000..d8bab86
--- /dev/null
+++ b/src/thumbnail.h
@@ -0,0 +1,39 @@
+#ifndef GGAZE_THUMBNAIL_H
+#define GGAZE_THUMBNAIL_H
+
+/*:*
+ * ggaze — thumbnail cache
+ *
+ * freedesktop Thumbnail Managing Standard: ~/.cache/thumbnails/normal (128)
+ * and large (256), plus a custom x-large bucket for >256 (decision #37/T).
+ * Stores PNG with Thumb::URI/MTime/Size; verifies mtime before trust. Decodes
+ * asynchronously in a GTask worker; the GdkTexture is returned on the main
+ * thread. See docs/tech-stack.md "Thumbnail cache".
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include <gdk/gdk.h>
+#include <gio/gio.h>
+#include <glib.h>
+
+G_BEGIN_DECLS
+
+typedef struct Thumbnail Thumbnail;
+
+Thumbnail *thumbnail_new(void);
+void thumbnail_delete(Thumbnail *p_t);
+
+/* Asynchronously get a thumbnail for p_file at ~i_size px (the nearest TMS
+ * bucket >= i_size is cached). Returns the GdkTexture via p_cb on the main
+ * thread (transfer full in thumbnail_get_finish). */
+void thumbnail_get_async(Thumbnail *p_t, GFile *p_file, int i_size,
+ GCancellable *p_cancel, GAsyncReadyCallback p_cb,
+ gpointer p_data);
+GdkTexture *thumbnail_get_finish(Thumbnail *p_t, GAsyncResult *p_res,
+ GError **p_err);
+
+G_END_DECLS
+
+#endif /* GGAZE_THUMBNAIL_H */ \ No newline at end of file
diff --git a/src/trash.c b/src/trash.c
new file mode 100644
index 0000000..331e99b
--- /dev/null
+++ b/src/trash.c
@@ -0,0 +1,142 @@
+/*:*
+ * ggaze — local ./Trash bin
+ *
+ * Implements the lazy .Trash folder, collision-suffixed binning, permanent
+ * delete, and one-level restore. Plain-C.
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "trash.h"
+
+struct Trash {
+ GFile *p_dir; /* the shoot dir; .Trash = p_dir/.Trash */
+ GFile *p_last_src; /* original path of the last binned file */
+ GFile *p_last_dst; /* .Trash path of the last binned file */
+};
+
+static GFile *
+_trash_dir(Trash *p_t) {
+ return (g_file_get_child(p_t->p_dir, ".Trash"));
+}
+
+/* Ensure .Trash exists (lazily). */
+static gboolean
+_ensure_trash_dir(Trash *p_t, GError **p_err) {
+ GFile *p_td = _trash_dir(p_t);
+ gboolean b_ok = g_file_make_directory_with_parents(p_td, NULL, p_err);
+ if (!b_ok) {
+ if (p_err != NULL &&
+ g_error_matches(*p_err, G_IO_ERROR, G_IO_ERROR_EXISTS)) {
+ g_clear_error(p_err);
+ b_ok = TRUE;
+ }
+ }
+ g_object_unref(p_td);
+ return (b_ok);
+}
+
+/* Find a non-colliding name in .Trash for c_basename. Caller frees the path. */
+static char *
+_unique_trash_name(Trash *p_t, const char *c_basename) {
+ GFile *p_td = _trash_dir(p_t);
+ char *c_name = g_strdu