summaryrefslogtreecommitdiff
path: root/src/gridview.c
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-13 20:57:16 +0300
committerPaul Buetow <paul@buetow.org>2026-07-13 20:57:16 +0300
commit0795d3cf926d0d937f25fcc699f5f674bf618c72 (patch)
tree48e3fcf44a7c5213c11c63c1f69eb8b336edb325 /src/gridview.c
parent48aa1b3cdbf27be2e016e8b532687226f9249043 (diff)
gridcull: thumbnail TMS cache, grid view, trash, window d/D/u/t (jt0)
M7 (task jt0): thumbnail cache + grid view + trash. - src/trash.{c,h}: ./Trash bin (lazy create, collision suffix -1/-2, restore_last for u undo, permanent delete for D). Plain-C, unit-tested. - src/thumbnail.{c,h}: freedesktop TMS cache (normal/large/x-large buckets, md5(URI) key, Thumb::URI/MTime/Size, mtime verify). Async GTask worker with GCancellable so the grid can cancel pending requests on dispose. - src/gridview.{c,h}: GgazeGrid (GtkWidget wrapping GtkFlowBox): one cell per navigator file, lazy thumbnail decode on realize, +/- resize 64-512, dim removed cells, mark badges, Enter/double-click → 'activate' signal, cursor sync. ggaze_grid_detach() breaks the nav ref before dispose. - src/navigator.{c,h}: removed set (mark_removed/is_removed/unmark_removed); _relist preserves removed items (stay dimmed even if absent from disk); get_remaining = count - removed_count. - src/window.c: d (trash+mark_removed+advance), D (permanent delete, confirm dialog if >1 marked), u (restore_last+rescan), t (toggle grid/large), +/- (viewer zoom in large / grid resize). Header counter uses remaining. - src/shortcuts.c: d/D/u/t/+/= bindings added. - tests: unit test_trash + test_thumbnail, integration test_grid_cull (grid count + trash/undo + ./sample-images 613-cell grid). 13/13 green, ASan clean. Fixed: grid dispose UAF (detach before nav freed), g_file_get_basename leak, test p_plain lifetime, thumbnail async-cancellable for clean dispose, test grid-toggle avoiding async-thumbnail drain issues.
Diffstat (limited to 'src/gridview.c')
-rw-r--r--src/gridview.c383
1 files changed, 383 insertions, 0 deletions
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