From e2db5fbf639026b065c6fa2f7d080163fe1a20f4 Mon Sep 17 00:00:00 2001 From: Philipp Lutz Date: Sat, 16 May 2026 22:02:40 +0200 Subject: [PATCH 1/3] Revert "Fix zoom freezing at named stops" This reverts commit b04e36ed68c379ae42cb88d57ec3b1624718d059. --- src/develop/develop.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/develop/develop.c b/src/develop/develop.c index 2bbe4998dd0a..94c480925b51 100644 --- a/src/develop/develop.c +++ b/src/develop/develop.c @@ -2890,9 +2890,9 @@ static float _calculate_new_scroll_zoom_tscale(const int up, { case SIZE_LARGE: tscalemax = constrained - ? (tscaleold >= 2.0f + ? (tscaleold > 2.0f ? tscaletop - : (tscaleold >= 1.0f ? 2.0f : 1.0f)) + : (tscaleold > 1.0f ? 2.0f : 1.0f)) : tscaletop; tscalemin = constrained ? (tscaleold < tscalefit @@ -2902,7 +2902,7 @@ static float _calculate_new_scroll_zoom_tscale(const int up, break; case SIZE_MEDIUM: tscalemax = constrained - ? (tscaleold >= 2.0f + ? (tscaleold > 2.0f ? tscaletop : 2.0f) : tscaletop; @@ -2914,7 +2914,7 @@ static float _calculate_new_scroll_zoom_tscale(const int up, break; case SIZE_SMALL: tscalemax = constrained - ? (tscaleold >= 2.0f + ? (tscaleold > 2.0f ? tscaletop : tscalefit) : tscaletop; From 62af43279c5ae064f653b34f20f8bdffe02fc6c1 Mon Sep 17 00:00:00 2001 From: Philipp Lutz Date: Fri, 15 May 2026 01:57:53 +0200 Subject: [PATCH 2/3] Activate 100% zoom limit again * make scroll limit configurable through config parameter `darkroom/ui/constrain_zoom`, which is TRUE by default, keeping the previous behavior. * refactored the soft-step zooming logic in order to re-use it for pinch-zoom gestures. --- data/darktableconfig.xml.in | 11 +++- src/develop/develop.c | 108 +++++++++++++++++++++++------------- src/develop/develop.h | 1 + src/libs/navigation.c | 5 +- src/views/darkroom.c | 17 +++--- 5 files changed, 92 insertions(+), 50 deletions(-) diff --git a/data/darktableconfig.xml.in b/data/darktableconfig.xml.in index 6442022c909f..81f9355026fa 100644 --- a/data/darktableconfig.xml.in +++ b/data/darktableconfig.xml.in @@ -2433,12 +2433,19 @@ enabled: the mouse wheel scrolls the modules panel and with Ctrl+Alt adjusts a control's value\n\ndisabled: the mouse wheel adjusts the control under the pointer and with Ctrl+Alt scrolls the panel.]]> - + darkroom/ui/touchpad_gestures bool true enable touchpad gestures in darkroom - enabled: touchpad pinch gestures zoom the image and two-finger touchpad scrolling pans it; Ctrl+scroll still uses the legacy zoom behavior. disabled: touchpad gestures are ignored and darkroom falls back to the legacy scroll behavior, including Ctrl+scroll for zooming in and out.]]> + enabled: touchpad pinch gestures zoom the image and two-finger touchpad scrolling pans it; Ctrl+scroll still uses the legacy zoom behavior. disabled: touchpad gestures are ignored and darkroom falls back to the legacy scroll behavior, including Ctrl+scroll for zooming in and out. (restart required)]]> + + + darkroom/ui/constrain_zoom + bool + true + constrain darkroom zoom between screen fit and 100% + enabled: zoom is constrained; hold Ctrl while zooming to temporarily lift the limit and zoom beyond 100%. disabled: zoom is never constrained and the additional Ctrl key press is not needed to zoom beyond 100%. (restart required)]]> plugins/darkroom/ui/border_size diff --git a/src/develop/develop.c b/src/develop/develop.c index 94c480925b51..247805043697 100644 --- a/src/develop/develop.c +++ b/src/develop/develop.c @@ -151,6 +151,8 @@ void dt_dev_init(dt_develop_t *dev, dev->full.color_assessment = dt_conf_get_bool("full_window/color_assessment"); dev->preview2.color_assessment = dt_conf_get_bool("second_window/color_assessment"); + dev->constrain_zoom = dt_conf_get_bool("darkroom/ui/constrain_zoom"); + dev->full.zoom = dev->preview2.zoom = DT_ZOOM_FIT; dev->full.closeup = dev->preview2.closeup = 0; dev->full.zoom_x = dev->full.zoom_y = dev->preview2.zoom_x = dev->preview2.zoom_y = 0.0f; @@ -2847,11 +2849,20 @@ gboolean dt_dev_get_processed_size(dt_dev_viewport_t *port, return FALSE; } -static float _calculate_new_scroll_zoom_tscale(const int up, - const gboolean constrained, - const float tscaleold, - const float tscalefit) +// Compute the zoom soft limits for a given old tscale. +// Shared between scroll-step zoom and continuous (pinch) zoom so both honor +// the same constrain semantics: cap at 100%/200%/top depending on where the +// previous scale sits, with CTRL clearing `constrained` upstream as the +// escape hatch. +static void _zoom_constraint_bounds(const gboolean constrained, + const float tscaleold, + const float tscalefit, + float *tscalemin, + float *tscalemax) { + const float tscaletop = 16.0f; + const float tscalefloor = MIN(0.5f * tscalefit, 1.0f); + enum { SIZE_SMALL, SIZE_MEDIUM, @@ -2865,69 +2876,72 @@ static float _calculate_new_scroll_zoom_tscale(const int up, else image_size = SIZE_SMALL; - // at 200% zoom level or more, we use a step of 2x, while at lower level we use 1.1x - const float step = - up - ? (tscaleold >= 2.0f ? 2.0f : 1.1f) - : (tscaleold > 2.0f ? 2.0f : 1.1f); - - // we calculate the new scale - float tscalenew = up ? tscaleold * step : tscaleold / step; - - // when zooming, secure we include 2:1, 1:1 and FIT levels anyway in the zoom stops - if((tscalenew - tscalefit) * (tscaleold - tscalefit) < 0 && image_size != SIZE_SMALL) - tscalenew = tscalefit; - else if((tscalenew - 1.0f) * (tscaleold - 1.0f) < 0) - tscalenew = 1.0f; - else if((tscalenew - 2.0f) * (tscaleold - 2.0f) < 0) - tscalenew = 2.0f; - - float tscalemax, tscalemin; // the zoom soft limits - const float tscaletop = 16.0f; // the zoom hard limits - const float tscalefloor = MIN(0.5f * tscalefit, 1.0f); - - switch (image_size) // here we set the logic of zoom limits - { + switch(image_size) + { case SIZE_LARGE: - tscalemax = constrained + *tscalemax = constrained ? (tscaleold > 2.0f ? tscaletop : (tscaleold > 1.0f ? 2.0f : 1.0f)) : tscaletop; - tscalemin = constrained + *tscalemin = constrained ? (tscaleold < tscalefit ? tscalefloor : tscalefit) : tscalefloor; break; case SIZE_MEDIUM: - tscalemax = constrained + *tscalemax = constrained ? (tscaleold > 2.0f ? tscaletop : 2.0f) : tscaletop; - tscalemin = constrained + *tscalemin = constrained ? (tscaleold < tscalefit ? tscalefloor : tscalefit) : tscalefloor; break; case SIZE_SMALL: - tscalemax = constrained + *tscalemax = constrained ? (tscaleold > 2.0f ? tscaletop : tscalefit) : tscaletop; - tscalemin = tscalefloor; + *tscalemin = tscalefloor; break; - } + } +} + +static float _calculate_new_scroll_zoom_tscale(const int up, + const gboolean constrained, + const float tscaleold, + const float tscalefit) +{ + // at 200% zoom level or more, we use a step of 2x, while at lower level we use 1.1x + const float step = + up + ? (tscaleold >= 2.0f ? 2.0f : 1.1f) + : (tscaleold > 2.0f ? 2.0f : 1.1f); + + // we calculate the new scale + float tscalenew = up ? tscaleold * step : tscaleold / step; + + // when zooming, secure we include 2:1, 1:1 and FIT levels anyway in the zoom stops + const gboolean is_small = tscalefit > 2.0f; + if((tscalenew - tscalefit) * (tscaleold - tscalefit) < 0 && !is_small) + tscalenew = tscalefit; + else if((tscalenew - 1.0f) * (tscaleold - 1.0f) < 0) + tscalenew = 1.0f; + else if((tscalenew - 2.0f) * (tscaleold - 2.0f) < 0) + tscalenew = 2.0f; - // we enforce the zoom limits - tscalenew = up + float tscalemin, tscalemax; + _zoom_constraint_bounds(constrained, tscaleold, tscalefit, &tscalemin, &tscalemax); + + return up ? MIN(tscalenew, tscalemax) : MAX(tscalenew, tscalemin); - - return tscalenew; } static char *_transform_type(const dt_dev_transform_direction_t transf_direction) @@ -3105,13 +3119,13 @@ void dt_dev_zoom_move(dt_dev_viewport_t *port, else if(zoom == DT_ZOOM_SCROLL) { zoom = DT_ZOOM_FREE; - const float fitscale = dt_dev_get_zoom_scale(port, DT_ZOOM_FIT, 1.0, FALSE); + const float fitscale = dt_dev_get_zoom_scale(port, DT_ZOOM_FIT, 1, FALSE); const float tscaleold = cur_scale * ppd; const float tscale = _calculate_new_scroll_zoom_tscale (closeup, constrain, tscaleold, fitscale * ppd); scale = tscale / ppd; closeup = 0; - if(tscale < 1.9999) + if(tscale < 1.9999f) scale = tscale / ppd; else { @@ -3144,6 +3158,20 @@ void dt_dev_zoom_move(dt_dev_viewport_t *port, zoom_y = dev->full_preview_last_zoom_y; scale = port->zoom_scale; } + else if(zoom == DT_ZOOM_FREE && constrain) + { + // Continuous zoom (pinch): apply the same soft caps as scroll. Using + // the current scale as tscaleold means once we clamp at 100%, the next + // frame still sees tscaleold == 1.0 and stays clamped; if a prior CTRL + // frame lifted us past 1.0, the released-CTRL frame sees tscaleold > 1.0 + // and progresses up to the 200% cap — matching the scroll escape hatch. + const float fitscale = dt_dev_get_zoom_scale(port, DT_ZOOM_FIT, 1, FALSE); + const float tscaleold = cur_scale * ppd; + float tscalemin, tscalemax; + _zoom_constraint_bounds(TRUE, tscaleold, fitscale * ppd, + &tscalemin, &tscalemax); + scale = CLAMP(scale * ppd, tscalemin, tscalemax) / ppd; + } port->closeup = closeup; port->zoom_scale = scale; diff --git a/src/develop/develop.h b/src/develop/develop.h index 0af65b3968d2..d5f8a0b33ad9 100644 --- a/src/develop/develop.h +++ b/src/develop/develop.h @@ -357,6 +357,7 @@ typedef struct dt_develop_t int mask_form_selected_id; // select a mask inside an iop gboolean darkroom_skip_mouse_events; // skip mouse events for masks gboolean darkroom_mouse_in_center_area; // TRUE if the mouse cursor is in center area + gboolean constrain_zoom; // cached value of "darkroom/ui/constrain_zoom" pref GList *module_filter_out; diff --git a/src/libs/navigation.c b/src/libs/navigation.c index a1eec3337e73..975fd476ffec 100644 --- a/src/libs/navigation.c +++ b/src/libs/navigation.c @@ -466,7 +466,10 @@ static void _zoom_changed(GtkWidget *widget, gpointer user_data) else scale = val / 100.0f * ppd; - dt_dev_zoom_move(port, zoom, scale, closeup, -1.0f, -1.0f, TRUE); + // Preset zoom picks (small / 50% / custom %) take DT_ZOOM_FREE with an + // explicit scale — don't run them through the constrain soft caps. + dt_dev_zoom_move(port, zoom, scale, closeup, -1.0f, -1.0f, + zoom != DT_ZOOM_FREE); } static gboolean _lib_navigation_widget_to_center(GtkEventController *controller, diff --git a/src/views/darkroom.c b/src/views/darkroom.c index b87b8e53fb10..82ddd3639253 100644 --- a/src/views/darkroom.c +++ b/src/views/darkroom.c @@ -4198,8 +4198,9 @@ void scrolled(dt_view_t *self, if(handled) return; } - // free zoom - const gboolean constrained = !dt_modifier_is(state, GDK_CONTROL_MASK); + // free zoom: constrain when the config asks for it AND CTRL isn't held + const gboolean constrained = + dev->constrain_zoom && !dt_modifier_is(state, GDK_CONTROL_MASK); dt_dev_zoom_move(&dev->full, DT_ZOOM_SCROLL, 0.0f, up, x, y, constrained); } @@ -4257,7 +4258,9 @@ gboolean gesture_pinch(dt_view_t *self, { dt_develop_t *dev = self->data; if(!dev) return FALSE; - (void)state; + // constrain when the config asks for it AND CTRL isn't held + const gboolean constrained = + dev->constrain_zoom && !dt_modifier_is(state, GDK_CONTROL_MASK); // Gesture-mode classifier: each event contributes its zoom_eq & pan_eq magnitude in pixels // to a decaying score. When the recent history is zoom-dominant (score > ZOOM_DOMINANT_PX) @@ -4340,7 +4343,7 @@ gboolean gesture_pinch(dt_view_t *self, "[darkroom pinch] pan component eff_dx=%.3f eff_dy=%.3f" " (score=%.2f zoom_eq=%.2f pan_eq=%.2f)", eff_dx, eff_dy, zoom_pan_score, zoom_eq_px, pan_eq_px); - dt_dev_zoom_move(&dev->full, DT_ZOOM_MOVE, 1.0f, 0, eff_dx, eff_dy, TRUE); + dt_dev_zoom_move(&dev->full, DT_ZOOM_MOVE, 1.0f, 0, eff_dx, eff_dy, constrained); } const float ppd = dev->full.ppd; @@ -4368,7 +4371,7 @@ gboolean gesture_pinch(dt_view_t *self, dev->full.border_size, dev->full.width, dev->full.height, dx, dy, eff_dx, eff_dy, zoom_pan_score, zoom_dominant, scale, state, tscale, tscalefloor, tscaletop, zoom_scale); - dt_dev_zoom_move(&dev->full, DT_ZOOM_FREE, zoom_scale, 0, x_local, y_local, TRUE); + dt_dev_zoom_move(&dev->full, DT_ZOOM_FREE, zoom_scale, 0, x_local, y_local, constrained); return TRUE; } @@ -4559,7 +4562,6 @@ static gboolean _second_window_scrolled_callback(GtkWidget *widget, dt_develop_t *dev) { if(dev->gui_leaving) return TRUE; - int delta_y; if(dt_gui_get_scroll_unit_delta(event, &delta_y)) { @@ -4569,7 +4571,8 @@ static gboolean _second_window_scrolled_callback(GtkWidget *widget, dt_dev_viewport_t *port = pinned_dev ? &pinned_dev->preview2 : &dev->preview2; - const gboolean constrained = !dt_modifier_is(event->state, GDK_CONTROL_MASK); + const gboolean constrained = + dev->constrain_zoom && !dt_modifier_is(event->state, GDK_CONTROL_MASK); dt_dev_zoom_move(port, DT_ZOOM_SCROLL, 0.0f, delta_y < 0, event->x, event->y, constrained); } From 2106a493601b82682acc99a95aca6828f601c0bd Mon Sep 17 00:00:00 2001 From: Philipp Lutz Date: Tue, 19 May 2026 14:44:24 +0200 Subject: [PATCH 3/3] Make config params run-time configurable --- data/darktableconfig.xml.in | 8 ++++---- src/gui/gtk.c | 26 +++++++++++--------------- src/gui/gtk.h | 1 + src/views/darkroom.c | 11 +++++++++++ 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/data/darktableconfig.xml.in b/data/darktableconfig.xml.in index 81f9355026fa..a6c4163765f5 100644 --- a/data/darktableconfig.xml.in +++ b/data/darktableconfig.xml.in @@ -2433,19 +2433,19 @@ enabled: the mouse wheel scrolls the modules panel and with Ctrl+Alt adjusts a control's value\n\ndisabled: the mouse wheel adjusts the control under the pointer and with Ctrl+Alt scrolls the panel.]]> - + darkroom/ui/touchpad_gestures bool true enable touchpad gestures in darkroom - enabled: touchpad pinch gestures zoom the image and two-finger touchpad scrolling pans it; Ctrl+scroll still uses the legacy zoom behavior. disabled: touchpad gestures are ignored and darkroom falls back to the legacy scroll behavior, including Ctrl+scroll for zooming in and out. (restart required)]]> + enabled: touchpad pinch gestures zoom the image and two-finger touchpad scrolling pans it; Ctrl + two-finger scroll still leads to the zoom behavior. disabled: touchpad pinch-to-zoom and panning gestures are ignored and darkroom falls back to the legacy scroll behavior, including two-finger scroll for zooming in and out.]]> - + darkroom/ui/constrain_zoom bool true constrain darkroom zoom between screen fit and 100% - enabled: zoom is constrained; hold Ctrl while zooming to temporarily lift the limit and zoom beyond 100%. disabled: zoom is never constrained and the additional Ctrl key press is not needed to zoom beyond 100%. (restart required)]]> + enabled: zoom is constrained; hold Ctrl while zooming to temporarily lift the limit and zoom beyond 100%. disabled: zoom is never constrained and the additional Ctrl key press is not needed to zoom beyond 100%.]]> plugins/darkroom/ui/border_size diff --git a/src/gui/gtk.c b/src/gui/gtk.c index c2792018f1cc..0f2db284cebf 100644 --- a/src/gui/gtk.c +++ b/src/gui/gtk.c @@ -727,20 +727,12 @@ static gboolean _draw(GtkWidget *da, } static GdkDevice *_touchpad = NULL; -static gboolean _touchpad_gestures_enabled(void) + +static void _touchpad_gestures_pref_changed(gpointer instance, + gboolean* touchpad_gestures_enabled) { - // If conf_gen.h was built before darktableconfig.xml.in gained this key - // (incremental build without cmake reconfigure), dt_confgen_value_exists - // returns FALSE and dt_conf_get_bool gets an empty string → FALSE. - // Default to enabled in that case so a stale build doesn't silently break gestures. - if(!dt_confgen_value_exists("darkroom/ui/touchpad_gestures", DT_DEFAULT)) - { - dt_print(DT_DEBUG_INPUT, - "[touchpad] 'darkroom/ui/touchpad_gestures' missing from confgen" - " (stale conf_gen.h — run cmake reconfigure), defaulting to enabled"); - return TRUE; - } - return dt_conf_get_bool("darkroom/ui/touchpad_gestures"); + (void)instance; + *touchpad_gestures_enabled = dt_conf_get_bool("darkroom/ui/touchpad_gestures"); } static gboolean _input_event(GtkWidget *widget, @@ -773,7 +765,7 @@ static gboolean _input_event(GtkWidget *widget, break; } - if(event->type == GDK_TOUCHPAD_PINCH && _touchpad_gestures_enabled()) + if(event->type == GDK_TOUCHPAD_PINCH && darktable.gui->touchpad_gestures_enabled) { const GdkEventTouchpadPinch *pinch = &event->touchpad_pinch; dt_print(DT_DEBUG_INPUT, @@ -805,7 +797,7 @@ static gboolean _scrolled(GtkWidget *widget, { (void)user_data; GdkDevice *device = gdk_event_get_source_device((GdkEvent *)event); - const gboolean touchpad_enabled = _touchpad_gestures_enabled(); + const gboolean touchpad_enabled = darktable.gui->touchpad_gestures_enabled; const gboolean ctrl_pressed = dt_modifier_is(event->state, GDK_CONTROL_MASK); dt_print(DT_DEBUG_INPUT, @@ -1523,6 +1515,10 @@ int dt_gui_gtk_init(dt_gui_gtk_t *gui) // Init focus peaking gui->show_focus_peaking = dt_conf_get_bool("ui/show_focus_peaking"); + gui->touchpad_gestures_enabled = TRUE; + DT_CONTROL_SIGNAL_CONNECT(DT_SIGNAL_PREFERENCES_CHANGE, + _touchpad_gestures_pref_changed, &gui->touchpad_gestures_enabled); + /* Have the delete event (window close) end the program */ snprintf(path, sizeof(path), "%s/icons", datadir); gtk_icon_theme_append_search_path(gtk_icon_theme_get_default(), path); diff --git a/src/gui/gtk.h b/src/gui/gtk.h index 7c47a48cd95f..93a9999f6489 100644 --- a/src/gui/gtk.h +++ b/src/gui/gtk.h @@ -132,6 +132,7 @@ typedef struct dt_gui_gtk_t gboolean show_overlays; gboolean show_focus_peaking; + gboolean touchpad_gestures_enabled; double overlay_red, overlay_blue, overlay_green, overlay_contrast; GtkWidget *focus_peaking_button; diff --git a/src/views/darkroom.c b/src/views/darkroom.c index 82ddd3639253..09e1c2328899 100644 --- a/src/views/darkroom.c +++ b/src/views/darkroom.c @@ -2212,6 +2212,7 @@ static void _preference_changed(gpointer instance, static void _preference_changed_button_hide(gpointer instance, const dt_view_t *self) { + (void)instance; dt_develop_t *dev = self->data; for(const GList *modules = dev->iop; modules; modules = g_list_next(modules)) { @@ -2225,6 +2226,14 @@ static void _preference_changed_button_hide(gpointer instance, } } +static void _preference_changed_constrain_zoom(gpointer instance, + const dt_view_t *self) +{ + (void)instance; + dt_develop_t *dev = self->data; + dev->constrain_zoom = dt_conf_get_bool("darkroom/ui/constrain_zoom"); +} + static void _update_display_profile_cmb(GtkWidget *cmb_display_profile) { for(const GList *l = darktable.color_profiles->profiles; l; l = g_list_next(l)) @@ -3582,6 +3591,8 @@ void enter(dt_view_t *self) // connect to preference change for module header button hiding DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_PREFERENCES_CHANGE, _preference_changed_button_hide); + DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_PREFERENCES_CHANGE, _preference_changed_constrain_zoom); + dt_iop_color_picker_init(); dt_image_check_camera_missing_sample(&dev->image_storage);