diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/gridview.c | 383 | ||||
| -rw-r--r-- | src/gridview.h | 49 | ||||
| -rw-r--r-- | src/navigator.c | 55 | ||||
| -rw-r--r-- | src/navigator.h | 9 | ||||
| -rw-r--r-- | src/shortcuts.c | 8 | ||||
| -rw-r--r-- | src/thumbnail.c | 241 | ||||
| -rw-r--r-- | src/thumbnail.h | 39 | ||||
| -rw-r--r-- | src/trash.c | 142 | ||||
| -rw-r--r-- | src/trash.h | 43 | ||||
| -rw-r--r-- | src/window.c | 231 |
10 files changed, 1186 insertions, 14 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 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_strdup(c_basename); + for (guint u_n = 1; u_n < 100000; u_n++) { + GFile *p_candidate = g_file_get_child(p_td, c_name); + if (!g_file_query_exists(p_candidate, NULL)) { + g_object_unref(p_candidate); + g_object_unref(p_td); + return (c_name); /* the basename is the unique name */ + } + g_object_unref(p_candidate); + g_free(c_name); + c_name = g_strdup_printf("%s-%u", c_basename, u_n); + } + g_free(c_name); + g_object_unref(p_td); + return (NULL); +} + +Trash * +trash_new(GFile *p_shoot_dir) { + g_return_val_if_fail(G_IS_FILE(p_shoot_dir), NULL); + Trash *p_t = g_new(Trash, 1); + p_t->p_dir = (GFile *)g_object_ref(p_shoot_dir); + p_t->p_last_src = NULL; + p_t->p_last_dst = NULL; + return (p_t); +} + +void +trash_delete(Trash *p_t) { + if (p_t == NULL) { + return; + } + g_clear_object(&p_t->p_last_dst); + g_clear_object(&p_t->p_last_src); + g_clear_object(&p_t->p_dir); + g_free(p_t); +} + +gboolean +trash_bin(Trash *p_t, GFile *p_file, GError **p_err) { + g_return_val_if_fail(p_t != NULL, FALSE); + g_return_val_if_fail(G_IS_FILE(p_file), FALSE); + if (!_ensure_trash_dir(p_t, p_err)) { + return (FALSE); + } + char *c_base = g_file_get_basename(p_file); + char *c_name = _unique_trash_name(p_t, c_base); + g_free(c_base); + if (c_name == NULL) { + g_set_error(p_err, G_IO_ERROR, G_IO_ERROR_TOO_MANY_OPEN_FILES, + "could not find a non-colliding trash name"); + return (FALSE); + } + GFile *p_td = _trash_dir(p_t); + GFile *p_dst = g_file_get_child(p_td, c_name); + g_free(c_name); + g_object_unref(p_td); + + gboolean b_ok = g_file_move(p_file, p_dst, G_FILE_COPY_NOFOLLOW_SYMLINKS, + NULL, NULL, NULL, p_err); + if (b_ok) { + g_clear_object(&p_t->p_last_src); + g_clear_object(&p_t->p_last_dst); + p_t->p_last_src = (GFile *)g_object_ref(p_file); + p_t->p_last_dst = p_dst; /* take ownership (ref'd by get_child) */ + } else { + g_object_unref(p_dst); + } + return (b_ok); +} + +gboolean +trash_permanently_delete(Trash *p_t, GFile *p_file, GError **p_err) { + (void)p_t; + g_return_val_if_fail(G_IS_FILE(p_file), FALSE); + return (g_file_delete(p_file, NULL, p_err)); +} + +gboolean +trash_restore_last(Trash *p_t, GError **p_err) { + g_return_val_if_fail(p_t != NULL, FALSE);< |
