diff options
| author | Paul Buetow <paul@buetow.org> | 2026-07-20 19:46:42 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-07-20 19:46:42 +0300 |
| commit | de9ceaeb0cc7040153c3ba1288180bd38767f639 (patch) | |
| tree | 6a3fd48200ac87622659f9cea013da3ad70ad27b | |
| parent | b69ed3923e42f122910242e724ee9dc43208d7b4 (diff) | |
grid: mark multi-selection (v / Ctrl+a / Esc) and a ? shortcuts overlay
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.
| -rw-r--r-- | src/gridview.c | 46 | ||||
| -rw-r--r-- | src/gridview.h | 8 | ||||
| -rw-r--r-- | src/shortcuts.c | 3 | ||||
| -rw-r--r-- | src/window.c | 211 | ||||
| -rw-r--r-- | tests/test_shortcut.c | 15 |
5 files changed, 276 insertions, 7 deletions
diff --git a/src/gridview.c b/src/gridview.c index c57eeac..3275333 100644 --- a/src/gridview.c +++ b/src/gridview.c @@ -240,6 +240,52 @@ ggaze_grid_sync_current(GgazeGrid *p_grid) { return (navigator_set_current_file(p_grid->p_nav, p_file)); } +/* Borrowed pointer to the selected cell's file (NULL if nothing selected). */ +GFile * +ggaze_grid_get_selected_file(GgazeGrid *p_grid) { + g_return_val_if_fail(GGAZE_IS_GRID(p_grid), NULL); + if (p_grid->p_flow == NULL) { + return (NULL); + } + GList *p_sel = + gtk_flow_box_get_selected_children(GTK_FLOW_BOX(p_grid->p_flow)); + if (p_sel == NULL) { + return (NULL); + } + GFile *p_file = (GFile *)g_object_get_data(G_OBJECT(p_sel->data), "file"); + g_list_free(p_sel); + return (p_file); /* borrowed: owned by the cell's qdata */ +} + +/* Toggle just one cell's "ggaze-marked" css class to match the navigator's + * mark set, without rebuilding the grid (so toggling a mark doesn't reflow or + * re-request thumbnails). */ +void +ggaze_grid_update_mark_badge(GgazeGrid *p_grid, GFile *p_file) { + g_return_if_fail(GGAZE_IS_GRID(p_grid)); + if (p_file == NULL || p_grid->p_nav == NULL) { + return; + } + gboolean b_marked = navigator_is_marked(p_grid->p_nav, p_file); + 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_file)) { + GtkWidget *p_box = + gtk_flow_box_child_get_child(GTK_FLOW_BOX_CHILD(p_child)); + if (p_box != NULL) { + if (b_marked) { + gtk_widget_add_css_class(p_box, "ggaze-marked"); + } else { + gtk_widget_remove_css_class(p_box, "ggaze-marked"); + } + } + 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)); diff --git a/src/gridview.h b/src/gridview.h index f6a4ad2..bfbf816 100644 --- a/src/gridview.h +++ b/src/gridview.h @@ -38,6 +38,14 @@ void ggaze_grid_refresh(GgazeGrid *p_grid); * the grid (Enter / toggle-to-large) opens the highlighted image. */ gboolean ggaze_grid_sync_current(GgazeGrid *p_grid); +/* Borrowed pointer to the currently-selected cell's file (NULL if none). The + * pointer is owned by the cell; only valid while the cell lives. */ +GFile *ggaze_grid_get_selected_file(GgazeGrid *p_grid); + +/* Update one cell's "ggaze-marked" badge from the navigator's mark set, + * without rebuilding the grid. No-op if the file's cell isn't present. */ +void ggaze_grid_update_mark_badge(GgazeGrid *p_grid, GFile *p_file); + /* Number of cells currently in the grid. */ guint ggaze_grid_get_count(GgazeGrid *p_grid); diff --git a/src/shortcuts.c b/src/shortcuts.c index 0c737bd..8ea978d 100644 --- a/src/shortcuts.c +++ b/src/shortcuts.c @@ -33,6 +33,9 @@ static const ShortcutEntry SHORTCUTS[] = { {GDK_KEY_D, GDK_SHIFT_MASK, "win.delete"}, {GDK_KEY_u, 0, "win.undo"}, {GDK_KEY_t, 0, "win.toggle-view"}, + {GDK_KEY_v, 0, "win.mark"}, + {GDK_KEY_a, GDK_CONTROL_MASK, "win.mark-all"}, + {GDK_KEY_question, 0, "win.shortcuts"}, {GDK_KEY_plus, 0, "win.zoom-in"}, {GDK_KEY_equal, 0, "win.zoom-in"}, {GDK_KEY_minus, 0, "win.zoom-out"}, 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 = + "<interface>" + " <object class=\"GtkShortcutsWindow\" id=\"shortcuts\">" + " <property name=\"modal\">True</property>" + " <property name=\"section-name\">shortcuts</property>" + " <child>" + " <object class=\"GtkShortcutsSection\" id=\"sec\">" + " <property name=\"section-name\">shortcuts</property>" + " <property name=\"title\">ggaze</property>" + " <child>" + " <object class=\"GtkShortcutsGroup\">" + " <property name=\"title\">Navigation</property>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">h Left</property>" + " <property name=\"title\">Previous " + "image</property></object></child>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">l Right</property>" + " <property name=\"title\">Next " + "image</property></object></child>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">g</property>" + " <property name=\"title\">First " + "image</property></object></child>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">Shift+G</property>" + " <property name=\"title\">Last " + "image</property></object></child>" + " </object></child>" + " <child>" + " <object class=\"GtkShortcutsGroup\">" + " <property name=\"title\">View</property>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">t</property>" + " <property name=\"title\">Toggle large / " + "grid</property></object></child>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">f</property>" + " <property " + "name=\"title\">Fullscreen</property></object></child>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">Shift+S</property>" + " <property " + "name=\"title\">Slideshow</property></object></child>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">i</property>" + " <property name=\"title\">Info " + "overlay</property></object></child>" + " </object></child>" + " <child>" + " <object class=\"GtkShortcutsGroup\">" + " <property name=\"title\">Selection (marks)</property>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">v</property>" + " <property name=\"title\">Toggle mark on " + "highlighted</property></object></child>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">Ctrl+a</property>" + " <property name=\"title\">Mark all</property></object></child>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">Escape</property>" + " <property name=\"title\">Clear marks / " + "back</property></object></child>" + " </object></child>" + " <child>" + " <object class=\"GtkShortcutsGroup\">" + " <property name=\"title\">Files</property>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">o</property>" + " <property name=\"title\">Open</property></object></child>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">d</property>" + " <property name=\"title\">Trash</property></object></child>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">Shift+D</property>" + " <property name=\"title\">Delete " + "permanently</property></object></child>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">u</property>" + " <property name=\"title\">Undo</property></object></child>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">q</property>" + " <property name=\"title\">Quit</property></object></child>" + " </object></child>" + " <child>" + " <object class=\"GtkShortcutsGroup\">" + " <property name=\"title\">Zoom</property>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">plus equal</property>" + " <property name=\"title\">Zoom in</property></object></child>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">minus underscore</property>" + " <property name=\"title\">Zoom out</property></object></child>" + " <child><object class=\"GtkShortcutsShortcut\">" + " <property name=\"accelerator\">question</property>" + " <property name=\"title\">Show this " + "help</property></object></child>" + " </object></child>" + " </object></child>" + " </child>" + " </object></child>" + "</interface>"; + +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); diff --git a/tests/test_shortcut.c b/tests/test_shortcut.c index db68e7b..e57d361 100644 --- a/tests/test_shortcut.c +++ b/tests/test_shortcut.c @@ -328,10 +328,11 @@ test_shortcut_keypath_toggle_and_back(void) { static void test_shortcut_full_table_registered(void) { static const char *ACTIONS[] = { - "win.prev", "win.next", "win.first", "win.last", - "win.open", "win.quit", "win.trash", "win.delete", - "win.undo", "win.toggle-view", "win.zoom-in", "win.zoom-out", - "win.fullscreen", "win.slideshow", "win.info", "win.back", + "win.prev", "win.next", "win.first", "win.last", + "win.open", "win.quit", "win.trash", "win.delete", + "win.undo", "win.toggle-view", "win.mark", "win.mark-all", + "win.shortcuts", "win.zoom-in", "win.zoom-out", "win.fullscreen", + "win.slideshow", "win.info", "win.back", }; GgazeWindow *p_win = new_window(); GtkShortcutController *p_sc = find_shortcut_controller(GTK_WIDGET(p_win)); @@ -341,9 +342,9 @@ test_shortcut_full_table_registered(void) { g_assert_nonnull(p_s); g_object_unref(p_s); } - /* The SHORTCUTS[] table has 20 rows (some actions appear twice, e.g. - * win.prev for h and Left). The controller must carry all 20. */ - g_assert_cmpint(g_list_model_get_n_items(G_LIST_MODEL(p_sc)), ==, 20); + /* The SHORTCUTS[] table has 23 rows now (some actions appear twice, e.g. + * win.prev for h and Left; win.zoom-in for plus and equal). */ + g_assert_cmpint(g_list_model_get_n_items(G_LIST_MODEL(p_sc)), ==, 23); g_object_unref(p_sc); g_object_unref(p_win); drain_main(200); |
