From de9ceaeb0cc7040153c3ba1288180bd38767f639 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 20 Jul 2026 19:46:42 +0300 Subject: grid: mark multi-selection (v / Ctrl+a / Esc) and a ? shortcuts overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The navigator already had a mark API and the grid rendered mark badges, but nothing could set marks — so multi-file ops (D, Ctrl+c, m) only ever acted on the single current image. Wire up marking in the UI: - v toggles a mark on the highlighted grid cell (or the current image in large view). navigator_toggle_mark does not emit "changed", so the cell's badge is updated in place via ggaze_grid_update_mark_badge (no reflow / re-decode). - Ctrl+a marks all; Esc now clears marks first (contextual), then keeps its existing back/quit behavior. - The window title appends "N marked" so multi-selection is visible. - A GtkCssProvider styles .ggaze-marked (accent border + tint) — the class was set but had no styling, so badges were invisible. Also add a ? -> win.shortcuts action that presents a GtkShortcutsWindow (grouped: navigation, view, selection, files, zoom) built from an inline GtkBuilder UI string, transient to the window. tests/test_shortcut.c full-table test updated for the three new bindings (v, Ctrl+a, ?) -> 23 rows, 19 actions. --- src/window.c | 211 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) (limited to 'src/window.c') diff --git a/src/window.c b/src/window.c index e28ca0a..50a6e04 100644 --- a/src/window.c +++ b/src/window.c @@ -253,6 +253,173 @@ _action_toggle_view(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) { } } +/* Toggle a mark on the highlighted grid cell (grid view) or the current image + * (large view). Marks are ggaze's multi-selection: D / Ctrl+c / m act on the + * marked set. Toggle does not emit navigator "changed", so the grid cell's + * badge is updated in place (no reflow/re-decode). */ +static void +_action_mark(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) { + (void)p_a; + (void)p_v; + GgazeWindow *p_win = GGAZE_WINDOW(p_data); + if (p_win->p_nav == NULL) { + return; + } + GFile *p_target = NULL; + const char *c_cur = + gtk_stack_get_visible_child_name(GTK_STACK(p_win->p_stack)); + if (g_strcmp0(c_cur, "grid") == 0 && p_win->p_grid != NULL) { + p_target = ggaze_grid_get_selected_file(p_win->p_grid); + } + if (p_target == NULL) { + p_target = navigator_get_current(p_win->p_nav); + } + if (p_target == NULL) { + return; + } + navigator_toggle_mark(p_win->p_nav, p_target); + if (p_win->p_grid != NULL) { + ggaze_grid_update_mark_badge(p_win->p_grid, p_target); + } + _update_header(p_win); +} + +static void +_action_mark_all(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) { + (void)p_a; + (void)p_v; + GgazeWindow *p_win = GGAZE_WINDOW(p_data); + if (p_win->p_nav == NULL) { + return; + } + navigator_mark_all(p_win->p_nav); /* emits "changed" -> grid refresh */ + _update_header(p_win); +} + +/* GtkBuilder UI for the shortcuts overlay (?). Accel strings use gtk + * accelerator syntax: "h Left" means h OR Left triggers it. */ +static const char *SHORTCUTS_UI = + "" + " " + " True" + " shortcuts" + " " + " " + " shortcuts" + " ggaze" + " " + " " + " Navigation" + " " + " h Left" + " Previous " + "image" + " " + " l Right" + " Next " + "image" + " " + " g" + " First " + "image" + " " + " Shift+G" + " Last " + "image" + " " + " " + " " + " View" + " " + " t" + " Toggle large / " + "grid" + " " + " f" + " Fullscreen" + " " + " Shift+S" + " Slideshow" + " " + " i" + " Info " + "overlay" + " " + " " + " " + " Selection (marks)" + " " + " v" + " Toggle mark on " + "highlighted" + " " + " Ctrl+a" + " Mark all" + " " + " Escape" + " Clear marks / " + "back" + " " + " " + " " + " Files" + " " + " o" + " Open" + " " + " d" + " Trash" + " " + " Shift+D" + " Delete " + "permanently" + " " + " u" + " Undo" + " " + " q" + " Quit" + " " + " " + " " + " Zoom" + " " + " plus equal" + " Zoom in" + " " + " minus underscore" + " Zoom out" + " " + " question" + " Show this " + "help" + " " + " " + " " + " " + ""; + +static void +_action_shortcuts(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) { + (void)p_a; + (void)p_v; + GgazeWindow *p_win = GGAZE_WINDOW(p_data); + GtkBuilder *p_b = gtk_builder_new_from_string(SHORTCUTS_UI, -1); + GtkShortcutsWindow *p_w = + GTK_SHORTCUTS_WINDOW(gtk_builder_get_object(p_b, "shortcuts")); + if (p_w == NULL) { + g_object_unref(p_b); + return; + } + gtk_window_set_transient_for(GTK_WINDOW(p_w), GTK_WINDOW(p_win)); + gtk_window_set_title(GTK_WINDOW(p_w), "ggaze — keyboard shortcuts"); + /* Keep the builder alive for the window's lifetime, drop it on close. */ + g_signal_connect_swapped(p_w, "destroy", G_CALLBACK(g_object_unref), p_b); + gtk_window_present(GTK_WINDOW(p_w)); +} + static void _action_zoom_in(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) { (void)p_a; @@ -330,6 +497,12 @@ _action_back(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) { if (p_win->b_fullscreen) { gtk_window_unfullscreen(GTK_WINDOW(p_win)); p_win->b_fullscreen = FALSE; + } else if (p_win->p_nav != NULL && + navigator_get_mark_count(p_win->p_nav) > 0) { + /* Contextual Esc: clear marks before backing out (docs/ui-and- + * interactions.md marks). Emits "changed" -> grid refreshes badges. */ + navigator_clear_marks(p_win->p_nav); + _update_header(p_win); } else { const char *c_cur = gtk_stack_get_visible_child_name(GTK_STACK(p_win->p_stack)); @@ -398,6 +571,9 @@ static const GActionEntry ACTIONS[] = { {.name = "delete", .activate = _action_delete}, {.name = "undo", .activate = _action_undo}, {.name = "toggle-view", .activate = _action_toggle_view}, + {.name = "mark", .activate = _action_mark}, + {.name = "mark-all", .activate = _action_mark_all}, + {.name = "shortcuts", .activate = _action_shortcuts}, {.name = "zoom-in", .activate = _action_zoom_in}, {.name = "zoom-out", .activate = _action_zoom_out}, {.name = "fullscreen", .activate = _action_fullscreen}, @@ -615,6 +791,14 @@ _update_header(GgazeWindow *p_win) { } g_free(c_name); } + /* Append the marked count so multi-selection is visible in the title. */ + guint u_marks = navigator_get_mark_count(p_win->p_nav); + if (u_marks > 0 && c_title != NULL) { + char *c_tmp = + g_strdup_printf("%s \u00b7 %u marked", c_title, u_marks); + g_free(c_title); + c_title = c_tmp; + } } if (c_title == NULL) { c_title = g_strdup("ggaze"); @@ -665,8 +849,35 @@ ggaze_window_class_init(GgazeWindowClass *p_klass) { p_obj_class->dispose = ggaze_window_dispose; } +/* Load the small ggaze stylesheet once (mark badge styling — the navigator's + * mark API has no visual representation without it). */ +static void +_ensure_css(void) { + static gboolean b_done = FALSE; + if (b_done) { + return; + } + b_done = TRUE; + GtkCssProvider *p_css = gtk_css_provider_new(); + gtk_css_provider_load_from_string( + p_css, "/* marked-thumbnail badge (multi-selection). */\n" + ".ggaze-marked {\n" + " border: 2px solid #3584e4;\n" + " border-radius: 4px;\n" + " background-color: rgba(53, 132, 228, 0.15);\n" + "}\n"); + GdkDisplay *p_disp = gdk_display_get_default(); + if (p_disp != NULL) { + gtk_style_context_add_provider_for_display( + p_disp, GTK_STYLE_PROVIDER(p_css), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + } + g_object_unref(p_css); +} + static void ggaze_window_init(GgazeWindow *p_win) { + _ensure_css(); p_win->p_cancel = g_cancellable_new(); p_win->p_prefetch_cancel = g_cancellable_new(); p_win->p_cache = texturecache_new(4); -- cgit v1.2.3