From f59977000039935c96ba471ac0eef78299eb3722 Mon Sep 17 00:00:00 2001 From: Arecsu Date: Fri, 21 Aug 2026 23:27:11 -0300 Subject: [PATCH 1/2] gui: fix cursor ownership across GTK3 widgets --- src/control/control.c | 30 ++++++++-- src/dtgtk/range.c | 11 ++-- src/gui/gtk.c | 121 ++++++++++++++++++++++++++------------ src/gui/gtk.h | 10 +++- src/libs/tools/timeline.c | 10 ++-- 5 files changed, 130 insertions(+), 52 deletions(-) diff --git a/src/control/control.c b/src/control/control.c index 017000a49b7..0b11164fc5d 100644 --- a/src/control/control.c +++ b/src/control/control.c @@ -286,9 +286,9 @@ void dt_control_cursor_debug(const char *owner, static void _change_cursor_with_fallback(const char *cursor_name, gboolean is_temp, - const char *owner) + const char *owner, + GtkWidget *widget) { - GtkWidget *widget = dt_ui_main_window(darktable.gui->ui); if(!widget) return; GdkCursor *cursor = dt_gui_cursor_new_for_name(gtk_widget_get_display(widget), cursor_name); @@ -327,7 +327,7 @@ void dt_control_set_temp_cursor(const char *cursor_name) if(_prev_cursor) g_object_ref(_prev_cursor); } - _change_cursor_with_fallback(cursor_name, TRUE, "control/temp"); + _change_cursor_with_fallback(cursor_name, TRUE, "control/temp", widget); } void dt_control_clear_temp_cursor() @@ -352,9 +352,31 @@ void dt_control_clear_temp_cursor() _prev_cursor = NULL; } +/* This is the shared, application-wide cursor: it has to stay on the toplevel. + * + * Its callers are of three kinds, and only the toplevel window is under the + * pointer for all three: whole-application states (view switching, startup, + * leaving shortcut-mapping mode), on-canvas interaction (crop, ashift, + * clipping, the darkroom view itself) and a handful of panel widgets that have + * not been migrated yet. + * + * Aiming it at dt_ui_center() instead splits the target in two, because the + * code that sets a cursor and the code that clears it then write different + * GdkWindows: _set_mapping_mode_cursor() and dt_control_set_temp_cursor() + * apply to the toplevel, so their cursor is never cleared and stays stuck. + * There is no rectangle where the retargeted clear is even visible in + * lighttable, since the thumbtable is an overlay sibling of the centre canvas + * rather than a child of it, so the pointer is never over dt_ui_center(). + * + * A widget that owns its own interaction should call dt_gui_cursor_set() with + * its own widget rather than come through here -- that is what the panel + * handles, the range selector, the timeline and the resize wrappers now do, + * and it is also what stops a child control from claiming the toplevel cursor. + */ void dt_control_change_cursor(const char *cursor_name) { - _change_cursor_with_fallback(cursor_name, FALSE, "control/change"); + _change_cursor_with_fallback(cursor_name, FALSE, "control/change", + dt_ui_main_window(darktable.gui->ui)); } /* Some implementation and how-to use notes about control->running diff --git a/src/dtgtk/range.c b/src/dtgtk/range.c index 8f617eb9620..2540c050817 100644 --- a/src/dtgtk/range.c +++ b/src/dtgtk/range.c @@ -1509,6 +1509,7 @@ void dtgtk_range_select_redraw(GtkDarktableRangeSelect *range) static void _event_band_motion_cb(GtkEventControllerMotion *controller, double x, double y, GtkDarktableRangeSelect *range) { + GtkWidget *widget = dt_gui_get_widget(controller); range->current_x_px = x - range->alloc_padding.x; // if we are outside the graph, don't go further @@ -1516,7 +1517,7 @@ static void _event_band_motion_cb(GtkEventControllerMotion *controller, double x if(!inside) { range->mouse_inside = HOVER_OUTSIDE; - dt_control_change_cursor("default"); + dt_gui_cursor_set(widget, NULL, "range-select"); _current_hide_popup(range); return; } @@ -1538,19 +1539,19 @@ static void _event_band_motion_cb(GtkEventControllerMotion *controller, double x && fabs(range->current_x_px - smin_px) <= SNAP_SIZE) { range->mouse_inside = HOVER_MIN; - dt_control_change_cursor("w-resize"); + dt_gui_cursor_set(widget, "w-resize", "range-select"); } else if(range->allow_resize && !range->set_selection && fabs(range->current_x_px - smax_px) <= SNAP_SIZE) { range->mouse_inside = HOVER_MAX; - dt_control_change_cursor("e-resize"); + dt_gui_cursor_set(widget, "e-resize", "range-select"); } else { range->mouse_inside = HOVER_INSIDE; - dt_control_change_cursor("default"); + dt_gui_cursor_set(widget, NULL, "range-select"); } gtk_widget_queue_draw(range->band); } @@ -1558,7 +1559,7 @@ static void _event_band_motion_cb(GtkEventControllerMotion *controller, double x static void _event_band_leave_cb(GtkEventControllerMotion *controller, GtkDarktableRangeSelect *range) { range->mouse_inside = HOVER_OUTSIDE; - dt_control_change_cursor("default"); + dt_gui_cursor_set(dt_gui_get_widget(controller), NULL, "range-select"); _current_hide_popup(range); gtk_widget_queue_draw(range->band); diff --git a/src/gui/gtk.c b/src/gui/gtk.c index 66a53762895..fd34542704e 100644 --- a/src/gui/gtk.c +++ b/src/gui/gtk.c @@ -3306,13 +3306,15 @@ static void _panel_handle_button_released(GtkGestureSingle *gesture, static void _panel_handle_cursor_set(GtkWidget *handle, const gboolean entering) { // GTK produces a lot of GDK_NOTIFY_ANCESTOR when dragging handle, - // but we only care about events when enter/leave the drag region + // but we only care about events when enter/leave the drag region. if(darktable.gui->widgets.panel_handle_dragging) return; - if(strcmp(gtk_widget_get_name(handle), "panel-handle-bottom") == 0) - dt_control_change_cursor(entering ? "ns-resize" : "default"); - else - dt_control_change_cursor(entering ? "ew-resize" : "default"); + + const gboolean bottom = + strcmp(gtk_widget_get_name(handle), "panel-handle-bottom") == 0; + dt_gui_cursor_set(handle, + entering ? (bottom ? "ns-resize" : "ew-resize") : NULL, + "panel-handle"); } static void _panel_handle_cursor_enter(GtkEventControllerMotion *controller, @@ -4898,21 +4900,43 @@ static gboolean _scroll_wrap_height_controller(GtkEventControllerScroll *control } #endif -static gboolean _resize_wrap_dragging = FALSE; -static gboolean _resize_wrap_handle_hover = FALSE; -static GtkWidget *_resize_wrap_hovered = NULL; +#define DT_RESIZE_WRAP_STATE_KEY "dt-resize-wrap-state" + +typedef struct dt_resize_wrap_state_t +{ + gboolean dragging; + gboolean handle_hover; + gboolean pointer_inside; +} dt_resize_wrap_state_t; + +static dt_resize_wrap_state_t *_resize_wrap_state(GtkWidget *widget) +{ + return g_object_get_data(G_OBJECT(widget), DT_RESIZE_WRAP_STATE_KEY); +} + +static void _resize_wrap_set_handle_hover(GtkWidget *widget, + dt_resize_wrap_state_t *state, + const gboolean handle_hover) +{ + if(state->handle_hover == handle_hover) return; + state->handle_hover = handle_hover; + dt_gui_cursor_set(widget, + handle_hover ? "ns-resize" : NULL, + "resize-wrap/handle"); +} static gboolean _resize_wrap_draw_handle(GtkWidget *w, void *cr, gpointer user_data) { - if(w != _resize_wrap_hovered) + dt_resize_wrap_state_t *state = _resize_wrap_state(w); + if(!state || !state->pointer_inside) return FALSE; GtkAllocation allocation; gtk_widget_get_allocation(w, &allocation); - set_color(cr, _resize_wrap_handle_hover + set_color(cr, state->handle_hover ? darktable.bauhaus->color_fg_hover : darktable.bauhaus->color_fg_insensitive); cairo_move_to(cr, allocation.width / 8 * 3, @@ -4925,18 +4949,20 @@ static gboolean _resize_wrap_draw_handle(GtkWidget *w, return FALSE; } -/* controller version: the drag uses the controller-relative y, the hover - * check keeps the old window comparison via the current event (BUBBLE phase - * delivers motions over child widgets too, as the old signal did) */ +/* Keep resize state per wrapper. Multiple metadata rows can contain resize + * wrappers at once; shared hover/drag state lets one row clear another row's + * cursor. */ static void _resize_wrap_motion_controller(GtkEventControllerMotion *controller, gdouble x, gdouble y, gpointer user_data) { GtkWidget *widget = dt_gui_get_widget(controller); + dt_resize_wrap_state_t *state = _resize_wrap_state(widget); const char *config_str = user_data; + if(!state) return; - if(_resize_wrap_dragging) + if(state->dragging) { // keeps resize box from shrinking when user clicks above very // bottom of handle @@ -4956,20 +4982,15 @@ static void _resize_wrap_motion_controller(GtkEventControllerMotion *controller, return; } - const gboolean prior = _resize_wrap_handle_hover; GdkEvent *event = dt_gui_get_current_event(GTK_EVENT_CONTROLLER(controller)); if(!(dt_gui_get_current_event_state(GTK_EVENT_CONTROLLER(controller)) & GDK_BUTTON1_MASK) && (!event || dt_gdk_event_get_window(event) == gtk_widget_get_window(widget))) { - _resize_wrap_handle_hover = + const gboolean handle_hover = y >= gtk_widget_get_allocated_height(widget) - DT_RESIZE_HANDLE_SIZE; - if(_resize_wrap_handle_hover != prior) + if(handle_hover != state->handle_hover) { - if(_resize_wrap_handle_hover) - dt_control_set_temp_cursor("ns-resize"); - else - dt_control_clear_temp_cursor(); - // draw changed handle hover state + _resize_wrap_set_handle_hover(widget, state, handle_hover); gtk_widget_queue_draw(widget); } } @@ -4985,10 +5006,19 @@ static void _resize_wrap_button_pressed(GtkGestureSingle *gesture, gpointer user_data) { GtkWidget *widget = dt_gui_get_widget(gesture); + dt_resize_wrap_state_t *state = _resize_wrap_state(widget); + if(!state) return; if(y >= gtk_widget_get_allocated_height(widget) - DT_RESIZE_HANDLE_SIZE && gtk_gesture_single_get_current_button(gesture) == GDK_BUTTON_PRIMARY) { - _resize_wrap_dragging = TRUE; + state->dragging = TRUE; + // go through the hover setter rather than setting the cursor directly, so + // that handle_hover records the cursor now on the widget. The release path + // only clears it when it sees handle_hover change back to FALSE, so a + // press that arrives without a preceding hover motion -- the motion + // handler ignores events coming from a child window -- would otherwise + // leave "ns-resize" on the widget for good. + _resize_wrap_set_handle_hover(widget, state, TRUE); } } @@ -4998,11 +5028,15 @@ static void _resize_wrap_button_released(GtkGestureSingle *gesture, gdouble y, gpointer user_data) { - if(_resize_wrap_dragging) - { - _resize_wrap_dragging = FALSE; - dt_control_clear_temp_cursor(); - } + GtkWidget *widget = dt_gui_get_widget(gesture); + dt_resize_wrap_state_t *state = _resize_wrap_state(widget); + if(!state || !state->dragging) return; + + state->dragging = FALSE; + _resize_wrap_set_handle_hover + (widget, state, + y >= gtk_widget_get_allocated_height(widget) - DT_RESIZE_HANDLE_SIZE); + gtk_widget_queue_draw(widget); } static void _resize_wrap_enter_leave_controller(GtkEventControllerMotion *controller, @@ -5028,21 +5062,29 @@ static void _resize_wrap_enter_leave_controller(GtkEventControllerMotion *contro #endif } - _resize_wrap_hovered = - is_enter || detail == GDK_NOTIFY_INFERIOR || _resize_wrap_dragging ? widget : NULL; - - // When leave handle and widget, remove temp resize cursor. When - // enter widget, motion event will handle cursor change for handle. - if(!is_enter && !_resize_wrap_dragging && _resize_wrap_handle_hover) + dt_resize_wrap_state_t *state = _resize_wrap_state(widget); + if(!state) { - dt_control_clear_temp_cursor(); - _resize_wrap_handle_hover = FALSE; +#if !GTK_CHECK_VERSION(4, 0, 0) + if(event) gdk_event_free(event); +#endif + return; } + state->pointer_inside = + is_enter || detail == GDK_NOTIFY_INFERIOR || state->dragging; + + if(!is_enter && !state->dragging) + _resize_wrap_set_handle_hover(widget, state, FALSE); + gtk_widget_queue_draw(widget); - if(mode == GDK_CROSSING_GTK_UNGRAB) - _resize_wrap_dragging = FALSE; + if(mode == GDK_CROSSING_GTK_UNGRAB && state->dragging) + { + state->dragging = FALSE; + state->pointer_inside = FALSE; + _resize_wrap_set_handle_hover(widget, state, FALSE); + } #if !GTK_CHECK_VERSION(4, 0, 0) if(event) gdk_event_free(event); #endif @@ -5111,6 +5153,9 @@ GtkWidget *dt_ui_resize_wrap(GtkWidget *w, gtk_container_add(GTK_CONTAINER(w), sw); } + g_object_set_data_full(G_OBJECT(w), DT_RESIZE_WRAP_STATE_KEY, + g_new0(dt_resize_wrap_state_t, 1), g_free); + gtk_widget_add_events(w, GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_ENTER_NOTIFY_MASK | GDK_LEAVE_NOTIFY_MASK | GDK_POINTER_MOTION_MASK | darktable.gui->scroll_mask); diff --git a/src/gui/gtk.h b/src/gui/gtk.h index 64b6a04aced..0a276401df0 100644 --- a/src/gui/gtk.h +++ b/src/gui/gtk.h @@ -772,7 +772,15 @@ gboolean dt_gui_osx_edit_command(GtkWidget *focus, GdkEvent *event); /* Cursor compatibility boundary. GTK3 applies cursors to the widget's * backing window (using a TreeView's bin window when needed); GTK4 applies - * them to the widget itself. The owner string is used by -d input tracing. */ + * them to the widget itself. The owner string is used by -d input tracing. + * + * A NULL cursor_name means "inherit whatever the parent shows", in both GTK3 + * and GTK4, and that is how a widget-local cursor should be undone. Passing + * "default" instead pins a real arrow on the widget, which then hides the + * cursor the toplevel is showing -- the busy/wait cursor, or shortcut-mapping + * and help mode. Pass "default" only where overriding the toplevel is the + * point, as src/libs/backgroundjobs.c does so that the busy cursor does not + * cover the job's cancel box. */ GdkCursor *dt_gui_cursor_new_for_name(GdkDisplay *display, const char *cursor_name); GdkCursor *dt_gui_cursor_get(GtkWidget *widget); void dt_gui_cursor_apply(GtkWidget *widget, GdkCursor *cursor); diff --git a/src/libs/tools/timeline.c b/src/libs/tools/timeline.c index 94c1ab8f852..c3c4500922b 100644 --- a/src/libs/tools/timeline.c +++ b/src/libs/tools/timeline.c @@ -1268,6 +1268,7 @@ static void _lib_timeline_motion_notify_cb(GtkEventControllerMotion *controller, gdouble y, dt_lib_module_t *self) { + GtkWidget *widget = dt_gui_get_widget(controller); dt_lib_timeline_t *strip = self->data; strip->in = TRUE; @@ -1289,22 +1290,22 @@ static void _lib_timeline_motion_notify_cb(GtkEventControllerMotion *controller, { strip->stop_x = x; strip->stop_t = _time_get_from_pos(x, strip); - dt_control_change_cursor("default"); + dt_gui_cursor_set(widget, NULL, "timeline"); } else { // we change the cursor if we are close enough of a selection limit if(x - strip->start_x < 2 && x - strip->start_x > -2) { - dt_control_change_cursor("w-resize"); + dt_gui_cursor_set(widget, "w-resize", "timeline"); } else if(x - strip->stop_x < 2 && x - strip->stop_x > -2) { - dt_control_change_cursor("e-resize"); + dt_gui_cursor_set(widget, "e-resize", "timeline"); } else { - dt_control_change_cursor("default"); + dt_gui_cursor_set(widget, NULL, "timeline"); } } gtk_widget_queue_draw(strip->timeline); @@ -1374,6 +1375,7 @@ static void _lib_timeline_mouse_leave_cb(GtkEventControllerMotion *controller, dt_lib_timeline_t *strip = self->data; strip->in = FALSE; + dt_gui_cursor_set(dt_gui_get_widget(controller), NULL, "timeline"); gtk_widget_queue_draw(strip->timeline); } From 40b1924b361d40f1059eb0abe7f80841d8614dd2 Mon Sep 17 00:00:00 2001 From: Arecsu Date: Sat, 22 Aug 2026 12:50:17 -0300 Subject: [PATCH 2/2] gui: route equalizer cursors through compatibility helpers --- src/iop/toneequal.c | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/iop/toneequal.c b/src/iop/toneequal.c index 8ac4caaf3dd..ec70487e4e4 100644 --- a/src/iop/toneequal.c +++ b/src/iop/toneequal.c @@ -1977,7 +1977,6 @@ static void switch_cursors(dt_iop_module_t *self) { // display default cursor dt_control_change_cursor("default"); - return; } @@ -2009,7 +2008,6 @@ static void switch_cursors(dt_iop_module_t *self) // if module is active and opened but cursor is out of the preview, // display default cursor dt_control_change_cursor("default"); - dt_control_queue_redraw_center(); } else