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 /src | |
| 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.
Diffstat (limited to 'src')
| -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 |
5 files changed, 139 insertions, 10 deletions
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}, |
