diff --git a/data/darktableconfig.xml.in b/data/darktableconfig.xml.in
index 6442022c909f..a6c4163765f5 100644
--- a/data/darktableconfig.xml.in
+++ b/data/darktableconfig.xml.in
@@ -2438,7 +2438,14 @@
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 + 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%.]]>
plugins/darkroom/ui/border_size
diff --git a/src/develop/develop.c b/src/develop/develop.c
index 2bbe4998dd0a..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
- ? (tscaleold >= 2.0f
+ *tscalemax = constrained
+ ? (tscaleold > 2.0f
? tscaletop
- : (tscaleold >= 1.0f ? 2.0f : 1.0f))
+ : (tscaleold > 1.0f ? 2.0f : 1.0f))
: tscaletop;
- tscalemin = constrained
+ *tscalemin = constrained
? (tscaleold < tscalefit
? tscalefloor
: tscalefit)
: tscalefloor;
break;
case SIZE_MEDIUM:
- tscalemax = constrained
- ? (tscaleold >= 2.0f
+ *tscalemax = constrained
+ ? (tscaleold > 2.0f
? tscaletop
: 2.0f)
: tscaletop;
- tscalemin = constrained
+ *tscalemin = constrained
? (tscaleold < tscalefit
? tscalefloor
: tscalefit)
: tscalefloor;
break;
case SIZE_SMALL:
- tscalemax = constrained
- ? (tscaleold >= 2.0f
+ *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/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/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..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);
@@ -4198,8 +4209,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 +4269,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 +4354,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 +4382,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 +4573,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 +4582,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);
}