summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-20 19:46:42 +0300
committerPaul Buetow <paul@buetow.org>2026-07-20 19:46:42 +0300
commitde9ceaeb0cc7040153c3ba1288180bd38767f639 (patch)
tree6a3fd48200ac87622659f9cea013da3ad70ad27b
parentb69ed3923e42f122910242e724ee9dc43208d7b4 (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.c46
-rw-r--r--src/gridview.h8
-rw-r--r--src/shortcuts.c3
-rw-r--r--src/window.c211
-rw-r--r--tests/test_shortcut.c15
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);