summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/ui-and-interactions.md6
-rw-r--r--src/gridview.c38
-rw-r--r--src/navigator.c65
-rw-r--r--src/navigator.h5
-rw-r--r--src/shortcuts.c1
-rw-r--r--src/window.c40
-rw-r--r--tests/meson.build8
-rw-r--r--tests/test_marks.c424
-rw-r--r--tests/test_navigator.c108
-rw-r--r--tests/test_shortcut.c20
10 files changed, 693 insertions, 22 deletions
diff --git a/docs/ui-and-interactions.md b/docs/ui-and-interactions.md
index 05081e1..28718e1 100644
--- a/docs/ui-and-interactions.md
+++ b/docs/ui-and-interactions.md
@@ -124,7 +124,8 @@ the grid it quits. `q` always quits outright (exiting fullscreen first).
— `scroll-behavior` setting.
- **Click-drag** — pan when zoomed in.
- **Double-click** — toggle fit ↔ 100%.
-- **Middle-click** — toggle fullscreen.
+- **Middle-click** — toggle mark on a grid cell (grid view) / toggle
+ fullscreen (large view).
- **Touch pinch** — zoom; **swipe** — next/prev; **two-finger tap** — info.
## Zoom behavior
@@ -161,7 +162,8 @@ Loaded lazily; never blocks display of the pixels.
small, fewer when large. Size persists in GSettings (`thumbnail-size`) and
is restored on next launch; `0` resets to default.
- Marks: `v` toggles a check badge on the current cell; `V` range-marks;
- `Ctrl+a` marks all. `d`/`D`/`m` act on the marked set (or current if none).
+ `Ctrl+a` marks all; middle-click a cell toggles its mark. `d`/`D`/`m` act on the
+ marked set (or current if none).
## Selection & moving
diff --git a/src/gridview.c b/src/gridview.c
index 69a62c1..7d7cc62 100644
--- a/src/gridview.c
+++ b/src/gridview.c
@@ -196,6 +196,37 @@ _on_flow_key(GtkEventControllerKey *p_key, guint u_kv, guint u_kc,
return (FALSE);
}
+/* Middle-click on a grid cell toggles its mark (pointer-accessible
+ * marks): select the cell, sync navigator.current to it, then dispatch the
+ * shared "win.mark" action (the window updates the header + badge exactly
+ * as `v` does). */
+static void
+_on_flow_middle_pressed(GtkGesture *p_g, gint i_n_press, gdouble d_x,
+ gdouble d_y, gpointer p_data) {
+ (void)i_n_press;
+ if (gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(p_g)) !=
+ GDK_BUTTON_MIDDLE) {
+ return;
+ }
+ GgazeGrid *p_grid = GGAZE_GRID(p_data);
+ if (p_grid->p_nav == NULL || p_grid->p_flow == NULL) {
+ return;
+ }
+ GtkFlowBoxChild *p_child = gtk_flow_box_get_child_at_pos(
+ GTK_FLOW_BOX(p_grid->p_flow), (gint)d_x, (gint)d_y);
+ if (p_child == NULL) {
+ return;
+ }
+ /* Select + sync navigator.current so win.mark targets this cell, then
+ * dispatch the shared toggle action (handles badge + header update). */
+ gtk_flow_box_select_child(GTK_FLOW_BOX(p_grid->p_flow), p_child);
+ 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);
+ }
+ gtk_widget_activate_action(GTK_WIDGET(p_grid), "win.mark", NULL);
+}
+
/* --- refresh / rebuild --------------------------------------------------- */
static void
@@ -476,6 +507,13 @@ ggaze_grid_init(GgazeGrid *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);
+ /* Middle-click on a cell toggles its mark (pointer-accessible marks). */
+ GtkGesture *p_middle = gtk_gesture_click_new();
+ gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(p_middle),
+ GDK_BUTTON_MIDDLE);
+ g_signal_connect(p_middle, "pressed", G_CALLBACK(_on_flow_middle_pressed),
+ p_grid);
+ gtk_widget_add_controller(p_grid->p_flow, GTK_EVENT_CONTROLLER(p_middle));
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(p_grid->p_scrolled),
p_grid->p_flow);
}
diff --git a/src/navigator.c b/src/navigator.c
index 8752e2d..38f02b1 100644
--- a/src/navigator.c
+++ b/src/navigator.c
@@ -34,15 +34,16 @@ typedef struct {
} Entry;
struct _Navigator {
- GObject parent_instance;
- GFile *p_dir; /* owned */
- GPtrArray *p_files; /* GFile* (owned refs), sorted/filtered */
- gint i_current; /* -1 if empty */
- GgazeSort e_sort;
- gboolean b_wrap;
- gboolean b_hide_raw;
- GHashTable *p_marks; /* GFile* (owned refs) -> presence */
- GHashTable *p_removed; /* GFile* (owned refs): trashed/deleted, dimmed */
+ GObject parent_instance;
+ GFile *p_dir; /* owned */
+ GPtrArray *p_files; /* GFile* (owned refs), sorted/filtered */
+ gint i_current; /* -1 if empty */
+ GgazeSort e_sort;
+ gboolean b_wrap;
+ gboolean b_hide_raw;
+ GHashTable *p_marks; /* GFile* (owned refs) -> presence */
+ GHashTable *p_removed; /* GFile* (owned refs): trashed/deleted, dimmed */
+ GFile *p_last_mark; /* anchor for `V` range-mark (owned ref, NULL=none) */
GFileMonitor *p_monitor;
guint u_debounce_ms;
guint u_debounce_id; /* 0 = none pending */
@@ -277,6 +278,13 @@ _relist(Navigator *p_nav) {
g_hash_table_iter_remove(&iter);
}
}
+ /* The range anchor must also be a live marked file: drop it if its file
+ * left the listing (external delete / rescan), so `V` no-ops instead of
+ * range-marking from a stale path. */
+ if (p_nav->p_last_mark != NULL &&
+ _find_index_by_file(p_nav, p_nav->p_last_mark) < 0) {
+ g_clear_object(&p_nav->p_last_mark);
+ }
/* Preserve removed (dimmed) items: keep them in the listing even if absent
* from disk (trashed/deleted this session); un-remove any that reappeared.
@@ -359,6 +367,7 @@ navigator_dispose(GObject *p_obj) {
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_clear_object(&p_nav->p_last_mark);
G_OBJECT_CLASS(navigator_parent_class)->dispose(p_obj);
}
@@ -386,6 +395,7 @@ navigator_init(Navigator *p_nav) {
p_nav->b_wrap = TRUE;
p_nav->b_hide_raw = TRUE;
p_nav->u_debounce_ms = GGAZE_DEFAULT_DEBOUNCE_MS;
+ p_nav->p_last_mark = NULL;
}
/* --- public API ---------------------------------------------------------- */
@@ -476,8 +486,22 @@ 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)) {
+ /* Decision Q: marks clear on trash/delete. Drop this file's mark (and
+ * the range anchor if it was the anchor) without touching unrelated
+ * marks, so mark-based ops never target paths that no longer exist. */
+ gboolean b_mark_changed = g_hash_table_remove(p_nav->p_marks, p_file);
+ if (p_nav->p_last_mark != NULL && g_file_equal(p_nav->p_last_mark, p_file)) {
+ g_clear_object(&p_nav->p_last_mark);
+ b_mark_changed = TRUE;
+ }
+ gboolean b_removed_added = !g_hash_table_contains(p_nav->p_removed, p_file);
+ if (b_removed_added) {
g_hash_table_add(p_nav->p_removed, g_object_ref(p_file));
+ }
+ /* Emit when the removed set or the marks actually changed, so grid badges
+ * and the header never go stale (e.g. re-marking a file already in
+ * p_removed, then removing it again). */
+ if (b_removed_added || b_mark_changed) {
_emit_changed(p_nav);
}
}
@@ -630,8 +654,16 @@ navigator_toggle_mark(Navigator *p_nav, GFile *p_file) {
g_return_if_fail(G_IS_FILE(p_file));
if (g_hash_table_contains(p_nav->p_marks, p_file)) {
g_hash_table_remove(p_nav->p_marks, p_file);
+ /* If the unmarked file was the range anchor, drop it: a subsequent `V`
+ * has no meaningful anchor once the anchor itself is unmarked. */
+ if (p_nav->p_last_mark != NULL &&
+ g_file_equal(p_nav->p_last_mark, p_file)) {
+ g_clear_object(&p_nav->p_last_mark);
+ }
} else {
g_hash_table_add(p_nav->p_marks, g_object_ref(p_file));
+ /* Remember this as the anchor for a later `V` range-mark. */
+ g_set_object(&p_nav->p_last_mark, p_file);
}
}
@@ -675,6 +707,7 @@ void
navigator_clear_marks(Navigator *p_nav) {
g_return_if_fail(GGAZE_IS_NAVIGATOR(p_nav));
g_hash_table_remove_all(p_nav->p_marks);
+ g_clear_object(&p_nav->p_last_mark);
_emit_changed(p_nav);
}
@@ -696,6 +729,15 @@ navigator_get_marks(Navigator *p_nav) {
return (g_list_reverse(p_out));
}
+/* The anchor set by the last `v` toggle-on, used by `V` range-mark. Borrowed
+ * (owned by the navigator); NULL if no mark has been toggled on yet, or if the
+ * anchor was unmarked / removed / cleared. Survives re-sort (path-based). */
+GFile *
+navigator_get_last_mark(Navigator *p_nav) {
+ g_return_val_if_fail(GGAZE_IS_NAVIGATOR(p_nav), NULL);
+ return (p_nav->p_last_mark);
+}
+
/* --- mutations ----------------------------------------------------------- */
void
@@ -715,6 +757,9 @@ navigator_remove(Navigator *p_nav, GFile *p_file) {
}
/* Clear its mark first (decision Q) while the file ref is still valid. */
g_hash_table_remove(p_nav->p_marks, p_file);
+ if (p_nav->p_last_mark != NULL && g_file_equal(p_nav->p_last_mark, p_file)) {
+ g_clear_object(&p_nav->p_last_mark);
+ }
g_ptr_array_remove_index(p_nav->p_files, (guint)i);
if (p_nav->p_files->len == 0) {
p_nav->i_current = -1;
diff --git a/src/navigator.h b/src/navigator.h
index db781ab..3946a21 100644
--- a/src/navigator.h
+++ b/src/navigator.h
@@ -78,6 +78,11 @@ void navigator_clear_marks(Navigator *p_nav);
guint navigator_get_mark_count(Navigator *p_nav);
GList *navigator_get_marks(Navigator *p_nav); /* (transfer full) GFile* refs */
+/* Borrowed pointer to the last file marked via a `v` toggle-on - the anchor
+ * for `V` range-mark (NULL if none / cleared). Survives re-sort (path-based).
+ */
+GFile *navigator_get_last_mark(Navigator *p_nav); /* (transfer none) */
+
/* --- mutations ---------------------------------------------------------- */
/* Re-read the directory; if the current file is gone, fall back to nearest;
* emit "changed". */
diff --git a/src/shortcuts.c b/src/shortcuts.c
index 0d86bb4..cfecc95 100644
--- a/src/shortcuts.c
+++ b/src/shortcuts.c
@@ -34,6 +34,7 @@ static const ShortcutEntry SHORTCUTS[] = {
{GDK_KEY_u, 0, "win.undo"},
{GDK_KEY_t, 0, "win.toggle-view"},
{GDK_KEY_v, 0, "win.mark"},
+ {GDK_KEY_V, GDK_SHIFT_MASK, "win.mark-range"},
{GDK_KEY_a, GDK_CONTROL_MASK, "win.mark-all"},
{GDK_KEY_a, 0, "win.enhance"},
{GDK_KEY_s, 0, "win.enhance-save"},
diff --git a/src/window.c b/src/window.c
index 65ef229..26beb57 100644
--- a/src/window.c
+++ b/src/window.c
@@ -462,6 +462,38 @@ _action_mark_all(GSimpleAction *p_a, GVariant *p_v, gpointer p_data) {
_update_header(p_win);
}
+/* `V` range-mark: mark every file from the last `v`-toggled mark (the anchor)
+ * to the highlighted grid cell / current large-view image, inclusive. No-op
+ * if no mark has been toggled on yet (no anchor). Emits "changed" so grid
+ * badges and the header mark count refresh. */
+static void
+_action_mark_range(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_anchor = navigator_get_last_mark(p_win->p_nav);
+ if (p_anchor == 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_mark_range(p_win->p_nav, p_anchor, p_target);
+ _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 =
@@ -557,6 +589,13 @@ static const char *SHORTCUTS_UI =
" </child>\n"
" <child>\n"
" <object class=\"GtkShortcutsShortcut\">\n"
+ " <property name=\"accelerator\">Shift+V</property>\n"
+ " <property name=\"title\">Range-mark from last mark "
+ "to current</property>\n"
+ " </object>\n"
+ " </child>\n"
+ " <child>\n"
+ " <object class=\"GtkShortcutsShortcut\">\n"
" <property name=\"accelerator\">Ctrl+a</property>\n"
" <property name=\"title\">Mark all</property>\n"
" </object>\n"
@@ -1083,6 +1122,7 @@ static const GActionEntry ACTIONS[] = {
{.name = "toggle-view", .activate = _action_toggle_view},
{.name = "mark", .activate = _action_mark},
{.name = "mark-all", .activate = _action_mark_all},
+ {.name = "mark-range", .activate = _action_mark_range},
{.name = "shortcuts", .activate = _action_shortcuts},
{.name = "enhance-1", .activate = _action_enhance_n},
{.name = "enhance-2", .activate = _action_enhance_n},
diff --git a/tests/meson.build b/tests/meson.build
index e895210..37d1233 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -263,4 +263,12 @@ test_clipboard = executable(
test('clipboard', test_clipboard,
suite : 'integration', env : fixtures_env)
+test_marks = executable(
+ 'test_marks', ['test_marks.c', ggaze_conf_h],
+ include_directories : [inc, src_inc],
+ dependencies : ggaze_deps,
+ link_with : ggaze_lib, install : false,
+)
+test('marks', test_marks, suite : 'integration', env : fixtures_env)
+
subdir('integration')
diff --git a/tests/test_marks.c b/tests/test_marks.c
new file mode 100644
index 0000000..a2350b1
--- /dev/null
+++ b/tests/test_marks.c
@@ -0,0 +1,424 @@
+/*:*
+ * ggaze — marks integration test
+ *
+ * Exercises the mark UI wiring in the window + grid: `v` toggle, `V` range,
+ * `Ctrl+a` mark-all, contextual `Esc` clear, persistence across a large/grid
+ * view switch, and mark clearing on a successful trash (decision Q / du0).
+ * Verifies through the public UI surface: the header title carries "N marked"
+ * and the grid cells carry the "ggaze-marked" badge in sync. Needs a display
+ * (integration suite; CI uses xvfb).
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "gridview.h"
+#include "viewer.h"
+#include "window.h"
+
+#include <gdk/gdk.h>
+#include <gio/gio.h>
+#include <glib.h>
+#include <gtk/gtk.h>
+
+/* --- helpers ------------------------------------------------------------ */
+
+static GgazeWindow *
+new_window(void) {
+ return (GGAZE_WINDOW(g_object_new(GGAZE_TYPE_WINDOW, NULL)));
+}
+
+static GdkTexture *
+viewer_texture(GgazeWindow *p_win) {
+ GtkStack *p_stack = ggaze_window_get_stack(p_win);
+ GtkWidget *p_large = gtk_stack_get_child_by_name(p_stack, "large");
+ return (ggaze_viewer_get_texture(GGAZE_VIEWER(p_large)));
+}
+
+static void
+drain_main(guint u_ms) {
+ for (guint u = 0; u < u_ms; u++) {
+ g_main_context_iteration(g_main_context_default(), FALSE);
+ g_usleep(1000);
+ }
+}
+
+static void
+copy_fixture(const char *c_dir, const char *c_name) {
+ const gchar *c_fx = g_getenv("GGAZE_FIXTURES_DIR");
+ g_assert_nonnull(c_fx);
+ char *c_src = g_build_filename(c_fx, c_name, NULL);
+ char *c_dst = g_build_filename(c_dir, c_name, NULL);
+ GFile *p_src = g_file_new_for_path(c_src);
+ GFile *p_dst = g_file_new_for_path(c_dst);
+ GError *p_err = NULL;
+ g_assert_true(g_file_copy(p_src, p_dst, G_FILE_COPY_OVERWRITE, NULL, NULL,
+ NULL, &p_err));
+ g_assert_no_error(p_err);
+ g_object_unref(p_src);
+ g_object_unref(p_dst);
+ g_free(c_src);
+ g_free(c_dst);
+}
+
+static void
+cleanup_temp_dir(char *c_dir) {
+ GFile *p_dir = g_file_new_for_path(c_dir);
+ GFileEnumerator *p_e =
+ g_file_enumerate_children(p_dir, "standard::name,standard::type",
+ G_FILE_QUERY_INFO_NONE, NULL, NULL);
+ if (p_e != NULL) {
+ GFileInfo *p_info;
+ while ((p_info = g_file_enumerator_next_file(p_e, NULL, NULL)) != NULL) {
+ GFile *p_child = g_file_get_child(p_dir, g_file_info_get_name(p_info));
+ if (g_file_info_get_file_type(p_info) == G_FILE_TYPE_DIRECTORY) {
+ GFileEnumerator *p_e2 = g_file_enumerate_children(
+ p_child, "standard::name", G_FILE_QUERY_INFO_NONE, NULL, NULL);
+ if (p_e2 != NULL) {
+ GFileInfo *p_i2;
+ while ((p_i2 = g_file_enumerator_next_file(p_e2, NULL, NULL)) !=
+ NULL) {
+ GFile *p_c2 =
+ g_file_get_child(p_child, g_file_info_get_name(p_i2));
+ g_file_delete(p_c2, NULL, NULL);
+ g_object_unref(p_c2);
+ g_object_unref(p_i2);
+ }
+ g_object_unref(p_e2);
+ }
+ }
+ g_file_delete(p_child, NULL, NULL);
+ g_object_unref(p_child);
+ g_object_unref(p_info);
+ }
+ g_object_unref(p_e);
+ }
+ g_file_delete(p_dir, NULL, NULL);
+ g_object_unref(p_dir);
+ g_free(c_dir);
+}
+
+/* Fire a win.* action on the window. */
+static void
+fire(GgazeWindow *p_win, const char *c_action) {
+ gtk_widget_activate_action(GTK_WIDGET(p_win), c_action, NULL);
+}
+
+/* The grid built on open (the stack's "grid" child), even when not visible. */
+static GgazeGrid *
+window_grid(GgazeWindow *p_win) {
+ GtkStack *p_stack = ggaze_window_get_stack(p_win);
+ return (GGAZE_GRID(gtk_stack_get_child_by_name(p_stack, "grid")));
+}
+
+/* Find the first GtkFlowBox descendant of p_root (the grid's flowbox lives
+ * under the scrolled window, possibly via an internal GtkViewport). */
+static GtkWidget *
+find_flowbox(GtkWidget *p_root) {
+ if (GTK_IS_FLOW_BOX(p_root)) {
+ return (p_root);
+ }
+ GtkWidget *p_child = gtk_widget_get_first_child(p_root);
+ while (p_child != NULL) {
+ GtkWidget *p_found = find_flowbox(p_child);
+ if (p_found != NULL) {
+ return (p_found);
+ }
+ p_child = gtk_widget_get_next_sibling(p_child);
+ }
+ return (NULL);
+}
+
+/* Count grid cells currently showing the "ggaze-marked" badge. Walks the
+ * GtkFlowBox (grid -> scrolled -> flowbox) and checks each cell's box. */
+static guint
+grid_marked_count(GgazeGrid *p_grid) {
+ GtkWidget *p_flow = find_flowbox(GTK_WIDGET(p_grid));
+ g_assert_nonnull(p_flow);
+ guint u_count = 0;
+ GtkWidget *p_child = gtk_widget_get_first_child(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 && gtk_widget_has_css_class(p_box, "ggaze-marked")) {
+ u_count++;
+ }
+ p_child = gtk_widget_get_next_sibling(p_child);
+ }
+ return (u_count);
+}
+
+/* The mark count shown in the window title ("N marked"), or 0 if the title
+ * carries no "marked" suffix (which is how 0 marks is rendered). */
+static guint
+title_mark_count(GgazeWindow *p_win) {
+ const gchar *c_title = gtk_window_get_title(GTK_WINDOW(p_win));
+ if (c_title == NULL) {
+ return (0);
+ }
+ const char *c_p = g_strstr_len(c_title, -1, " marked");
+ if (c_p == NULL) {
+ return (0);
+ }
+ /* walk back over the digits immediately preceding " marked" */
+ const char *c_num = c_p;
+ while (c_num > c_title && g_ascii_isdigit(*(c_num - 1))) {
+ c_num--;
+ }
+ if (c_num == c_p) {
+ return (0);
+ }
+ return ((guint)g_ascii_strtoull(c_num, NULL, 10));
+}
+
+static void
+assert_marks(GgazeWindow *p_win, guint u_expect) {
+ drain_main(100);
+ g_assert_cmpint(title_mark_count(p_win), ==, u_expect);
+ g_assert_cmpint(grid_marked_count(window_grid(p_win)), ==, u_expect);
+}
+
+static char *
+setup_dir_with_three(GFile **p_plain_out) {
+ GError *p_err = NULL;
+ char *c_dir = g_dir_make_tmp("ggaze-marks-XXXXXX", &p_err);
+ g_assert_no_error(p_err);
+ copy_fixture(c_dir, "plain.jpg");
+ copy_fixture(c_dir, "rot6.jpg");
+ copy_fixture(c_dir, "small.png");
+ char *c_path = g_build_filename(c_dir, "plain.jpg", NULL);
+ GFile *p_plain = g_file_new_for_path(c_path);
+ g_free(c_path);
+ *p_plain_out = p_plain;
+ return (c_dir);
+}
+
+static void
+wait_for_load(GgazeWindow *p_win) {
+ for (guint u = 0; u < 3000 && viewer_texture(p_win) == NULL; u++) {
+ g_main_context_iteration(g_main_context_default(), FALSE);
+ g_usleep(1000);
+ }
+ g_assert_nonnull(viewer_texture(p_win));
+ drain_main(200);
+}
+
+/* --- subtests ----------------------------------------------------------- */
+
+/* `v` toggles a mark on the current (large-view) image; the header shows
+ * "1 marked" and the grid badge appears on exactly one cell. `v` again
+ * untoggles it. */
+static void
+test_mark_toggle(void) {
+ GFile *p_plain = NULL;
+ char *c_dir = setup_dir_with_three(&p_plain);
+
+ GgazeWindow *p_win = new_window();
+ ggaze_window_open(p_win, p_plain);
+ wait_for_load(p_win);
+
+ g_assert_cmpint(ggaze_grid_get_count(window_grid(p_win)), ==, 3);
+ assert_marks(p_win, 0);
+
+ fire(p_win, "win.mark");
+ assert_marks(p_win, 1);
+
+ fire(p_win, "win.mark"); /* untoggle */
+ assert_marks(p_win, 0);
+
+ g_object_unref(p_plain);
+ g_object_unref(p_win);
+ drain_main(300);
+ cleanup_temp_dir(c_dir);
+}
+
+/* `Ctrl+a` marks every image; `Esc` (win.back) clears all marks contextually
+ * (no fullscreen / no large->grid back-step happens while marks exist). */
+static void
+test_mark_all_and_clear(void) {
+ GFile *p_plain = NULL;
+ char *c_dir = setup_dir_with_three(&p_plain);
+
+ GgazeWindow *p_win = new_window();
+ ggaze_window_open(p_win, p_plain);
+ wait_for_load(p_win);
+
+ fire(p_win, "win.mark-all");
+ assert_marks(p_win, 3);
+
+ /* Esc clears marks first (contextual back), rather than leaving large view.
+ */
+ GtkStack *p_stack = ggaze_window_get_stack(p_win);
+ g_assert_cmpstr(gtk_stack_get_visible_child_name(p_stack), ==, "large");
+ fire(p_win, "win.back");
+ assert_marks(p_win, 0);
+ /* Still in large view: Esc only cleared marks, did not back out. */
+ g_assert_cmpstr(gtk_stack_get_visible_child_name(p_stack), ==, "large");
+
+ g_object_unref(p_plain);
+ g_object_unref(p_win);
+ drain_main(300);
+ cleanup_temp_dir(c_dir);
+}
+
+/* `V` range-marks from the last `v`-toggled anchor to the current image.
+ * Mark a (anchor), advance to b, range -> 2 marked; advance to c, range
+ * -> 3 marked. */
+static void
+test_mark_range(void) {
+ GFile *p_plain = NULL;
+ char *c_dir = setup_dir_with_three(&p_plain);
+
+ GgazeWindow *p_win = new_window();
+ ggaze_window_open(p_win, p_plain);
+ wait_for_load(p_win);
+
+ /* current = plain.jpg (idx 0). Mark it -> anchor. */
+ fire(p_win, "win.mark");
+ assert_marks(p_win, 1);
+
+ /* advance to rot6.jpg (idx 1), range from anchor -> a, b marked. */
+ fire(p_win, "win.next");
+ drain_main(100);
+ fire(p_win, "win.mark-range");
+ assert_marks(p_win, 2);
+
+ /* advance to small.png (idx 2), range from anchor -> a, b, c marked. */
+ fire(p_win, "win.next");
+ drain_main(100);
+ fire(p_win, "win.mark-range");
+ assert_marks(p_win, 3);
+
+ g_object_unref(p_plain);
+ g_object_unref(p_win);
+ drain_main(300);
+ cleanup_temp_dir(c_dir);
+}
+
+/* Marks persist across a large > grid > large view switch: the grid shows
+ * the same badges and the header keeps the same count. */
+static void
+test_marks_persist_across_view_switch(void) {
+ GFile *p_plain = NULL;
+ char *c_dir = setup_dir_with_three(&p_plain);
+
+ GgazeWindow *p_win = new_window();
+ ggaze_window_open(p_win, p_plain);
+ wait_for_load(p_win);
+
+ fire(p_win, "win.mark");
+ fire(p_win, "win.next");
+ drain_main(100);
+ fire(p_win, "win.mark"); /* mark 2 images */
+ assert_marks(p_win, 2);
+
+ /* large -> grid: badges reflect the 2 marks. */
+ GtkStack *p_stack = ggaze_window_get_stack(p_win);
+ fire(p_win, "win.toggle-view");
+ drain_main(200);
+ g_assert_cmpstr(gtk_stack_get_visible_child_name(p_stack), ==, "grid");
+ g_assert_cmpint(grid_marked_count(window_grid(p_win)), ==, 2);
+ g_assert_cmpint(title_mark_count(p_win), ==, 2);
+
+ /* grid -> large: marks still present. */
+ fire(p_win, "win.toggle-view");
+ drain_main(200);
+ g_assert_cmpstr(gtk_stack_get_visible_child_name(p_stack), ==, "large");
+ assert_marks(p_win, 2);
+
+ g_object_unref(p_plain);
+ g_object_unref(p_win);
+ drain_main(300);
+ cleanup_temp_dir(c_dir);
+}
+
+/* Trashing the current image clears its mark (decision Q / du0): mark all 3,
+ * trash the current, the mark count drops to 2 and the grid badges drop one. */
+static void
+test_marks_clear_on_trash(void) {
+ GFile *p_plain = NULL;
+ char *c_dir = setup_dir_with_three(&p_plain);
+
+ GgazeWindow *p_win = new_window();
+ ggaze_window_open(p_win, p_plain);
+ wait_for_load(p_win);
+
+ fire(p_win, "win.mark-all");
+ assert_marks(p_win, 3);
+
+ fire(p_win, "win.trash");
+ drain_main(300);
+ /* The trashed file's mark is cleared; 2 marks remain. */
+ g_assert_cmpint(title_mark_count(p_win), ==, 2);
+ g_assert_cmpint(grid_marked_count(window_grid(p_win)), ==, 2);
+
+ /* .Trash was created with the binned file. */
+ char *c_trashpath = g_build_filename(c_dir, ".Trash", NULL);
+ GFile *p_trash = g_file_new_for_path(c_trashpath);
+ g_assert_true(g_file_query_exists(p_trash, NULL));
+ g_object_unref(p_trash);
+ g_free(c_trashpath);
+
+ g_object_unref(p_plain);
+ g_object_unref(p_win);
+ drain_main(300);
+ cleanup_temp_dir(c_dir);
+}
+
+/* A middle-click GtkGestureClick is attached to the grid flowbox, wired to
+ * toggle the mark on the clicked cell (pointer-accessible marks). GTK 4.22
+ * exposes no public API to synthesize a button event, so this is a structural
+ * check that the gesture is registered with button == middle, analogous to the
+ * shortcut-controller scope check in test_shortcut.c. */
+static void
+test_pointer_middle_click_wired(void) {
+ GFile *p_plain = NULL;
+ char *c_dir = setup_dir_with_three(&p_plain);
+
+ GgazeWindow *p_win = new_window();
+ ggaze_window_open(p_win, p_plain);
+ wait_for_load(p_win);
+
+ GgazeGrid *p_grid = window_grid(p_win);
+ GtkWidget *p_flow = find_flowbox(GTK_WIDGET(p_grid));
+ g_assert_nonnull(p_flow);
+ GListModel *p_ctrls = gtk_widget_observe_controllers(p_flow);
+ gboolean b_found = FALSE;
+ guint u_n = g_list_model_get_n_items(p_ctrls);
+ for (guint i = 0; i < u_n; i++) {
+ GObject *p_obj = g_list_model_get_item(p_ctrls, i);
+ if (GTK_IS_GESTURE_CLICK(p_obj)) {
+ if (gtk_gesture_single_get_button(GTK_GESTURE_SINGLE(p_obj)) ==
+ GDK_BUTTON_MIDDLE) {
+ b_found = TRUE;
+ }
+ }
+ g_object_unref(p_obj);
+ }
+ g_object_unref(p_ctrls);
+ g_assert_true(b_found);
+
+ g_object_unref(p_plain);
+ g_object_unref(p_win);
+ drain_main(300);
+ cleanup_temp_dir(c_dir);
+}
+
+int
+main(int i_argc, char **c_argv) {
+ g_test_init(&i_argc, &c_argv, NULL);
+ g_log_set_always_fatal(G_LOG_LEVEL_ERROR | G_LOG_LEVEL_CRITICAL);
+ if (!gtk_init_check()) {
+ g_test_skip("no display available (run under xvfb)");
+ return (g_test_run());
+ }
+ g_test_add_func("/marks/toggle", test_mark_toggle);
+ g_test_add_func("/marks/all_and_clear", test_mark_all_and_clear);
+ g_test_add_func("/marks/range", test_mark_range);
+ g_test_add_func("/marks/persist_across_view_switch",
+ test_marks_persist_across_view_switch);
+ g_test_add_func("/marks/clear_on_trash", test_marks_clear_on_trash);
+ g_test_add_func("/marks/pointer_middle_click_wired",
+ test_pointer_middle_click_wired);
+ return (g_test_run());
+} \ No newline at end of file
diff --git a/tests/test_navigator.c b/tests/test_navigator.c
index e8528b1..9cc86f2 100644
--- a/tests/test_navigator.c
+++ b/tests/test_navigator.c
@@ -304,6 +304,110 @@ test_marks(void) {
cleanup_temp_dir(c_dir);
}
+/* `v` toggle-on records the anchor used by `V` range-mark; unmarking the
+ * anchor drops it. navigator_mark_removed clears the mark (decision Q) and
+ * the anchor if it was the removed file, without touching unrelated marks. */
+static void
+test_marks_anchor_and_remove(void) {
+ char *c_dir = make_temp_dir();
+ write_file(c_dir, "a.jpg", "x", 1);
+ write_file(c_dir, "b.jpg", "x", 1);
+ write_file(c_dir, "c.jpg", "x", 1);
+ GFile *p_a = file_ref(c_dir, "a.jpg");
+ GFile *p_b = file_ref(c_dir, "b.jpg");
+ GFile *p_c = file_ref(c_dir, "c.jpg");
+
+ GFile *p_dirf = g_file_new_for_path(c_dir);
+ Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE);
+
+ /* No anchor until a mark is toggled on. */
+ g_assert_null(navigator_get_last_mark(p_nav));
+ navigator_toggle_mark(p_nav, p_a); /* mark a, anchor = a */
+ g_assert_true(g_file_equal(navigator_get_last_mark(p_nav), p_a));
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 1);
+
+ /* Range from anchor a to current c marks a, b, c. */
+ navigator_set_current_file(p_nav, p_c);
+ navigator_mark_range(p_nav, navigator_get_last_mark(p_nav), p_c);
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 3);
+
+ /* Unmarking the anchor drops it; the other marks remain. */
+ navigator_toggle_mark(p_nav, p_a); /* unmark a (was the anchor) */
+ g_assert_null(navigator_get_last_mark(p_nav));
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 2);
+ g_assert_true(navigator_is_marked(p_nav, p_b));
+ g_assert_true(navigator_is_marked(p_nav, p_c));
+
+ /* Re-marking a sets the anchor again; clearing marks drops it. */
+ navigator_toggle_mark(p_nav, p_a); /* mark a, anchor = a */
+ g_assert_true(g_file_equal(navigator_get_last_mark(p_nav), p_a));
+ navigator_clear_marks(p_nav);
+ g_assert_null(navigator_get_last_mark(p_nav));
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 0);
+
+ /* mark_removed clears that file's mark but leaves unrelated marks, and
+ * drops the anchor when the removed file was the anchor. */
+ navigator_toggle_mark(p_nav, p_b); /* mark b, anchor = b */
+ navigator_toggle_mark(p_nav, p_c); /* mark c, anchor = c */
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 2);
+ navigator_mark_removed(p_nav, p_c);
+ g_assert_false(navigator_is_marked(p_nav, p_c));
+ g_assert_true(navigator_is_marked(p_nav, p_b));
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 1);
+ g_assert_null(navigator_get_last_mark(p_nav)); /* anchor was c -> cleared */
+ /* mark_removed on the remaining (non-anchor) mark clears it too. */
+ navigator_mark_removed(p_nav, p_b);
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 0);
+
+ navigator_delete(p_nav);
+ g_object_unref(p_a);
+ g_object_unref(p_b);
+ g_object_unref(p_c);
+ g_object_unref(p_dirf);
+ cleanup_temp_dir(c_dir);
+}
+
+/* The range anchor is dropped when its file leaves the listing via an
+ * external delete + rescan (the monitor path), so `V` no-ops instead of
+ * range-marking from a stale path. */
+static void
+test_marks_anchor_pruned_on_rescan(void) {
+ char *c_dir = make_temp_dir();
+ write_file(c_dir, "a.jpg", "x", 1);
+ write_file(c_dir, "b.jpg", "x", 1);
+ write_file(c_dir, "c.jpg", "x", 1);
+ GFile *p_a = file_ref(c_dir, "a.jpg");
+ GFile *p_b = file_ref(c_dir, "b.jpg");
+ GFile *p_c = file_ref(c_dir, "c.jpg");
+
+ GFile *p_dirf = g_file_new_for_path(c_dir);
+ Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE);
+
+ navigator_toggle_mark(p_nav, p_a); /* anchor = a */
+ navigator_toggle_mark(p_nav, p_b);
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 2);
+ g_assert_true(g_file_equal(navigator_get_last_mark(p_nav), p_b));
+
+ /* Externally delete a (the anchor's mark prunes on rescan). */
+ g_assert_true(g_file_delete(p_a, NULL, NULL));
+ navigator_rescan(p_nav);
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 1); /* b remains */
+ g_assert_false(navigator_is_marked(p_nav, p_a));
+
+ /* Externally delete b: the anchor's file is gone, so the anchor drops. */
+ g_assert_true(g_file_delete(p_b, NULL, NULL));
+ navigator_rescan(p_nav);
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 0);
+ g_assert_null(navigator_get_last_mark(p_nav)); /* anchor pruned */
+
+ navigator_delete(p_nav);
+ g_object_unref(p_a);
+ g_object_unref(p_b);
+ g_object_unref(p_c);
+ g_object_unref(p_dirf);
+ cleanup_temp_dir(c_dir);
+}
+
static void
test_rescan_and_nearest_fallback(void) {
char *c_dir = make_temp_dir();
@@ -471,6 +575,10 @@ main(int i_argc, char **c_argv) {
g_test_add_func("/navigator/prev_next_wrap", test_prev_next_wrap);
g_test_add_func("/navigator/set_current_file", test_set_current_file);
g_test_add_func("/navigator/marks", test_marks);
+ g_test_add_func("/navigator/marks_anchor_and_remove",
+ test_marks_anchor_and_remove);
+ g_test_add_func("/navigator/marks_anchor_pruned_on_rescan",
+ test_marks_anchor_pruned_on_rescan);
g_test_add_func("/navigator/rescan_fallback",
test_rescan_and_nearest_fallback);
g_test_add_func("/navigator/remove", test_remove_clears_mark_and_falls_back);
diff --git a/tests/test_shortcut.c b/tests/test_shortcut.c
index 96a5b74..7365ef0 100644
--- a/tests/test_shortcut.c
+++ b/tests/test_shortcut.c
@@ -328,14 +328,14 @@ 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.mark", "win.mark-all",
- "win.shortcuts", "win.zoom-in", "win.zoom-out", "win.fullscreen",
- "win.slideshow", "win.info", "win.back", "win.enhance",
- "win.enhance-save", "win.enhance-1", "win.enhance-2", "win.enhance-3",
- "win.enhance-4", "win.enhance-5", "win.enhance-6", "win.enhance-7",
- "win.enhance-8",
+ "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.mark-range", "win.shortcuts", "win.zoom-in", "win.zoom-out",
+ "win.fullscreen", "win.slideshow", "win.info", "win.back",
+ "win.enhance", "win.enhance-save", "win.enhance-1", "win.enhance-2",
+ "win.enhance-3", "win.enhance-4", "win.enhance-5", "win.enhance-6",
+ "win.enhance-7", "win.enhance-8",
};
GgazeWindow *p_win = new_window();
GtkShortcutController *p_sc = find_shortcut_controller(GTK_WIDGET(p_win));
@@ -345,9 +345,9 @@ test_shortcut_full_table_registered(void) {
g_assert_nonnull(p_s);
g_object_unref(p_s);
}
- /* The SHORTCUTS[] table has 25 rows now (some actions appear twice, e.g.
+ /* The SHORTCUTS[] table has 34 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)), ==, 33);
+ g_asser