diff options
| author | Paul Buetow <paul@buetow.org> | 2026-07-22 11:04:05 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-07-22 11:04:05 +0300 |
| commit | 9af69a3409f67cbe3807538ccfb56fd0d3f43070 (patch) | |
| tree | 912c18c43cfaecde242d15a0f60db88e40f2be78 | |
| parent | d0a824c685db1567facaf08fcae19ee0636496da (diff) | |
Integrate navigator marks into window and grid UI (M8)
Wire the navigator mark API into the window actions, grid badges, and
keyboard/pointer controls:
- Navigator: add a path-based range anchor (p_last_mark) set on `v`
toggle-on and cleared on unmark-of-anchor / clear_marks / remove /
mark_removed / rescan-prune; expose navigator_get_last_mark().
navigator_mark_removed now clears the removed file's mark (decision Q
/ task du0) and the anchor if it was it, without touching unrelated
marks, and emits "changed" whenever the removed set or marks actually
changed so grid badges and the header never go stale.
- Window: add win.mark-range (`V`: range from the last-mark anchor to the
current/grid-selected image), register it, wire Shift+V, and document
it in the shortcuts overlay. Trash/delete clear marks via mark_removed.
- Grid: middle-click on a cell selects it and dispatches win.mark
(pointer-accessible marks).
- Tests: unit test_marks_anchor_and_remove and
test_marks_anchor_pruned_on_rescan; integration test_marks.c covering
toggle, all+clear, range, persistence across view switch,
clear-on-trash, and the middle-click gesture wiring; updated
test_shortcut.c action table + count.
clang-format clean; full suite + ASan leak check green.
| -rw-r--r-- | docs/ui-and-interactions.md | 6 | ||||
| -rw-r--r-- | src/gridview.c | 38 | ||||
| -rw-r--r-- | src/navigator.c | 65 | ||||
| -rw-r--r-- | src/navigator.h | 5 | ||||
| -rw-r--r-- | src/shortcuts.c | 1 | ||||
| -rw-r--r-- | src/window.c | 40 | ||||
| -rw-r--r-- | tests/meson.build | 8 | ||||
| -rw-r--r-- | tests/test_marks.c | 424 | ||||
| -rw-r--r-- | tests/test_navigator.c | 108 | ||||
| -rw-r--r-- | tests/test_shortcut.c | 20 |
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) { |
