Skip to content

gtk4-prep: event controller & shortcut fixes - #21745

Merged
TurboGit merged 26 commits into
darktable-org:masterfrom
Arecsu:gtk4-prep-check
Aug 8, 2026
Merged

gtk4-prep: event controller & shortcut fixes#21745
TurboGit merged 26 commits into
darktable-org:masterfrom
Arecsu:gtk4-prep-check

Conversation

@Arecsu

@Arecsu Arecsu commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

All the changes from #21733, #21741 and #21742 are included in this PR. Their commits are part of this branch. I closed those PRs because they shared most of their code paths with the review fixes below; a single PR is easier to review than three overlapping ones.

From the previous PRs, incorporated here:

Color picker shortcuts + double-press on hold keys (#21733). The event-controller conversion broke shortcuts on standalone picker buttons (e.g. AgX auto-tune): shortcuts synthesize fake button-press events that capture-phase gestures never receive. Pickers now use their own action definition and the shared callback takes explicit ctrl/right-click flags instead of fabricating events. Also, a key with a hold shortcut (default a) could never trigger a double/triple-press shortcut on the same key; the hold press is now recorded and a fast second press is detected as a double press.

Lighttable scrolling (#21741). Scrolling stopped as soon as the pointer was over an always-visible thumbnail overlay icon (e.g. the star ratings): the converted scroll handlers were target-only instead of bubbling. Bubble phase restored, and the raw smooth scroll deltas in thumbtable/culling were restored (touchpad scrolling had been chopped into fixed 50 px steps).

Gesture-driven toggle button shortcuts (#21742). Widgets whose action lives in a gesture (mask shape buttons, retouch/spots, filmicrgb, temperature, liquify, ashift fit/structure, the Masks module buttons, ...) now register the gesture (DT_ACTION_GESTURE_KEY), and the shortcut layer fires the gesture's press signal — the same path a real click takes.

New fixes

Help provided by the technical writeups of @kofa73 here and here.

  • Curve editors: right-clicking an endpoint reset it and silently deleted a node (dropped early return); right-clicking a node also opened the module presets menu. Both fixed, matching the pre-migration behavior.
  • Removed the GTK3 double-click bridge: GtkGestureMultiPress counts n_press natively, and the bridge fired every double-click twice (double view switch in lighttable/culling, duplicate filmstrip select+ACTIVATE).
  • GdkEvent leaks from gtk_get_current_event() in controller callbacks (culling, thumbtable, darkroom second window, colorlabels, shortcuts dialog, metadata, collection treeview), plus a dt_gui_get_current_root_coords() helper.
  • Removed the smooth-scroll *_fallback helpers that double-accumulated after the DISCRETE proxy (bauhaus popup zoom-range reset, misleading preset '(last)' toast); preset scrolling now uses the proxy steps directly, with a reversal dampener at the preset list boundaries.
  • Module reset created two undo records (duplicate dt_dev_add_history_item).
  • Thumbtable/culling release handlers lost the primary-button guard (middle/right release changed the selection).
  • Shortcut action effects are now carried into synthetic gesture presses: ctrl-/right-effect variants work again on ashift fit, blend shapes, retouch, spots and masks (previously a ctrl+v shortcut ran the ctrl variant of a plain-activate binding, and the plain fit was unreachable from that shortcut).
  • Retouch "editing" shortcut regression fixed (it read the key event during dispatch and got button 0, so edit mode never toggled).

Linked issues

Fixes #21730
Fixes #21736
Fixes #21735
Fixes #21578

And, besides those previously quoted fixes, plenty of other things were tested working properly along the way.

Tests

Manual tests already conducted

All of the following were manually tested on this branch and verified working (where a behavior was broken before this PR, the old broken behavior is noted as "(before: ...)").

Darkroom — module header

  • Right-click on a module body (e.g. exposure, tone curve): the presets popup must open (this was dead before the button=0 fix upstream; verify it still works).

  • Module reset button: single click → module resets and the undo history gains exactly one entry (undo once, then redo — before, one click created two undo steps).

  • Presets button scroll: hover the module presets arrow and scroll the wheel → presets cycle (previous/next) with the toast; a smooth trackpad scroll must not show a bogus preset 'x' (last) toast when nothing was applied.

  • Ctrl+click the reset button → re-applies auto-presets (once).

    1. Store a preset on a module (presets arrow → store new preset) and tick "auto apply this preset" in the store dialog (set the image filter if you like).
    2. Then ctrl+click the module's reset button → the auto preset is re-applied (module label shows the preset name). Plain click → resets to defaults.

Darkroom — curve editors (tone curve, RGB curves, base curve, color zones)

  • Right-click a curve endpoint (first or last node): the endpoint resets to 0/1 and no node is deleted (before this fix the node count silently shrank). Right-click a middle node → deletes the node as before. Check the persisted params: draw a curve, right-click an endpoint, then expand/collapse the module — node count must be unchanged. Right-click on empty curve area → presets menu still opens

  • Double-click the curve area → resets the curve (unchanged). Result: Works correctly.


Lighttable — thumbtable / culling

  • Double-click a thumbnail in filemanager/zoom mode → switches to darkroom once (before, the bridge+gesture double-fired; on slow machines you could see the modules rebuild twice / a flash).

  • Right-click a thumbnail and release → selection must not change. Middle-click release → selection must not change (before, any-button release selected the hovered image).

  • Culling mode: middle-click → zoom toggle works and does not flip the culling selection (act-on set). Right-click release → no selection change.

  • Memory: wiggle the mouse over the lighttable/culling grid and scroll a lot (e.g. 30 s), then check RSS with top/Activity Monitor — should stay flat (before, culling/thumbtable motion leaked one GdkEvent per event).


Bauhaus slider popup

  • Open a slider popup (e.g. Exposure → Black Level or any Bauhaus slider), then smooth-scroll with a trackpad over it: the slider's zoom range must not flip between soft/hard bounds (before, delta 0 reset the range). A clicky wheel should still zoom the range as before; middle-click should still reset the range.

Second darkroom window (preview2)

  • Scroll / trackpad-pan in the second window (preview pinned) for a while — memory must stay flat (the scroll callback leaked a GdkEvent per event).

Shortcut-activated toggle buttons (PR #21742 fixes)

  • Retouch "editing" toggle via keyboard: open Retouch, bind a shortcut to Retouch → Editing (or use the existing default), press it → mask edit mode must toggle on/off (before: only the shape buttons cleared and edit mode never changed).

  • effect variants

    1. Open the shortcuts dialog (keyboard icon).
    2. In the left actions search, type ashift → find "fit" → "vertical". Select it.
    3. Press a key, e.g. v → a new row appears in the right shortcuts list.
    4. Click the "effect" cell of that row → dropdown → pick "ctrl-activate".
    5. Close the dialog, go to darkroom, expand ashift, press v → it must run the ctrl variant (fit rotation/lens-shift only, not the full vertical fit). Before this fix it ran the plain fit.
    6. Bonus scenario: assign ctrl+v with the plain "activate" effect → pressing ctrl+v must do the plain fit (before: the live ctrl state hijacked it into the ctrl variant).
    7. Same for shapes: actions → "blend" → "shapes" → "ellipse", effect "ctrl-toggle" → activating should start continuous ("add multiple") shape creation.
  • Retouch shape buttons (path/circle/ellipse/brush) via shortcut: ctrl-effect shortcut → continuous (multi) shape creation starts.

  • Color labels: The "colorlabels" module is in the lighttable, bottom-left panel (the section with the color squares under the thumbnails area). Right-click one of the color squares → the description-edit popup should open at the pointer.

Collections / masks / metadata (leak + popup)

  • Masks tree (Blend → Drawn Mask → right-click row) — context menu opens, no leak.

  • Shortcuts dialog: type in the search box — memory stays flat.

  • Folders & filmrolls are single-select by design. Click a folder row (e.g. photos/2024/) → only that row is selected and the collection filters to it. Shift-click or ctrl/cmd-click a second row must not extend the selection. Right-click a folder or filmroll row → the context menu opens at the pointer ("update path to files...", "remove...").

  • Shift-range selection only exists for the time/exif views (day, month, time, aperture, focal length, ISO, exposure, aspect ratio, rating): click one row, shift-click another → the rows in between are selected and the rule becomes a range [a;b] (order follows the sort direction). Ctrl+click adds nothing here — it is single-select on those views too.

  • Double-click toggles expand/collapse on tree parents; a single shift- or ctrl-click on a folder/filmroll row activates it directly (same as double-click); shift+ctrl+click on a folder row switches to the matching filmroll view and vice versa.


Related: #15920 #20433

Arecsu added 16 commits August 6, 2026 21:49
… buttons

The gtk4-prep migration replaced the picker buttons' GObject
button-press-event handler with a CAPTURE-phase GtkGestureMultiPress that
claims the event sequence (the button's own internal bubble-phase gesture
would otherwise toggle it a second time).  Shortcut activation however
still goes through dt_action_def_toggle, whose generic processor
(_action_process_toggle) synthesizes GObject button-press-event signals:
those only reach the widget class handler (bubble-phase controllers) and
carry no device, so they can never trigger the CAPTURE-phase gesture --
picker shortcuts bound to standalone toggle buttons (e.g. AgX 'auto tune
levels') silently stopped working.

Give standalone picker buttons their own action definition (like bauhaus
widgets have dt_action_def_bauhaus) whose process calls the same shared
entry as real clicks, so shortcuts reach the picker directly without
event synthesis.  Update the five modules that define standalone picker
buttons (AgX, channel mixer RGB, negadoctor, tone curve) to use it.

Real clicks still go through the CAPTURE gesture, which now also passes
the clicked button through so a secondary click still switches to area
mode.
…efinition

The earlier fix updated only the single-line dt_action_define_iop calls;
multi-line call sites had the same latent shortcut bug.  Borders (border
and frame line color), watermark, the RGB levels pickers (macro) and the
primary colorpicker lib button all used dt_action_def_toggle with a
standalone toggle button, so picker shortcuts could not reach their
CAPTURE-phase gesture either.

Switch them all to dt_action_def_color_picker and drop the synthetic
GdkEventButton construction in the shared callback: the picker logic only
cares about ctrl/right-click, so pass those as explicit flags from the
three activation paths (bauhaus quad, gesture, shortcut).
A key with a hold shortcut (e.g. the default 'a' = force pan/zoom/rotate
with mouse) could never trigger a double/triple-press shortcut bound to
the same key: every press re-engaged the hold and returned early, so the
double/triple press detection never ran.

Now the hold press is recorded in the shortcut state, and a fast
consecutive press of the same key - while a double/triple press shortcut
exists for it (checked via _shortcut_has_double_triple_press()) - falls
through to the normal handling and is detected as a double/triple press
instead of re-engaging the hold.
…lick

The gtk4-prep event-controller conversion dropped the early return that
terminated the secondary-button endpoint-reset branch in the four curve
editors (tone curve, rgb curves, base curve, color zones).  Control fell
through into the node-removal loop, so right-clicking an endpoint reset it
AND silently decremented the persisted node count.

The branch became reachable once dt_gui_connect_click() started listening
to all buttons (right-click no longer filtered out), so this is a live
data-corruption bug, not a latent one.
The gesture callback _gui_reset_clicked called dt_dev_add_history_item()
twice in a row, so one reset click produced two undo records and ran the
pipeline TOP_CHANGED invalidation / history autosave path twice.  Same fix
as PR darktable-org#21727.
The dt_gui_get_scroll_unit_delta(s)_fallback() helpers re-read the raw
GdkEventScroll through dt_gui_get_scroll_unit_deltas(), whose static
accumulator feeds off the same smooth-scroll stream the DISCRETE scroll
proxy already accumulated.  The two accumulators phase-drift, so the
fallback returned FALSE with delta 0 on real proxy steps, and:

- the bauhaus popup slider called _slider_zoom_range(w, 0) unconditionally,
  triggering the middle-click 'reset zoom range' (soft<->hard bounds toggle)
  on every sub-threshold trackpad scroll;
- the module presets scroller called
  dt_gui_presets_apply_adjacent_preset(module, 0), which matched no row
  (LIMIT 0) but still toasted a misleading "preset 'x' (last)".

The DISCRETE proxy already delivers integer unit steps, so both handlers now
use the proxy dx/dy directly with a delta != 0 guard (sign conventions match
the pre-migration dt_gui_get_scroll_unit_delta path), and the fallback
helpers are deleted - they were dead code on GTK3, where the current event
is always present inside controller callbacks.
GtkGestureMultiPress on GTK3 counts n_press by itself: GDK delivers a
second GDK_BUTTON_PRESS before GDK_2BUTTON_PRESS for the second click of a
double-click (gdk_synthesize_click appends a copy, gdkevents.c), and
gtk_gesture_multi_press_begin() emits pressed(n_press=2) for it
(gtkgesturemultipress.c).  The in-tree comment claiming GDK never delivers
a second GDK_BUTTON_PRESS was wrong.

The dt_gui_connect_double_click() bridge therefore fired the press callback
a second time on every double/triple click: the gesture's own n_press=2 call
plus the bridge's GDK_2BUTTON_PRESS forward.  In the thumbtable and culling
handlers this ran dt_view_manager_switch("darkroom") twice (a same-view
switch still tears down and rebuilds every expandable lib plugin) and the
filmstrip deselect/select/ACTIVATE twice.

Deleted the whole bridge (DblClkData, _dbl_clk_free, _dbl_clk_handler,
dt_gui_connect_double_click/disconnect, macro, both call sites) and
simplified the callbacks' gesture?get_current_button:GDK_BUTTON_PRIMARY
ternaries to a direct gtk_gesture_single_get_current_button() call.

Also makes dt_gui_connect_click_all() a plain alias of
dt_gui_connect_click(): the base function already sets button=0, so the
redundant second gtk_gesture_single_set_button() is gone.
gtk_get_current_event() returns an owned copy on GTK3 (transfer full);
every copy must be released with gdk_event_free().  The event-controller
migration introduced copies in callbacks that never freed them, leaking one
GdkEvent per event:

- culling button-press and motion-notify callbacks (root coordinates)
- thumbtable motion-notify callback (root coordinates)
- darkroom second-window scroll callback (all return paths)
- colorlabels secondary-click branch
- shortcuts-dialog key handler (accelerators)
- metadata textview key handler
- collection treeview click handler - all four return paths (this one was
  missed by the panel review)
- gtk_menu_popup_at_pointer(menu, gtk_get_current_event()) in collect and
  masks

New helper dt_gui_get_current_root_coords() (gtk.h/gtk.c) centralizes the
get/use/free dance for the four root-coordinate sites; it carries a GTK4
migration note (gtk_get_current_event() disappears, use the borrowed
gesture/controller events).
…ease handlers

The pre-migration button-release handlers required GDK_BUTTON_PRIMARY; the
event-controller conversion dropped the test while connecting the handlers
through dt_gui_connect_click_all() (button=0), so middle- and right-button
releases started changing the image selection:

- thumbtable: right/middle release over a thumbnail ran
  dt_selection_toggle/select_range/select_single;
- culling: middle-click zoom also flipped table->selection, changing which
  image later act-on operations apply to.

Both release handlers now check
gtk_gesture_single_get_current_button(gesture) == GDK_BUTTON_PRIMARY again.
The shortcut dispatcher (accelerators.c) activates gesture-driven widgets
(DT_ACTION_GESTURE_KEY, e.g. every dt_iop_togglebutton_new widget, the
masks-lib shape buttons, the ashift fit/structure buttons) by emitting a
bare "pressed" signal on the stored GtkGestureMultiPress.  The action
effect was never translated into that synthetic press, so the ctrl-,
right-button and long-press effect variants (dt_action_effect_toggle's
TOGGLE_CTRL/ON_CTRL/TOGGLE_RIGHT/ON_RIGHT, dt_action_effect_activate's
ACTIVATE_CTRL/ACTIVATE_RIGHT) collapsed into a plain primary click.

Effects:
- ashift fit_v/fit_h/fit_both: the callback read dt_key_modifier_state()
  (live keyboard state), so a shortcut bound to ctrl+<key> with the plain
  activate effect ran the ctrl fit variant (rotation-only) and the plain
  fit became unreachable from that shortcut; pre-migration the synthetic
  event carried the effect-encoded state.
- blend_gui _blendop_masks_add_shape / _blendop_blendif_showmask_clicked /
  _blendop_masks_show_and_edit and masks _bt_add_shape_cb read
  gtk_get_current_event_state(), which during shortcut dispatch is the key
  event, so ctrl-toggle/ctrl-on effects behaved like plain toggle.
- retouch rt_add_shape_callback (continuous creation), retouch
  rt_select_algorithm_callback (ctrl restricts algorithm switch) and the
  spots shape-add callback had the same pattern (missed by the review).

Fix: the dispatcher encodes the effect as (state << 8) | button into
object data on the gesture (DT_ACTION_GESTURE_SYNTH_KEY) around the
synthetic "pressed" emit; dt_gui_current_button() (gtk.h) consults it
first (right effects present as GDK_BUTTON_SECONDARY) and the new
dt_gui_current_state() helper returns the effect-determined state, falling
back to the real event state for genuine clicks.  All affected callbacks
migrated to the helpers.

Also fixes the retouch editing-toggle regression: rt_edit_masks_callback
read gtk_get_current_event() (the key event during dispatch, button 0), so
a keyboard shortcut on retouch > editing only cleared the shape toggles and
never entered/left mask-edit mode; the helpers return a primary button for
shortcut presses.  That callback also leaked its event copy - removed.
…oundaries

A continuous trackpad scroll is a series of swipes, and each swipe ends
with a short opposite-sign stream (fingers lifting/rebounding) that the
DISCRETE scroll proxy accumulates into a step back.  At the first/last
preset this oscillated between the boundary preset and its neighbour
(e.g. 1<->2, 9<->10) and spammed the "(first)"/"(last)" toast.

Ignore a direction reversal that arrives within 250 ms of the previous
step: a real direction change comes from a new gesture and is slower.
…urve node

The curve editors' press handlers return no value, so a right-click on a
curve node bubbled up to the module body's right-click handler, which opens
the presets popup - while the node was being deleted/reset at the same
time.  The pre-migration button-press handlers returned TRUE for node
actions, stopping GTK signal propagation.

The curve gestures now claim the event sequence when handling a node
right-click, consuming the event so the body handler never runs.  Clicks
on empty curve area still propagate (matching the pre-migration FALSE
return, which also opened the presets menu there).
@Arecsu Arecsu changed the title gtk4-prep: event controller & shortcut fixes (supersedes #21733, #21741, #21742) gtk4-prep: event controller & shortcut fixes Aug 7, 2026
@Arecsu Arecsu changed the title gtk4-prep: event controller & shortcut fixes gtk4-prep: event controllers II & shortcut fixes Aug 7, 2026
@Arecsu Arecsu changed the title gtk4-prep: event controllers II & shortcut fixes gtk4-prep: event controller & shortcut fixes Aug 7, 2026
@kofa73

kofa73 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Important (accepted, medium)

  • src/gui/accelerators.c:216_action_effect_button_state() encodes the toggle effects ON and OFF as ctrl-click and right-click, because DT_ACTION_EFFECT_ON/OFF have the same enum values as DT_ACTION_EFFECT_ACTIVATE_CTRL/ACTIVATE_RIGHT, so on/off and scroll shortcuts on gesture-backed toggle buttons now run the wrong variant or nothing at all. — ⚠ detail contested: severity (medium vs low)

    • Evidence:
      • src/common/action.h:99 / :111DT_ACTION_EFFECT_ON == DT_ACTION_EFFECT_DEFAULT_UP == 1 and DT_ACTION_EFFECT_ACTIVATE_CTRL == DT_ACTION_EFFECT_DEFAULT_UP == 1; likewise DT_ACTION_EFFECT_OFF == DT_ACTION_EFFECT_ACTIVATE_RIGHT == 2 (action.h:100, :112).
      • src/gui/accelerators.c:222 / :226 — the switch groups ACTIVATE_CTRL with the toggle ctrl effects and ACTIVATE_RIGHT with the toggle right effects, so effect ON returns (GDK_CONTROL_MASK<<8)|GDK_BUTTON_PRIMARY and effect OFF returns GDK_BUTTON_SECONDARY. A shortcut declared with the plain on effect is delivered to the gesture as ctrl+click; off is delivered as a right click.
      • src/gui/accelerators.c:260 — the retained non-gesture branch still uses the correct toggle-only test (TOGGLE_CTRL||ON_CTRL for the mask, TOGGLE_RIGHT||ON_RIGHT for the button), i.e. plain primary with no modifier for both ON and OFF. The two branches now disagree.
      • src/gui/accelerators.c:4193_process_shortcut() converts DT_ACTION_EFFECT_DEFAULT_MOVE into DEFAULT_DOWN/DEFAULT_UP, numerically OFF/ON, so every move- or scroll-bound shortcut on a gesture-backed toggle reaches the mis-mapped cases: scroll up acts as ctrl+click, scroll down is silently ignored.
      • src/libs/masks.c:670, :681_bt_add_shape_cb() gates on dt_gui_current_button(gesture)==GDK_BUTTON_PRIMARY and reads dt_gui_current_state(gesture) for creation_continuous, so an on shortcut for "add gradient" starts continuous shape creation and an off shortcut is dropped entirely by the primary-button guard (widget registered with DT_ACTION_GESTURE_KEY, masks.c:2277ff, driven through dt_action_def_toggle).
      • src/iop/ashift.c:5482_event_structure_auto_clicked() is affected identically (g->structure_auto registered at ashift.c:6134 with dt_action_def_toggle at :6148).
      • src/gui/accelerators.c:309_action_process_button() passes its effect to the same helper, so the helper cannot distinguish the toggle and button effect namespaces from the effect value alone; a correction confined to the switch cannot serve both callers — the mapping has to be selected by the calling action definition.
      • src/gui/accelerators.c:405 — scoping fact: _action_fallbacks_toggle maps ctrl/right-button/long-press to TOGGLE_CTRL(3)/TOGGLE_RIGHT(5), which the switch encodes correctly. The aliased ON/OFF cases are reached only through an explicitly declared on/off effect or a move/scroll-bound shortcut converted at accelerators.c:4193 — default click-derived variants are unaffected.
  • src/gui/accelerators.c:4568 — Engaging a hold shortcut now stores the key in the global _sc without any reset path, so after holding (and releasing) a hold-bound key every later plain scroll/move shortcut is resolved as "that key + move" and stops working. — ⚠ detail contested: severity (medium vs low)

    • Evidence:
      • src/gui/accelerators.c:4566-4570 — the hold-engage branch now sets _last_time = time, _sc.key_device = id, _sc.key = key before returning; the pre-change code left _sc untouched on this path.
      • src/gui/accelerators.c:4719, :4725dt_shortcut_key_release() returns as soon as the key is found in _hold_keys, before _delay_for_double_triple(), so _key_release_delayed() — the only place that does _sc = (dt_shortcut_t){0} when _pressed_keys is empty (accelerators.c:4445) — is never scheduled for a hold key. _sc.key stays set indefinitely.
      • src/gui/accelerators.c:4404, :4564 — a hold key lands in _hold_keys, not _pressed_keys, so dt_shortcut_move() takes the !_pressed_keys branch and calls _process_shortcut() with the stale _sc.
      • src/gui/accelerators.c:3791_shortcut_closest_match() bails out with fully_matched=FALSE whenever c->key_device != s->key_device || c->key != s->key, so a plain wheel scroll no longer resolves to the plain scroll shortcut once _sc.key is non-zero.
      • src/gui/accelerators.c:4152, :4158, :4460-4461 — the only other place that clears _sc is _ungrab_at_focus_loss(); the button-release path rewrites only _sc.button/_sc.click. The stale key persists across mouse clicks until an unrelated key press+release or window focus loss.
      • src/views/darkroom.c:3584, :3590GDK_KEY_w (full preview) and GDK_KEY_a (force pan/zoom/rotate) are registered with DT_ACTION_EFFECT_HOLD by default, so the branch is reached with stock settings.
      • src/views/darkroom.c:2662 — concrete failure: with _sc.key left at GDK_KEY_a, a wheel scroll (dispatched via dt_shortcut_move, accelerators.c:4905) matches the registered a hold shortcut; the fallback-to-move branch (accelerators.c:3929-3936) plus the DEFAULT_MOVE conversion (:4193-4202) runs _action_process_skip_mouse() with EFFECT_ON/OFF, toggling darktable.develop->darkroom_skip_mouse_events. Wheel scroll stops zooming and instead flips "skip mouse events" until an unrelated key press+release clears _sc.
  • src/gui/color_picker_proxy.c:526 — Several standalone color pickers still use the generic toggle action, so their shortcuts bypass the new capture gesture and remain nonfunctional. — ⚠ detail contested: severity (medium vs low)

    • Evidence:
      • src/gui/color_picker_proxy.c:526 — standalone and GtkBox-hosted picker buttons now handle real clicks in a capture-phase gesture.
      • src/gui/accelerators.c:243 — the generic shortcut toggle path emits a gesture only when DT_ACTION_GESTURE_KEY is present, and otherwise synthesizes a GdkEvent; the synthetic-event fallback cannot reach a capture-phase gesture, so the picker is not armed.
      • Sites still registering dt_action_def_toggle instead of dt_action_def_color_picker (one point each): src/develop/blend_gui.c:2611 (blend show-color), src/develop/blend_gui.c:2623 (blend set-range), src/iop/colorzones.c:2634 (show-color), src/iop/colorzones.c:2649 (create-curve), src/iop/rgbcurve.c:1499 (show-color), src/iop/rgbcurve.c:1514 (create-curve), src/iop/retouch.c:2646 (fill-color), src/iop/temperature.c:2078 (area picker).
      • src/gui/color_picker_proxy.c:531 — the standalone branch connects only the gesture's pressed signal; there is no clicked/toggled handler on the button.
      • src/gui/accelerators.c:275 — the synthetic branch ends in gtk_button_clicked() when no handler consumes the emitted button-press-event, which flips the GtkToggleButton's active state even though _color_picker_callback_button_press() never runs. The button renders as armed while no picker instance is activated, so button state and picker proxy disagree.
    • Note against the PR's own claim: this directly tests the "all twelve standalone pickers" enumeration the author asked to be checked — the panel found the conversion incomplete.
  • src/develop/imageop_gui.c:275 (defect site src/iop/liquify.c:3623) — The synthetic gesture shortcut path loses declared Ctrl effects for Liquify tools because their callback still reads the live GdkEvent state. — ⚠ detail contested: severity (medium vs low) and location (imageop_gui.c:275 vs liquify.c:3623)

    • Evidence:
      • src/develop/imageop_gui.c:275dt_iop_togglebutton_new() stores its capture gesture under DT_ACTION_GESTURE_KEY for shortcut routing.
      • src/gui/accelerators.c:252 — shortcut activation stores the declared effect state on the gesture before emitting pressed.
      • src/iop/liquify.c:3623btn_make_radio_callback() ignores DT_ACTION_GESTURE_SYNTH_KEY and calls gtk_get_current_event() directly instead of reading the gesture's synthetic state.
      • src/iop/liquify.c:3627ctrl_pressed is derived only from that live event state. During shortcut dispatch gtk_get_current_event() is the key event, so a shortcut bound to ctrl+<key> declared with the plain toggle effect still yields ctrl_pressed = TRUE, while a plain-key binding declared with the ctrl effect yields FALSE — both declared variants map to live modifier state rather than the declared effect.
      • src/iop/liquify.c:3548ctrl_pressed sets creation_continuous, so the shortcut selects a tool but fails to enable continuous drawing.
      • Cross-check: the commit "carry shortcut action effects into synthetic gesture presses" migrated blend_gui, masks, retouch, spots and ashift, but left liquify.c unchanged.

Minor (accepted, low)

Both entries below concern the same new preset-scroll dampener in _presets_scrolled(). They were deliberately kept as separate issues: the failure mechanisms differ (over-broad suppression vs. sign/magnitude comparison), and each was accepted unanimously in debate round 1.

  • src/develop/imageop.c:2619 — The new preset-scroll direction dampener uses process-global statics and no device check, so it also swallows a legitimate quick direction reversal of a clicky mouse wheel and steps on a different module's presets button.

    • Evidence:
      • src/develop/imageop.c:2592_presets_scrolled_last_step_time and _presets_scrolled_last_dir are file-scope statics shared by every module's presets button, so a step on module B is dropped when module A was scrolled the other way less than 250 ms earlier.
      • src/develop/imageop.c:2619 — any step whose sign differs from the previous one within 250000 µs returns early, with no test for whether the event came from a smooth (trackpad) gesture or a discrete wheel click; reversing the wheel within a quarter second over the presets arrow loses one preset step.
      • src/gui/gtk.c:4947_scroll_proxy_real() only attenuates and accumulates when the direction is GDK_SCROLL_SMOOTH, so a discrete wheel click reaches _presets_scrolled() as an integer ±1 and hits the same dampener; the removed dt_gui_get_scroll_unit_deltas_fallback() path applied every wheel click immediately.
  • src/develop/imageop.c:2620 — Preset hysteresis discards legitimate same-direction multi-step scroll events because it compares the full delta with a stored sign.

    • Evidence:
      • src/develop/imageop.c:2620 — the reversal guard compares delta directly with _presets_scrolled_last_dir.
      • src/develop/imageop.c:2623_presets_scrolled_last_dir is assigned only 1 or −1 regardless of delta magnitude, so delta != _presets_scrolled_last_dir is true for any |delta| > 1 in the same direction.
      • src/gui/gtk.c:4956, :4963 — the discrete scroll proxy documents that its callback can receive an absolute delta greater than one and emits trunc(_scroll_discrete_dx) as a multi-step integer when the accumulator crosses multiple units. A same-direction delta of ≥2 within 250 ms is treated as a reversal and skipped.

Contested (reviewers held positions — you decide)

  • src/libs/collect.c:4083 (disputed defect site collect.c:686 / :702-704) — Original severity as raised: critical. In the collection treeview, view_onButtonPressed_cb runs alongside GtkTreeView's own selection handling instead of replacing it: it clears and selects the clicked path before native Ctrl modifier handling and computes Shift ranges from an already-mutated selection, breaking Ctrl-click multi-selection and Shift-range selection in the DAY/time rules where GTK_SELECTION_MULTIPLE applies.

    This is the author's own open item from manual testing (Shift-range and Ctrl/Cmd-click multi-selection do not work; right-click context menus do). The panel confirms a real regression exists here but did not converge on its mechanism or severity.

    • For (as originally raised — event starvation):
      • src/libs/collect.c:4083 — the gesture controller is connected without an explicit propagation phase, defaulting to GTK_PHASE_BUBBLE.
      • src/libs/collect.c:635 — the native GtkTreeView class handler processes left-clicks in the TARGET phase and returns TRUE, stopping propagation before the BUBBLE phase, so view_onButtonPressed_cb never executes for left-clicks on rows.
      • src/libs/collect.c:734 — darktable's custom double-click activation and Ctrl/Shift-click selection logic relies entirely on this callback; right-clicks succeed because the treeview ignores them and they bubble.
    • For (revised mechanism — ordering, not starvation; folded in during round 2):
      • src/libs/collect.c:704 — for Ctrl-clicks (and all primary clicks not caught by the Shift branch) the callback unconditionally unselects all and selects only the clicked path, destroying multi-selection.
      • src/libs/collect.c:686 / :673 — for Shift-clicks the callback uses the already-extended selection from the native handler as its anchor, so the computed range is wrong; the pre-migration handler ran before the class handler and returned TRUE to suppress it.
    • Against (the starvation mechanism, from two seats):
      • row_activated_with_event() is reachable only from view_onButtonPressed_cb (collect.c:695, :717, :734) and no row-activated handler is connected to this treeview (collect.c:4083-4084). If primary presses never reached the gesture, clicking a folder or film-roll row could not change the collection at all — a total loss of the module's main function, not merely of modifier selection. That is not what is observed.
      • GtkTreeView in GTK 3.24 handles presses through its own bubble-phase GtkGestureMultiPress, not a TARGET-phase class handler (darktable's own migration notes state this at accelerators.c:2337-2345 and color_picker_proxy.c:514-525, which is why those sites use CAPTURE+claim). Later-attached GTK3 controllers are prepended, so the collection gesture runs first.
      • src/libs/collect.c:1861-1868 — the selection mode is GTK_SELECTION_SINGLE for every rule except day/time, identical to pre-migration code, so Ctrl-click multi-selection is impossible for folder/film-roll rules regardless of any phase question. This narrows the real defect to the DAY/time rules only, and with it the severity (one seat proposed low, one critical).
    • Disputed facts / how to check:
      1. Does view_onButtonPressed_cb actually run on a primary press on a row? Breakpoint or log at collect.c:673; if it fires, the starvation mechanism is dead and only the ordering defect stands.
      2. Set the collection rule to day or a time property (the only GTK_SELECTION_MULTIPLE modes) and try Ctrl-click and Shift-click. If Ctrl-click multi-selection fails only there, the narrowed low-severity reading is correct; if folder/film-roll navigation itself is also broken, the starvation reading is correct.
      3. Compare against master: GTK_SELECTION_SINGLE for non-day rules is pre-existing, so any Ctrl-click failure on folders/film rolls is not a regression from this PR.

Arecsu added 5 commits August 7, 2026 04:08
… gesture presses

The effect values DT_ACTION_EFFECT_ON/OFF alias ACTIVATE_CTRL/ACTIVATE_RIGHT
(action.h), so the shared _action_effect_button_state() encoded a toggle's
plain on/off shortcut as ctrl+click/right-click on gesture-backed toggle
buttons.  A scroll- or move-bound shortcut then ran the wrong variant or
nothing at all (masks shape-add dropped the off variant via its primary-
button guard; ashift structure-auto behaved identically).  The caller's
action definition now selects the mapping: toggles deliver plain on/off as
a plain primary click (matching the retained synthetic branch), buttons
keep the ctrl/right encoding for ACTIVATE_CTRL/ACTIVATE_RIGHT.
… double-press window

Engaging a hold shortcut stores the key in _sc (needed for the fast
second-press double/triple detection and for move routing while held), but
dt_shortcut_key_release() returned before _delay_for_double_triple(), so
_key_release_delayed() - the only normal-path _sc reset - was never
scheduled for a hold key.  With stock defaults (a/w are hold-bound) one
hold followed by a wheel scroll resolved the scroll as "that key + move"
and toggled darkroom_skip_mouse_events instead of zooming.

The hold release now schedules the same delayed cleanup as a normal key
release: _sc.key stays valid for the double/triple-press window and is
cleared once it elapses.  A _hold_release_pending flag keeps the delayed
pass from re-dispatching the shortcut (the hold action already ran ON/OFF),
and _interrupt_delayed_release() drops the stale key when the pending
release is cancelled by an unrelated event.
… own action definition

The color-picker conversion covered 12 sites but left 8 standalone pickers
on the generic toggle action, whose synthetic-event path cannot reach the
CAPTURE-phase gesture: the shortcut flipped the GtkToggleButton state via
gtk_button_clicked() while _color_picker_callback_button_press() never ran,
leaving the button armed with no picker instance.  The pickers now use
dt_action_def_color_picker, routing shortcuts through the same entry point
as a real click.  Same element name and fallbacks, so existing bindings
are unaffected.
btn_make_radio_callback() read gtk_get_current_event() (the key event
during shortcut dispatch) instead of the gesture's synthetic state, so a
declared ctrl variant was lost and creation_continuous was set from live
modifier state.  Now uses dt_gui_current_state(gesture) - the declared
effect for shortcut presses, live state for real clicks - and drops the
leaked event copy.
…gn comparison

The direction-reversal dampener used file-scope statics shared by every
module's presets button (a reversal on module A dropped a step on module
B), applied to discrete wheel clicks too (a deliberate wheel reversal
within 250 ms lost a step), and compared the full delta against a stored
sign, discarding same-direction multi-step deltas the discrete scroll
proxy legitimately emits.  The state now lives per presets button, the
hysteresis only applies to smooth (trackpad) scrolls, and the reversal
test compares signs.
… gesture

The collections treeview gesture ran in the default BUBBLE phase without
claiming, so every press was also processed by the treeview's internal
bubble-phase GtkGestureMultiPress: the callback's unconditional
unselect_all+select_path ran first, then the native ctrl-toggle / shift
handling mutated the selection again, breaking ctrl-click and shift-range
selection in the day/time multiple-selection rules.  Use the documented
CAPTURE+claim pattern (as in gui/accelerators.c) to replicate the event
consumption of the pre-migration button-press-event handler.
@Arecsu

Arecsu commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

New fixes

Toggle on/off shortcuts on gesture-backed buttons. Effect values alias (ON == ACTIVATE_CTRL, OFF == ACTIVATE_RIGHT), so a toggle's plain on/off shortcut was delivered as ctrl+click/right-click — an on for "add gradient" started continuous creation, an off was dropped by the primary-button guard. The mapping is now chosen by the calling action definition: toggles deliver on/off as a plain click (matching the retained synthetic branch), buttons keep the ctrl/right encoding. Scroll-bound shortcuts are fixed too: scroll up activates once, scroll down is no longer silently ignored.

Hold-key shortcuts leaving a stale key in the shortcut state. The hold-engage branch records the key in _sc (needed for double-press detection and move routing while held), but hold releases never scheduled the delayed cleanup normal keys get, so _sc.key stayed set forever — with stock defaults (a/w are hold-bound), one hold followed by a wheel scroll flipped "skip mouse events" instead of zooming. Hold releases now schedule the same delayed reset: the double/triple-press window still works, and _sc is cleared once it elapses. The delayed pass skips re-dispatching the already-dispatched action, and an event cancelling the pending reset drops the stale key on the spot.

The eight standalone color pickers that were left out. Eight pickers (blend show-color/set-range, colorzones and rgbcurve show-color/create-curve, retouch fill-color, temperature area picker) were still on the generic toggle action: their shortcuts flipped the button via gtk_button_clicked() while no picker instance was armed. They now use dt_action_def_color_picker, routing shortcuts through the same entry point as a real click; same element name and fallbacks, so existing bindings are unaffected.

Liquify tool shortcuts. btn_make_radio_callback still read gtk_get_current_event() — the key event during dispatch — so declared ctrl variants were lost. It now reads the gesture state like the other migrated callbacks, and the leaked event copy is gone.

Preset-scroll dampener (both minor items). The dampener used process-global statics (a reversal on module A swallowed a step on module B), applied to discrete wheel clicks too, and compared the full delta against a stored sign, dropping same-direction |delta| ≥ 2. State is now per presets button, the hysteresis only applies to smooth (trackpad) scrolls, and the reversal test compares signs.

Collections treeview — the contested one. Starvation is ruled out: row_activated_with_event is the only path that can change the collection, it's reachable only from the callback, and folder activation works — so the callback does run on primary presses. The real mechanism: the bubble-phase gesture didn't claim, so the treeview's internal gesture processed every press too, and its ctrl-toggle/shift handling mutated the selection after the callback's own unselect_all+select_path — breaking ctrl-click and shift-range in the day/time rules. Fixed with the CAPTURE + claim pattern the shortcuts dialog's treeview uses; day/time shift-range now works and ctrl-click is single-select as documented. For the record, that treeview code is identical to master (the conversion predates this PR), so it wasn't introduced here — but it was worth fixing anyway.

On the contested severities: I'd agree with medium on the hold-key item (stock defaults reach it), and with the low reading on the collections item for this PR, since it's pre-existing. Both are moot now that they're fixed.

Tests

Manual tests already conducted with these fixes

All of the following were manually tested on this branch and verified working (where a behavior was broken before, the old broken behavior is noted as "(before: ...)").

Darkroom — shortcut effects on gesture-backed toggles

  • Masks shape via shortcut, plain on effect: bind blend → shapes → ellipse to a key with effect "on" → a single shape is created, and continuous ("add multiple") creation does not start (before: the on shortcut was delivered as ctrl+click and started continuous creation).

  • Same button with ctrl-on: → continuous (add multiple) creation starts, as a real ctrl+click would.

  • off effect no longer swallowed: bind the same action with "off", arm the tool, press it → the press is acted on instead of being dropped (before: it was delivered as a right-click and the primary-button guard discarded it).

  • Ashift structure-auto: bind structure → auto with "on" → plain auto-tune runs (before: behaved as ctrl+click); with "ctrl-on" → the ctrl variant runs.

  • Scroll-bound shortcut on a gesture toggle: assign a scroll gesture to a gesture-backed toggle → scroll up activates it once (before: acted as ctrl+click); scroll down is no longer silently ignored (before: dropped).

Darkroom — hold-key shortcuts

  • Use a picker tool, like the one in white balance module, which spawns a square over the image to pick an area. With this square visible, hold a then wheel scroll, hold a (force pan/zoom/rotate), release, then scroll the wheel over the image → zoom works normally (before: the stale key made the scroll flip "skip mouse events" and zoom stopped working).

  • Same with w (full preview): hold, release, scroll → normal zoom.

  • Hold + drag while held: holding a and dragging still pans/zooms (move routing during the hold is unchanged); after release, scrolling zooms normally again.

  • Hold-key double/triple press still detected: a fast second press of a hold-bound key within the double-click window still fires the double-press shortcut (the feature from gtk4-prep: fix color picker shortcuts and double-press on hold keys #21733 is preserved).

  • Memory: hold/release a and scroll repeatedly for ~30 s → RSS stays flat.

Darkroom — standalone color pickers (the eight)

  • rgbcurve and colorzones pickers via shortcut: bind rgbcurve → pickers → show color and colorzones → pickers → create curve to keys and press → the picker arms (pointer changes / clicking on the image samples) and the button renders active (before: the button lit up but no picker instance was armed).

  • retouch → pick fill color, temperature → from image area, blend → pickers → show color / set range: same — the shortcut arms the picker.

  • Toggle off: press the same shortcut again → the picker deactivates and the button state follows.

Darkroom — liquify tool shortcuts

  • Liquify tools via shortcut: bind a liquify tool (e.g. shapes → draw curves) with "ctrl-on" → the tool activates with continuous drawing enabled; with the plain effect → plain (non-continuous) tool selection.

Darkroom — preset-scroll dampener

  • Two modules: scroll module A's presets one way, then within 250 ms the opposite way over module B's presets → B's step applies (before: the process-global state swallowed it).

  • Clicky wheel reversal: scroll up then quickly down over one presets arrow → both steps apply immediately (the dampener now only applies to smooth/trackpad scrolls).

  • Trackpad boundary: smooth-scroll to the first preset and keep swiping past it → stays on the first preset, no 1↔2 oscillation or (first)/(last) toast spam; a pause-then-reverse scroll still moves normally.

  • Multi-step swipe: a fast trackpad scroll that emits |delta| ≥ 2 in one event advances by two presets, not one.

Collections — day/time rules (the contested fix)

  • Shift-range: set the rule to day, click one row, shift-click another → the rows in between are selected and the rule becomes a range [a;b], ordered by the sort direction (before: the range was computed from an already-mutated selection and came out wrong).

  • Ctrl-click: on the day rule, ctrl-click a row → single-select, and the clicked row stays selected (before: the native gesture ran second and could leave nothing selected).

  • Folder/film-roll rules: click a folder row → the collection filters to it; shift/ctrl-click doesn't extend (single-select by design); right-click → the context menu opens at the pointer.

  • Double-click on a tree parent → expand/collapse, fired once.

  • Memory: click rows, shift-select ranges and open context menus for ~30 s → RSS stays flat.

General sanity

  • No GTK warnings/criticals during any of the above (-d common).

  • Full regression pass of the previous round's checklist (module header, curve editors, thumbtable/culling, bauhaus popup, second window, shortcuts dialog) — all still good.

@Arecsu

Arecsu commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Might be my impression and pure placebo, but running darktable with all these refactors and fixes feels cleaner

@kofa73

kofa73 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Important (accepted)

  • src/gui/accelerators.c:4593 — The hold-key press and release paths in the shortcut engine mutate the global shortcut state and schedule a delayed release without calling _interrupt_delayed_release(), so an already-armed delayed release is orphaned or resolves against the wrong shortcut state. — severity medium

    • Evidence:
      • src/gui/accelerators.c:4602 — The hold-engage branch now sets _last_time = time; _sc.key_device = id; _sc.key = key; and returns at line 4608 without ever calling _interrupt_delayed_release(); git show c2bf7c1ce:src/gui/accelerators.c line 4466-4475 shows the pre-change branch touched neither _sc nor _last_time. (precondition: a key bound to DT_ACTION_EFFECT_HOLD is pressed; impact: the global _sc identity used by an outstanding delayed release is silently replaced)
      • src/gui/accelerators.c:4621 — Every other new-key path in dt_shortcut_key_press() calls _interrupt_delayed_release(FALSE) (4621) or _interrupt_delayed_release(TRUE) (4625) before mutating _sc.key, which flushes the pending action first; the hold branch returns above both.
      • src/gui/accelerators.c:5038 — An ordinary quick mouse click arms _timeout_source: GDK_BUTTON_RELEASE calls _delay_for_double_triple(time, 0), whose _sc.click && darktable.control->enable_fallbacks break at 4724-4730 leaves passed_time < delay, so g_timeout_add() at 4743 schedules _button_release_delayed. (precondition: fallbacks enabled)
      • src/gui/accelerators.c:4494 — When that timer fires, _button_release_delayed() calls dt_shortcut_move(DT_SHORTCUT_DEVICE_KEYBOARD_MOUSE, 0, DT_SHORTCUT_MOVE_NONE, 1), which resolves the shortcut from the global _sc — now carrying the hold key — so the click's own shortcut is looked up as "hold key + click". (impact: the click-bound action does not run, or a different key+click shortcut runs instead)
      • src/gui/accelerators.c:4771 — The hold-release branch likewise calls _delay_for_double_triple(time, -1) without the _interrupt_delayed_release(TRUE) that the ordinary key-release branch performs at 4778, so _timeout_source = g_timeout_add(...) at 4743 overwrites a still-live source handle; the orphaned GSource can no longer be removed and later runs an extra _key_release_delayed() pass.
      • src/gui/accelerators.c:4770 — The hold-release path sets the single global _hold_release_pending flag before scheduling delayed cleanup.
      • src/gui/accelerators.c:4743 — The delayed-cleanup helper installs a new GLib timeout without first removing an existing _timeout_source.
      • src/gui/accelerators.c:4593 — The hold re-engagement branch dispatches ON and returns without calling _interrupt_delayed_release to cancel an earlier timeout. (precondition: the same hold key is re-pressed within the double-click window and has no double/triple-press binding)
      • src/gui/accelerators.c:4481 — When the old timeout runs, _key_release_delayed clears _sc whenever _pressed_keys is empty.
      • src/gui/accelerators.c:4598 — The re-engaged hold is stored in _hold_keys rather than _pressed_keys. (impact: the second hold remains active but its key no longer participates in move/scroll shortcut matching)
      • src/gui/accelerators.c:3810 — Shortcut matching rejects a candidate whose key differs from the key stored in the current shortcut state.
      • src/views/darkroom.c:3590 — The stock darkroom action for force pan/zoom/rotate registers the a key as a HOLD shortcut.
      • data/shortcutsrc:52 — The shipped x;double and t;double/t;triple bindings make _delay_for_double_triple break at src/gui/accelerators.c:4722 with passed_time < delay, so a plain tap of x or t arms _key_release_delayed; the hold-engage branch then replaces _sc.key before that timeout fires. (precondition: in darkroom, tap x or t for less than gtk-double-click-time, then press a or w within the remaining window; impact: the pending pass dispatches the hold key's own registered shortcut with effect HOLD, reaching the XOR default branch of _action_process_skip_mouse (src/views/darkroom.c:2677) or the full-preview exit, so the hold silently disengages while the key is still held)
    • Flagged by: Claude, Codex · independent Round-0 support: 2 of 3 · unanimously supported in debate

    Note on the fix under review: the new hold-release cleanup is real, but it is the only path in dt_shortcut_key_press()/dt_shortcut_key_release() that skips _interrupt_delayed_release(). A stock-default trigger exists (x/t double-press bindings vs. a/w hold bindings), so this is not a synthetic case.

Minor (accepted)

  • src/gui/accelerators.c:4696_hold_release_pending is left set when _delay_for_double_triple() returns through its stuck-key branch, so the next ordinary key release silently skips its shortcut dispatch. — severity low

    • Evidence:
      • src/gui/accelerators.c:4770dt_shortcut_key_release() sets _hold_release_pending = TRUE and then calls _delay_for_double_triple(time, -1), relying on that call to reach _key_release_delayed(), the only place that clears the flag on a normal path.
      • src/gui/accelerators.c:4696_delay_for_double_triple() returns early at else if(_break_stuck && !_sc.button) (4696-4701) before delay_func is selected or invoked, so neither _key_release_delayed() nor a timeout is scheduled and the flag stays TRUE. (precondition: _break_stuck was set TRUE by an earlier key press — line 4660: another key still in _pressed_keys and >2 s since _last_time — and the hold key was tapped within gtk-double-click-time)
      • src/gui/accelerators.c:4329_interrupt_delayed_release() only clears _hold_release_pending inside if(_timeout_source), and _timeout_source is 0 after that early return, so no later event clears the stale flag.
      • src/gui/accelerators.c:4472 — The next _key_release_delayed() reads the stale TRUE into hold_release and the guard if(!timed_out && !hold_release) at 4475 skips dt_shortcut_move(...). (impact: that key's single-press shortcut action is silently dropped)
      • src/gui/accelerators.c:4608 — The hold-engage branch returns before the _break_stuck assignment at 4660, so _break_stuck must have been set by a separate earlier ordinary key press made while another key was still in _pressed_keys and more than 2000 ms after _last_time. (precondition: a key left in _pressed_keys, a second ordinary key press setting _break_stuck, then a hold key released within gtk-double-click-time; impact: narrow trigger chain; one later key shortcut is dropped once)
    • Flagged by: Claude · independent Round-0 support: 1 of 3 · unanimously supported in debate
  • src/libs/collect.c:4106 — The unconditional CAPTURE-phase claim on the collections treeview suppresses the treeview's own press handling that the pre-migration handler let through by returning FALSE, so with the default single-click setting disabled a single click on a row's expander arrow no longer expands or collapses the node, because the replacement callback expands only for n_press >= 2. — severity low — ⚠ detail contested: the anchor line (collect.c:4106 vs :4107 vs :646); the mechanism itself is not disputed

    • Evidence for:
      • src/libs/collect.c:4106 — The gesture unconditionally claims the sequence in CAPTURE phase, preventing the treeview's internal handlers from expanding/collapsing nodes or handling expander-arrow clicks.
      • src/libs/collect.c:673 — The callback expands or collapses only for n_press >= 2 or d->singleclick, and plugins/lighttable/collect/single-click defaults to false (data/darktableconfig.xml.in:1871-1873), so in the default configuration a single press on the expander arrow reaches no expand/collapse path once the internal handling is denied. (impact: folder/tag trees can then only be expanded by double-click or keyboard, and a double-click also activates the row)
      • src/libs/collect.c:4101-4107 — The gesture is in GTK_PHASE_CAPTURE and claims every sequence in the begin handler, so the treeview's internal bubble-phase press handling (single-click expander toggle, focus grab, cursor placement) never runs; the pre-migration handler reached it because it returned FALSE for a plain single primary press with no modifier when singleclick is off and view_rule != MONTH. Folder/tag trees start collapsed (src/libs/collect.c:941). The in-tree precedent that CAPTURE+claim does suppress a widget's internal bubble gesture is src/gui/color_picker_proxy.c:284-298.
      • src/libs/tagging.c:3695 — The tagging dictionary treeview keeps a non-claiming BUBBLE-phase gesture (dt_gui_connect_click_all), so its native single-click expander handling still runs; the accelerators.c precedent cited in the collect.c comment differs because that pre-migration handler (_action_view_click) returned TRUE unconditionally. (impact: only collect.c converts a handler that had a FALSE fall-through into an unconditional capture-phase claim)
    • Evidence against (partial — corrected one of the two original premises):
      • The original second premise is wrong: GTK3 GtkGestureMultiPress does emit n_press >= 2, so double-click expansion at src/libs/collect.c:673 still runs. The claim does not depend on that premise; the capture-claim mechanism holds independently.
    • Flagged by: Gemini · independent Round-0 support: 1 of 3 · rejected by one seat in round 1, unanimously supported in round 2 after the false premise was stripped

    Note: this is a regression relative to the pre-migration handler's FALSE fall-through, i.e. introduced by the CAPTURE+claim fix in e6c6d2fcc7 that the author added to fix ctrl-click/shift-range. The two goals are compatible — claim selectively (modifier presses, MONTH rule, single-click mode) rather than unconditionally.

Contested

None — every issue reached unanimity among all three seats.

Unresolved

None.

Rejected (raised then dropped)

  • src/dtgtk/thumbtable.c:1477 — "Double-click actions in culling and thumbtable are unreachable because the GTK3 multi-press bridge was deleted, leaving them reliant on a GTK3 gesture that does not emit multi-press signals." (raised at severity high)
    • Raised because: the branch at src/dtgtk/thumbtable.c:1477 requires n_press == 2, and double-click zoom at src/dtgtk/culling.c:900 relies on the same gesture; the deleted dt_gui_connect_double_click bridge was read as evidence that the gesture never delivers it.
    • Dropped because: the premise is false. GDK3 delivers a plain GDK_BUTTON_PRESS for the second click of a double-click — documented order BUTTON_PRESS, BUTTON_RELEASE, BUTTON_PRESS, 2BUTTON_PRESS, BUTTON_RELEASE (/usr/include/gtk-3.0/gdk/gdkevents.h:227-229 and 721-733, GTK 3.24.52 in this environment). GtkGestureMultiPress counts those plain presses itself and emits pressed with n_press=2, which is precisely why the removed bridge additionally re-fired the callback on every double click. src/dtgtk/thumbtable.c:1469 and src/dtgtk/culling.c:919 therefore still receive n_press == 2; the bridge's removal fixed duplicate events. Separately, src/gui/gtk.c:4834 shows dt_gui_connect_click itself calls gtk_gesture_single_set_button(gesture, 0), so reducing dt_gui_connect_click_all to a plain alias does not restrict either handler to the primary button.
    • Rejected unanimously by all three seats in round 1.

Merged

  • "Engaging a hold shortcut overwrites the global shortcut state (_sc.key/_sc.key_device/_last_time) without cancelling an already-armed delayed release, so the pending release resolves against the hold key instead of the key/button that armed it." (raised at severity low) → folded into i1 at Round-0 clustering — same code path (the hold branch of dt_shortcut_key_press(), 4593-4608) and same failure mechanism (a still-armed delayed release surviving a _sc mutation) as the independently raised "a hold-key release can leave an old delayed cleanup active, allowing a rapid re-press to have that cleanup clear the shortcut state for the newly active hold" (raised at severity medium). All twelve evidence points from both raisers are preserved separately above; only the two claims were unified.

@Arecsu

Arecsu commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@kofa73 I appreciate you taking your time to do that. However, it feels like it's going to be a constant loop of going through it, me revisiting these things, the tool you're using to review it again, and back and forth. What if the same tooling you're using is also capable of doing those fixed and submitting it as commits, patches or something? My time is getting severely limited and although I'm progressing with this, these write ups are just too much to handle. To me it makes sense that given the time it took them to revisit the code, the source code of gtk to fact check, etc, a patch is like a small ask for them at the end

@kofa73

kofa73 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

The tool that runs the reviews is Claude Code, so sure, I could have it fix issues. However, I'm really uncomfortable with submitting code "fixing" issues in code I'm not familiar with, especially if I'm also unfamiliar with the technology (UI in general). I would only be able to "review" the fixes by running the agents again, and looping locally, and then still submitting code I personally understand very little about. So, please forgive me that I will not do that. I'm happy to run the reviews for you, if you wish, and sharing the results; you can also run the reviews yourself, the tool is at https://github.com/kofa73/panel-review - reusability was not really a concern, so you would probably have to fight a bit to get it running. Claude Code is a must, and at least one of Codex CLI and Antigravity CLI are required (+ subscriptions or API access, the latter of which would be prohibitively expensive for us mere mortals). Alternatively, I can send you a review skill for any coding agent that has been sharpened on darktable commits from people fixing bugs, you can run that with any coding agent and model of your choice.

Mention me in a commit if you would like me to run a review, at any time. I'll try to do it as soon as I can.

@Arecsu

Arecsu commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

I've been experimenting with similar tools for the last year or so. They are getting better indeed (both tooling and the intelligence of the LLMs), but they still tend to hallucinate and confabulate stuff, no matter if they are discussing between them and so on. I'm doing this with a combination of my own workflow, skills and intuition I gained with using them, specifically Deepseek Flash v4 and Pi.

Darktable's codebase, from what I've been experiencing, it's pretty fragile. We got modules that use different kinds of picker tools for instance, with different behaviors, and different bugs will trigger one and the other. The mask ellipse tools have some bugs different when applied from the mask manager module at the left vs inside a module drawn mask directly. The scrolling issues that sometimes are OS specific issues due to GTK3 incompleteness or deprecated system APIs. To just name a few.

When you got a complex codebase like this one, and scenarios like these, the ideal move is just one LLM vs deterministic tests they can loop on. This is the idea (link to comment) I proposed at #20433. Well, this works wonders whether is a complex or simple codebase. But still, it is even more true in complex codebases when compared to just looping agents through the code. Not even a simple human would be able to do this in a reasonable amount of time (considering only spare time) IMO, while new features keep getting incorporated and more code dump into all this code debt.

My idea, and please maybe I'm wrong, is the following:

  • Getting Darktable to compile to GTK4 as good as we can, without memory leaks, performance or UI UX regressions. Possibly with some amount of code still with repetition patterns (e.g. repeated functions that would have same input outputs but take different paths), non optimal code, etc.
  • Develop a good suite of automated tests within Darktable. It will take quite some time. Of course, an LLM will help wonders on this. But there's going to be also a good chunk of human involvement in making sure the tests are well done and actually triggering the target functionality properly. How exactly? I'm not sure, I haven't looked into how GTK4 can be automatically tested. But I'm pretty sure it will be a combination of accessibility tools, traces and calls to functions simulating touches and clicks in a headless mode, then reading the output and comparing, automated screenshot comparisons for things that might otherwise be hard to parse (like histograms with a reference image, or things like that), etc. Of course, this test suite should be also accompanied with performance and memory regression instrumentation across the code. The more deterministic tests and traces we can develop, the better we are.
  • This way, it becomes less scary to accept new features, debug problems, iterate through code, do huge refactors, improve the UI, trying new things does not become a whack-a-mole game. LLMs can do wonders when given real results from the actual APP running when it comes to what exactly is the problem. Otherwise, it turns into this "guessing" game, not just for them, but also we as developers. I don't know about you all, but speaking for me, when I change something in Darktable's code, even if I go through the laborious process of testing that feature I'm changing, I'm yet not completely assured I didn't break something else unfortunately. It helps the LLMs to give them the source code of GTK3, GTK4, any other documentation at all. But it is ultimately insufficient to assess correctly the problems, let alone solutions sometimes. It won't matter how many agents are running in a loop, and how state of the art they are. If you give just one of them deterministic results to loop through, it will give far more precise and wonderful results. That's been part of my process when doing these fixes, adding logs all over the place, compiling darktable, running these tests manually, letting the LLM know what exactly did I do and when a certain log has been triggered, what would be the expected result, and what ended up happening, having all documentation and source code of libraries at hand, etc. But it does become pretty exhausting to be honest.

@kofa73

kofa73 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

I agree with you on tests. For processing modules (pure algorithms), it's much easier to do (that's why I'm in backend development ;-) ). For the UI, not so.
And yes, a constant flux of new features, without heroic maintenance work like you (and some other people) do, can turn into a threat. So I thank you for your contributions! For my part, I only do small things: stuff I can understand, plus running the reviews if requested.

@anoderay

anoderay commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Did some testing with a compiled AppImage from your branch with native wayland: AgX Pickers fixed (#21730), didn't find any new issues in the darkroom UI.

Only one minor nitpick is the styles buttons "hover state" still being "sticky". Although they behave suboptimally in 5.6 (and probably before that), too.

This PR:
Bildschirmaufnahme_20260807_220632.webm

5.6:
Bildschirmaufnahme_20260807_220438.webm
(I'd expect a button to retain its "active state" as long as the submenu is displayed)

Arecsu added 2 commits August 7, 2026 18:20
- dt_shortcut_key_press() now flushes a still-armed delayed release
  before the hold takes over _sc: the pending pass requires
  c->key == s->key, so left armed it would resolve against the hold
  key and re-run the hold action, silently disengaging the hold while
  the key is still held.  A same-key re-engage inside the window is
  that pending pass's own double-press check and is only cancelled.
- dt_shortcut_key_release() cancels a still-armed delayed release
  before scheduling the hold cleanup, mirroring the ordinary key
  release, so g_timeout_add() cannot orphan a live source handle.
- _delay_for_double_triple()'s stuck-key early return drops the
  _hold_release_pending flag and the stale hold key, or the next
  ordinary key release would silently skip its own dispatch.
- _key_release_delayed() restores the identity of a still-engaged
  hold key when _pressed_keys is empty but _hold_keys is not,
  keeping move/scroll routing alive for the held key.
- the CAPTURE-phase gesture claims selectively (modifier presses,
  single-click mode, MONTH rule, folder/filmroll right-click) instead
  of unconditionally, so a plain click reaches the treeview's internal
  gesture again: single-click expander toggles, focus grab and cursor
  placement work as before the migration.
- a plain primary double-click on the expander arrow claims only the
  second press and skips the module's own toggle: the internal gesture
  toggles on the first release, giving exactly one toggle with no
  expand/collapse flicker, while longer click bursts keep responding.
- shift+ctrl+click on a folder/filmroll row activates again, making
  the folders<->filmrolls view switch reachable.
…close

GTK3 synthesizes a fake enter crossing (GDK_CROSSING_GTK_UNGRAB) on every
widget a grab was shadowing when the grab ends -- menus, popovers, modal
dialogs -- so the button's internal enter handler marks the pointer as
inside and the button keeps its hover state even after the pointer has
left, only clearing on the next genuine crossing.

Connect a grab-notify handler on dtgtk buttons that drops the stale
hover/pressed flags when the grab ends and the pointer is not over the
button.

The lib module reset button additionally ran its action from a custom
gesture's press, so the confirm dialog's grab split the button's press
from its release (the press was processed after the dialog closed, the
release was consumed by the dialog's grab), leaving the button stuck in
its pressed state; wiring the action to the button's own clicked signal
keeps the press/release cycle intact, and also avoids the cancel
forwarding in dt_gui_connect_click() firing the reset twice.
@Arecsu

Arecsu commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@anoderay should be fixed with the last commit. The history reset button from the module in the left sidebar at darkroom was also getting stuck, and sometimes won't even show the confirmation dialog.

This is sort of a hack to make it work in GTK3 properly. Maybe it even gets rid of some other bugs as you said that it was behaving sub-optimally. Check it if you can please!

GTK4 is just much better at handling these things without surprises nor hacks. We have to live with this code (I commented around it that it needs to be removed) in the meantime while we still support gtk3.

@Arecsu

Arecsu commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@TurboGit I believe this pr is ready to be merged, I exhausted my research for further fixes and improvements, at least for now, along with the suggestions from Kofa, and so far so good. At least the master branch will benefit of so many fixes going along with it at its current state. To me, revisiting the code over and over again, can't spot anything out of the ordinary. If you could review it, would be neat

@anoderay

anoderay commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

@Arecsu Sticky buttons are gone. But I did now notice one more thing:

In 5.6 and before when selecting multiple image and hovering over one of them, then e.g. rejecting, deleting, moving etc. will act on the whole selection. This makes sense IMHO.

Currently even when selecting multiple images only the hovered image is acted upon when rating/rejecting and moving (moving when triggered by shortcut); for color lables the behaviour is still as before.

5.6:
Bildschirmaufnahme_20260808_080130.webm

Current:
Bildschirmaufnahme_20260808_080236.webm

@anoderay

anoderay commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

I think you may have also fixed #20018 . In 5.7+135 this happens very often (but inconsistently) for me, but I cannot provoke it yet in the current state.

@Arecsu

Arecsu commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Ahhhh the issue with the hover selecting images that's on me, I was playing with that part of the code two days ago to make proper keyboard priority selection to work (#21385) and a whole can of worms spawned with it.

Your videos and details are incredibly important in debugging that one, will check!

And about the other issue fixed, glad you found out about it. Even more so, there might be other reports that might have been solved by now because I've been encountering stuff that suddenly started to work better for some reason along the way, although I don't recall exactly what. It's been such a monumental task that I'm forgetting plenty of things at this point

The shortcut machinery grabs the pointer on every key press
(gdk_seat_grab()); the synthetic crossings that the grab/ungrab dance
generates reach the thumbtable and culling leave handlers, which cleared
mouse_inside unconditionally -- the darktable-org#21734 hovered-image fix
(2f24f86) only guarded the mouse_over_id clear.  With mouse_inside
FALSE, _cache_update_hover() skips the "hovered image is inside the
selection" check (column 1) and acts on the hovered image only, so
rating, rejecting, moving and color labels hit just the hovered thumbnail
instead of the whole selection for every shortcut press until the pointer
moves.

Clear mouse_inside only on real leaves (same guard as mouse_over_id: not
an inferior/grab crossing, and not while the pointer is grabbed), and
restore it in the enter handler so the state self-heals.
@Arecsu

Arecsu commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

@anoderay Yannic, you can test the lighttable and film strip at the bottom once again, it should be working as intended, be it with hover priority setting or without it!

@anoderay

anoderay commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

@Arecsu looks good now, thanks!

@TurboGit TurboGit added this to the 5.8 milestone Aug 8, 2026
@TurboGit TurboGit added the gtk4 label Aug 8, 2026

@TurboGit TurboGit left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@TurboGit
TurboGit merged commit 4f63c2e into darktable-org:master Aug 8, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

4 participants