Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion data/darktableconfig.xml.in
Original file line number Diff line number Diff line change
Expand Up @@ -2438,7 +2438,14 @@
<type>bool</type>
<default>true</default>
<shortdescription>enable touchpad gestures in darkroom</shortdescription>
<longdescription><![CDATA[use two-finger touchpad gestures in darkroom for panning and pinch-to-zoom. <b>enabled:</b> touchpad pinch gestures zoom the image and two-finger touchpad scrolling pans it; <i>Ctrl+scroll</i> still uses the legacy zoom behavior. <b>disabled:</b> touchpad gestures are ignored and darkroom falls back to the legacy scroll behavior, including <i>Ctrl+scroll</i> for zooming in and out.]]></longdescription>
<longdescription><![CDATA[use two-finger touchpad gestures in darkroom for panning and pinch-to-zoom. <b>enabled:</b> touchpad pinch gestures zoom the image and two-finger touchpad scrolling pans it; <i>Ctrl + two-finger scroll</i> still leads to the zoom behavior. <b>disabled:</b> 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.]]></longdescription>
</dtconfig>
<dtconfig prefs="misc" section="interface">
<name>darkroom/ui/constrain_zoom</name>
<type>bool</type>
<default>true</default>
<shortdescription>constrain darkroom zoom between screen fit and 100%</shortdescription>
<longdescription><![CDATA[limits scroll-wheel and pinch-to-zoom in the darkroom to the range between screen fit (zoom out) and 100% (zoom in). <b>enabled:</b> zoom is constrained; hold <i>Ctrl</i> while zooming to temporarily lift the limit and zoom beyond 100%. <b>disabled:</b> zoom is never constrained and the additional <i>Ctrl</i> key press is not needed to zoom beyond 100%.]]></longdescription>
</dtconfig>
<dtconfig prefs="darkroom" section="general">
<name>plugins/darkroom/ui/border_size</name>
Expand Down
116 changes: 72 additions & 44 deletions src/develop/develop.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/develop/develop.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
26 changes: 11 additions & 15 deletions src/gui/gtk.c
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/gui/gtk.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
5 changes: 4 additions & 1 deletion src/libs/navigation.c
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 21 additions & 7 deletions src/views/darkroom.c
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
Expand All @@ -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))
Expand Down Expand Up @@ -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);
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.

You need a DT_CONTROL_SIGNAL_CONNECT and passing as third argument &darktable or DT_CONTROL_SIGNAL_HANDLE as above but in _preference_changed_constrain_zoom use darktable.dev->....

If the later solution is used, it probably make sense to use _preference_changed_button_hide as callback. Just renaming it to something more generic : _preference_changed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, I probably just didn't fully understand how it works and that those settings are already part of the preferences menu.
Please have a look again.

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.

We can simplify this... As outlined above a single _preference_changed callback can be used. How it works:

  • After editing any preferences the signal DT_SIGNAL_PREFERENCES_CHANGE is emitted.
  • We don't know what has been changed, but we need to reset all the needed preferences to their new value.
  • So we connect to DT_SIGNAL_PREFERENCES_CHANGE in darkroom.c (a single callback, a single connect of you prefer).
  • Inside this callback we read all the used preferences and we set the corresponding values in darktable global struct, the pattern is darktable.develop-><FIELD> = dt_conf_get_<TYPE>(<KEY>);.

Let me know if this clarifies things and if the changes to be done is clear to you. TIA.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, I thought so too, but that very callback is called twice every time, because it is registered for 2 color intent widgets. I didn't want to also completely refactor this code. But I agree that ideally we should only have one _preference_changed callback per module, as we have way to many unnecessary callbacks all over the place, as also outlined in my comment below:
#21031 (comment)

I'd prefer a separate PR to clean up this and do it properly. But if you think it's fine to extend the scope of this PR, I can already do it in this PR. Your call 😉

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.

I'd prefer a separate PR to clean up this and do it properly.

I'd prefer in this PR because to me the complexity added here should not be and we can do a clean implementation directly in this PR. Just adding the setting in a current DT_SIGNAL_PREFERENCES_CHANGE callback (just renaming it to be more generic). If I did not missed something it should be as simple as done currently and clean without the need for another PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Alrighty, will do once I'm back at my computer 👌


dt_iop_color_picker_init();

dt_image_check_camera_missing_sample(&dev->image_storage);
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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))
{
Expand All @@ -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);
}
Expand Down
Loading