diff --git a/src/develop/develop.c b/src/develop/develop.c index 2bbe4998dd0a..71536f225b02 100644 --- a/src/develop/develop.c +++ b/src/develop/develop.c @@ -3200,13 +3200,132 @@ void dt_dev_zoom_move(dt_dev_viewport_t *port, // Mark pipe as needing zoom update port->pipe->changed |= DT_DEV_PIPE_ZOOMED; - + if(port->widget) dt_control_queue_redraw_widget(port->widget); if(port == &dev->full) dt_control_navigation_redraw(); } +gboolean dt_dev_pinch_zoom(dt_dev_viewport_t *port, + const char *tag, + const double x, + const double y, + const double dx, + const double dy, + const int phase, + const double scale, + const int state) +{ + (void)state; + + // 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) + // we treat the gesture as a pinch and discard the dx/dy translation that would otherwise + // jitter the cursor anchor. When pan dominates, the score falls below the threshold and + // dx/dy flows through (subject to a small per-axis deadzone for touchpad noise). + // The exponential decay gives hysteresis so a single wobble frame mid-gesture doesn't flip the classification. + static const float PINCH_ZOOM_PAN_DECAY = 0.8f; + static const float PINCH_ZOOM_DOMINANT_PX = 8.0f; + static const float PINCH_PAN_DEADZONE_PX = 4.0f; + static float pinch_begin_tscale = 0.0f; + static float prev_scale = 1.0f; + static float zoom_pan_score = 0.0f; + + if(phase == GDK_TOUCHPAD_GESTURE_PHASE_BEGIN) + { + pinch_begin_tscale = + dt_dev_get_zoom_scale(port, port->zoom, 1 << port->closeup, FALSE) + * port->ppd; + prev_scale = (float)scale; + zoom_pan_score = 0.0f; + dt_print(DT_DEBUG_INPUT, + "[%s pinch] begin x=%.1f y=%.1f scale=%.6f state=0x%x" + " -> begin_tscale=%.6f ppd=%.2f", + tag, x, y, scale, state, pinch_begin_tscale, port->ppd); + return TRUE; + } + else if(phase == GDK_TOUCHPAD_GESTURE_PHASE_END + || phase == GDK_TOUCHPAD_GESTURE_PHASE_CANCEL) + { + dt_print(DT_DEBUG_INPUT, + "[%s pinch] %s x=%.1f y=%.1f scale=%.6f state=0x%x", + tag, phase == GDK_TOUCHPAD_GESTURE_PHASE_END ? "end" : "cancel", + x, y, scale, state); + pinch_begin_tscale = 0.0f; + prev_scale = 1.0f; + zoom_pan_score = 0.0f; + return TRUE; + } + + if(phase != GDK_TOUCHPAD_GESTURE_PHASE_UPDATE) + { + dt_print(DT_DEBUG_INPUT, + "[%s pinch] unknown phase=%d ignored", tag, phase); + return FALSE; + } + if(pinch_begin_tscale <= 0.0f || scale <= 0.0) + { + dt_print(DT_DEBUG_INPUT, + "[%s pinch] update skipped: begin_tscale=%.6f scale=%.6f", + tag, pinch_begin_tscale, scale); + return FALSE; + } + + // On macOS (GDK Quartz), NSEventTypeMagnify never populates dx/dy and the + // gesture focal-point x/y is set once at phase=BEGIN and does not update + // during the gesture — so both approaches to infer translation are zero. + // Pan on macOS therefore arrives as a separate smooth-scroll stream which is + // routed to gesture_pan by _scrolled() in gtk.c. + // On other platforms (Wayland/X11), dx/dy carry the actual translational delta. + float eff_dx = (float)dx; + float eff_dy = (float)dy; + + // Update the zoom-vs-pan dominance score and use it to decide whether the + // dx/dy component is finger-drift wobble (suppress) or real pan (allow). + const float scale_inc = (prev_scale > 0.0f) ? fabsf((float)scale / prev_scale - 1.0f) : 0.0f; + const float zoom_eq_px = scale_inc * 0.5f * (float)port->width; + const float pan_eq_px = sqrtf(eff_dx * eff_dx + eff_dy * eff_dy); + zoom_pan_score = zoom_pan_score * PINCH_ZOOM_PAN_DECAY + + (zoom_eq_px - pan_eq_px); + const gboolean zoom_dominant = zoom_pan_score > PINCH_ZOOM_DOMINANT_PX; + // If zoom is dominant or the pan component is within the deadzone zero that component. + eff_dx = (zoom_dominant || fabsf(eff_dx) < PINCH_PAN_DEADZONE_PX) ? 0.0f : eff_dx; + eff_dy = (zoom_dominant || fabsf(eff_dy) < PINCH_PAN_DEADZONE_PX) ? 0.0f : eff_dy; + prev_scale = (float)scale; + + if(eff_dx != 0.0f || eff_dy != 0.0f) + { + dt_print(DT_DEBUG_INPUT, + "[%s pinch] pan component eff_dx=%.3f eff_dy=%.3f" + " (score=%.2f zoom_eq=%.2f pan_eq=%.2f)", + tag, eff_dx, eff_dy, zoom_pan_score, zoom_eq_px, pan_eq_px); + dt_dev_zoom_move(port, DT_ZOOM_MOVE, 1.0f, 0, eff_dx, eff_dy, TRUE); + } + + const float ppd = port->ppd; + const float fitscale = dt_dev_get_zoom_scale(port, DT_ZOOM_FIT, 1.0f, FALSE); + const float tscalefloor = MIN(0.5f * fitscale * ppd, 1.0f); + const float tscaletop = 16.0f; + const float tscale = CLAMP(pinch_begin_tscale * scale, tscalefloor, tscaletop); + + // Keep pinch fully continuous for a smartphone-like feeling, including at high zoom. + // x/y are expected in widget-local coords (caller converts from root); this is + // the coord space dt_dev_zoom_move expects for cursor anchoring + // (it computes mouse_off via x - border - 0.5 * port->width). + const float zoom_scale = tscale / ppd; + dt_print(DT_DEBUG_INPUT, + "[%s pinch] update x=%.1f y=%.1f (border=%d port=%dx%d) raw_dx=%.3f raw_dy=%.3f" + " eff_dx=%.3f eff_dy=%.3f score=%.2f zoom_dom=%d scale=%.6f state=0x%x" + " -> tscale=%.6f (floor=%.6f top=%.1f) zoom_scale=%.6f", + tag, x, y, port->border_size, port->width, port->height, + dx, dy, eff_dx, eff_dy, zoom_pan_score, zoom_dominant, + scale, state, tscale, tscalefloor, tscaletop, zoom_scale); + dt_dev_zoom_move(port, DT_ZOOM_FREE, zoom_scale, 0, x, y, TRUE); + + return TRUE; +} + void dt_dev_get_pointer_zoom_pos(dt_dev_viewport_t *port, const float px, const float py, diff --git a/src/develop/develop.h b/src/develop/develop.h index 0af65b3968d2..8fc673145e39 100644 --- a/src/develop/develop.h +++ b/src/develop/develop.h @@ -460,6 +460,20 @@ void dt_dev_zoom_move(dt_dev_viewport_t *port, const float x, const float y, const gboolean constrain); +/* Apply a touchpad pinch-zoom gesture phase to `port`. `tag` is a short + * identifier (e.g. "darkroom" / "second window") used only for debug logging. + * x/y are the gesture focal point in widget coords, dx/dy any per-frame pan + * component, phase is a GdkTouchpadGesturePhase, scale is the cumulative + * gesture scale since BEGIN, state is the GDK modifier mask. */ +gboolean dt_dev_pinch_zoom(dt_dev_viewport_t *port, + const char *tag, + const double x, + const double y, + const double dx, + const double dy, + const int phase, + const double scale, + const int state); float dt_dev_get_zoom_scale(dt_dev_viewport_t *port, const dt_dev_zoom_t zoom, const int closeup_factor, diff --git a/src/gui/gtk.c b/src/gui/gtk.c index c2792018f1cc..c1b06d9989c7 100644 --- a/src/gui/gtk.c +++ b/src/gui/gtk.c @@ -727,7 +727,7 @@ static gboolean _draw(GtkWidget *da, } static GdkDevice *_touchpad = NULL; -static gboolean _touchpad_gestures_enabled(void) +gboolean dt_gui_touchpad_gestures_enabled(void) { // If conf_gen.h was built before darktableconfig.xml.in gained this key // (incremental build without cmake reconfigure), dt_confgen_value_exists @@ -756,11 +756,13 @@ static gboolean _input_event(GtkWidget *widget, _touchpad = gdk_event_get_source_device(event); if(_touchpad) { + GdkDevice *mst = gdk_event_get_device(event); dt_print(DT_DEBUG_INPUT, - "[touchpad] gesture event type=%d source='%s' source_type=%d", + "[touchpad] gesture event type=%d source='%s' source_type=%d master='%s'", event->type, gdk_device_get_name(_touchpad), - gdk_device_get_source(_touchpad)); + gdk_device_get_source(_touchpad), + mst ? gdk_device_get_name(mst) : ""); } else { @@ -773,115 +775,170 @@ static gboolean _input_event(GtkWidget *widget, break; } - if(event->type == GDK_TOUCHPAD_PINCH && _touchpad_gestures_enabled()) - { - const GdkEventTouchpadPinch *pinch = &event->touchpad_pinch; - dt_print(DT_DEBUG_INPUT, - "[touchpad] pinch x=%.2f y=%.2f phase=%d scale=%.6f state=0x%x", - pinch->x, pinch->y, pinch->phase, pinch->scale, pinch->state); - if(dt_view_manager_gesture_pinch(darktable.view_manager, pinch->x_root, pinch->y_root, - pinch->dx, pinch->dy, pinch->phase, - pinch->scale, pinch->state & 0xf)) - { - gtk_widget_queue_draw(widget); - return TRUE; - } + if(dt_gui_handle_touchpad_pinch_event(widget, event, "main", NULL)) + return TRUE; + + return FALSE; +} + +gboolean dt_gui_handle_touchpad_pinch_event(GtkWidget *widget, + GdkEvent *event, + const char *tag, + struct dt_dev_viewport_t *port) +{ + if(event->type != GDK_TOUCHPAD_PINCH) return FALSE; + if(!dt_gui_touchpad_gestures_enabled()) + { dt_print(DT_DEBUG_INPUT, - "[touchpad] pinch ignored by current view"); + "[%s touchpad] pinch received but disabled by preference" + " darkroom/ui/touchpad_gestures", tag); + return FALSE; } - else if(event->type == GDK_TOUCHPAD_PINCH) + + const GdkEventTouchpadPinch *pinch = &event->touchpad_pinch; + + // Convert root (screen-absolute) pinch coords to widget-local coords of the + // widget that received the event. This is the coord space dt_dev_zoom_move + // expects for cursor anchoring (it computes mouse_off via + // x - border - 0.5 * port->width). + int ox = 0, oy = 0; + GdkWindow *win = gtk_widget_get_window(widget); + if(win) gdk_window_get_origin(win, &ox, &oy); + const double x_local = pinch->x_root - ox; + const double y_local = pinch->y_root - oy; + + dt_print(DT_DEBUG_INPUT, + "[%s touchpad] pinch x=%.2f y=%.2f (local=%.1f,%.1f origin=%d,%d)" + " phase=%d scale=%.6f state=0x%x", + tag, pinch->x, pinch->y, x_local, y_local, ox, oy, + pinch->phase, pinch->scale, pinch->state); + + const gboolean handled = port + ? dt_dev_pinch_zoom(port, tag, x_local, y_local, + pinch->dx, pinch->dy, pinch->phase, + pinch->scale, pinch->state & 0xf) + : dt_view_manager_gesture_pinch(darktable.view_manager, + x_local, y_local, + pinch->dx, pinch->dy, pinch->phase, + pinch->scale, pinch->state & 0xf); + + if(handled) { - dt_print(DT_DEBUG_INPUT, - "[touchpad] pinch received but disabled by preference darkroom/ui/touchpad_gestures"); + gtk_widget_queue_draw(widget); + return TRUE; } + dt_print(DT_DEBUG_INPUT, "[%s touchpad] pinch ignored by handler", tag); return FALSE; } -static gboolean _scrolled(GtkWidget *widget, - const GdkEventScroll *event, - gpointer user_data) +gboolean dt_gui_handle_touchpad_scroll_pan_event(GtkWidget *widget, + GdkEventScroll *event, + struct dt_dev_viewport_t *port) { - (void)user_data; GdkDevice *device = gdk_event_get_source_device((GdkEvent *)event); - const gboolean touchpad_enabled = _touchpad_gestures_enabled(); + const gboolean touchpad_enabled = dt_gui_touchpad_gestures_enabled(); const gboolean ctrl_pressed = dt_modifier_is(event->state, GDK_CONTROL_MASK); - - dt_print(DT_DEBUG_INPUT, - "[scroll] direction=%d smooth=%s stop=%s ctrl=%s" - " x=%.1f y=%.1f dx=%.3f dy=%.3f state=0x%x" - " device='%s' source-type=%d", - event->direction, - event->direction == GDK_SCROLL_SMOOTH ? "yes" : "no", - event->is_stop ? "yes" : "no", - ctrl_pressed ? "yes" : "no", - event->x, event->y, event->delta_x, event->delta_y, event->state, - device ? gdk_device_get_name(device) : "", - device ? (int)gdk_device_get_source(device) : -1); const gboolean is_touchpad_source = device && gdk_device_get_source(device) == GDK_SOURCE_TOUCHPAD; const gboolean matches_last_gesture_device = (device == _touchpad); - const gboolean is_smooth = event->direction == GDK_SCROLL_SMOOTH && !event->is_stop; -#ifdef GDK_WINDOWING_QUARTZ - // On macOS/Quartz, the built-in trackpad reports as GDK_SOURCE_MOUSE, not - // GDK_SOURCE_TOUCHPAD. Route every non-ctrl smooth scroll to gesture_pan so - // that two-finger panning works in views like darkroom (both standalone and - // interleaved with a pinch-zoom gesture whose translational component macOS - // delivers as a separate scroll stream). - const gboolean route_as_pan = touchpad_enabled && !ctrl_pressed && is_smooth; -#else + + // Cross-platform routing: only smooth scrolls from a device known to be a touchpad (either reported as + // GDK_SOURCE_TOUCHPAD, or already seen emitting a TOUCHPAD_PINCH/SWIPE gesture this session) are turned into pan. + // + // On macOS/Quartz the built-in trackpad reports as GDK_SOURCE_MOUSE, so the `is_touchpad_source` clause never + // fires there; we rely on `matches_last_gesture_device` instead, which is set the first time the user pinches. + // That makes the combined pinch+pan flow work (because the pinch arrives first and binds `_touchpad`), and keeps + // mouse scrolls — which never emit TOUCHPAD_PINCH — out of the pan path so they fall through to the discrete + // unit-delta path and produce the traditional scroll-wheel zoom in darkroom. const gboolean route_as_pan = touchpad_enabled && !ctrl_pressed && (is_touchpad_source || matches_last_gesture_device) && is_smooth; -#endif - if(route_as_pan) + if(!route_as_pan) { - gdouble delta_x = 0.0, delta_y = 0.0; - if(!dt_gui_get_scroll_deltas(event, &delta_x, &delta_y)) - { + if(is_smooth) dt_print(DT_DEBUG_INPUT, - "[touchpad] smooth scroll ignored (likely pointer emulated), source='%s' source_type=%d", + "[touchpad] smooth scroll not treated as pan: enabled=%d ctrl=%d touchpad_source=%d matches_last_gesture=%d source='%s' source_type=%d", + touchpad_enabled, ctrl_pressed, is_touchpad_source, matches_last_gesture_device, device ? gdk_device_get_name(device) : "", device ? gdk_device_get_source(device) : -1); - return TRUE; - } - - delta_x *= DT_UI_SCROLL_SMOOTH_DELTA_SCALE; - delta_y *= DT_UI_SCROLL_SMOOTH_DELTA_SCALE; - if((delta_x != 0.0 || delta_y != 0.0) - && dt_view_manager_gesture_pan(darktable.view_manager, event->x, event->y, - delta_x, delta_y, event->state & 0xf)) - { - dt_print(DT_DEBUG_INPUT, - "[touchpad] pan x=%.2f y=%.2f dx=%.3f dy=%.3f source='%s'", - event->x, event->y, delta_x, delta_y, - device ? gdk_device_get_name(device) : ""); - gtk_widget_queue_draw(widget); - return TRUE; - } - else if(delta_x != 0.0 || delta_y != 0.0) - { - dt_print(DT_DEBUG_INPUT, - "[touchpad] pan not handled by current view (no gesture_pan handler?)" - " dx=%.3f dy=%.3f", - delta_x, delta_y); - } + return FALSE; } - else if(is_smooth) + + gdouble delta_x = 0.0, delta_y = 0.0; + if(!dt_gui_get_scroll_deltas(event, &delta_x, &delta_y)) { dt_print(DT_DEBUG_INPUT, - "[touchpad] smooth scroll not treated as pan: enabled=%d ctrl=%d touchpad_source=%d matches_last_gesture=%d route_as_pan=%d source='%s' source_type=%d", - touchpad_enabled, - ctrl_pressed, - is_touchpad_source, - matches_last_gesture_device, - route_as_pan, + "[touchpad] smooth scroll ignored (likely pointer emulated), source='%s' source_type=%d", device ? gdk_device_get_name(device) : "", device ? gdk_device_get_source(device) : -1); + return TRUE; + } + + delta_x *= DT_UI_SCROLL_SMOOTH_DELTA_SCALE; + delta_y *= DT_UI_SCROLL_SMOOTH_DELTA_SCALE; + if(delta_x == 0.0 && delta_y == 0.0) return FALSE; + + gboolean handled; + if(port) + { + dt_dev_zoom_move(port, DT_ZOOM_MOVE, 1.0f, 0, delta_x, delta_y, TRUE); + handled = TRUE; + } + else + { + handled = dt_view_manager_gesture_pan(darktable.view_manager, event->x, event->y, + delta_x, delta_y, event->state & 0xf); + } + + if(handled) + { + dt_print(DT_DEBUG_INPUT, + "[touchpad] pan x=%.2f y=%.2f dx=%.3f dy=%.3f source='%s'", + event->x, event->y, delta_x, delta_y, + device ? gdk_device_get_name(device) : ""); + gtk_widget_queue_draw(widget); + return TRUE; } + dt_print(DT_DEBUG_INPUT, + "[touchpad] pan not handled by current view (no gesture_pan handler?)" + " dx=%.3f dy=%.3f", delta_x, delta_y); + return FALSE; +} + +static gboolean _scrolled(GtkWidget *widget, + const GdkEventScroll *event, + gpointer user_data) +{ + (void)user_data; + GdkDevice *device = gdk_event_get_source_device((GdkEvent *)event); + GdkDevice *master = gdk_event_get_device((GdkEvent *)event); + + // `matches_last_gesture` tells us whether this scroll's source GdkDevice is the same one we last saw emit + // a TOUCHPAD_PINCH/SWIPE. On macOS/Quartz where the trackpad reports as GDK_SOURCE_MOUSE this is what discriminates + // the trackpad from a mouse, but only if GDK Quartz actually hands out distinct GdkDevice values per physical input. + dt_print(DT_DEBUG_INPUT, + "[scroll] direction=%d smooth=%s stop=%s ctrl=%s" + " x=%.1f y=%.1f dx=%.3f dy=%.3f state=0x%x" + " device='%s' source-type=%d master='%s'" + " is_touchpad_source=%d matches_last_gesture=%d", + event->direction, + event->direction == GDK_SCROLL_SMOOTH ? "yes" : "no", + event->is_stop ? "yes" : "no", + dt_modifier_is(event->state, GDK_CONTROL_MASK) ? "yes" : "no", + event->x, event->y, event->delta_x, event->delta_y, event->state, + device ? gdk_device_get_name(device) : "", + device ? (int)gdk_device_get_source(device) : -1, + master ? gdk_device_get_name(master) : "", + device && gdk_device_get_source(device) == GDK_SOURCE_TOUCHPAD, + device == _touchpad); + + if(dt_gui_handle_touchpad_scroll_pan_event(widget, (GdkEventScroll *)event, NULL)) + return TRUE; + int delta_y; if(dt_gui_get_scroll_unit_delta(event, &delta_y)) { diff --git a/src/gui/gtk.h b/src/gui/gtk.h index 7c47a48cd95f..685e6c7ac972 100644 --- a/src/gui/gtk.h +++ b/src/gui/gtk.h @@ -245,6 +245,34 @@ gboolean dt_gui_get_scroll_delta(const GdkEventScroll *event, gdouble *delta); * scroll events. */ gboolean dt_gui_get_scroll_unit_delta(const GdkEventScroll *event, int *delta); +/* Returns whether touchpad pinch/pan gestures are enabled by user preference. */ +gboolean dt_gui_touchpad_gestures_enabled(void); + +/* Handle a GDK_TOUCHPAD_PINCH event: gestures-enabled check, debug logging, + * dispatch, and queue_draw on the originating widget. `tag` identifies the + * source in log lines (e.g. "darkroom", "second window"). When `port` is + * non-NULL, the gesture is applied directly to that viewport via + * dt_dev_pinch_zoom; otherwise it is routed through the view manager so the + * current view's gesture_pinch callback fires (used for the main window). + * Returns TRUE if the event was a pinch and was consumed. */ +struct dt_dev_viewport_t; +gboolean dt_gui_handle_touchpad_pinch_event(GtkWidget *widget, + GdkEvent *event, + const char *tag, + struct dt_dev_viewport_t *port); + +/* Handle a GDK_SCROLL event coming from a touchpad as a pan gesture. Only + * smooth-scrolls from a known touchpad device (and without Ctrl held) are + * routed; everything else returns FALSE so the caller can fall back to its + * usual discrete-scroll handling. When `port` is non-NULL the pan is applied + * directly to that viewport via dt_dev_zoom_move(DT_ZOOM_MOVE), otherwise it + * is dispatched to the current view's gesture_pan callback via the view + * manager (used for the main window). Returns TRUE if the event was consumed + * as a touchpad pan. */ +gboolean dt_gui_handle_touchpad_scroll_pan_event(GtkWidget *widget, + GdkEventScroll *event, + struct dt_dev_viewport_t *port); + /* * new ui api */ diff --git a/src/views/darkroom.c b/src/views/darkroom.c index b87b8e53fb10..79111710dba4 100644 --- a/src/views/darkroom.c +++ b/src/views/darkroom.c @@ -4257,120 +4257,7 @@ gboolean gesture_pinch(dt_view_t *self, { dt_develop_t *dev = self->data; if(!dev) return FALSE; - (void)state; - - // 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) - // we treat the gesture as a pinch and discard the dx/dy translation that would otherwise - // jitter the cursor anchor. When pan dominates, the score falls below the threshold and - // dx/dy flows through (subject to a small per-axis deadzone for touchpad noise). - // The exponential decay gives hysteresis so a single wobble frame mid-gesture doesn't flip the classification. - static const float PINCH_ZOOM_PAN_DECAY = 0.8f; - static const float PINCH_ZOOM_DOMINANT_PX = 8.0f; - static const float PINCH_PAN_DEADZONE_PX = 4.0f; - static float pinch_begin_tscale = 0.0f; - static float prev_scale = 1.0f; - static float zoom_pan_score = 0.0f; - - if(phase == GDK_TOUCHPAD_GESTURE_PHASE_BEGIN) - { - pinch_begin_tscale = - dt_dev_get_zoom_scale(&dev->full, dev->full.zoom, 1 << dev->full.closeup, FALSE) - * dev->full.ppd; - prev_scale = (float)scale; - zoom_pan_score = 0.0f; - dt_print(DT_DEBUG_INPUT, - "[darkroom pinch] begin x=%.1f y=%.1f scale=%.6f state=0x%x" - " -> begin_tscale=%.6f ppd=%.2f", - x, y, scale, state, pinch_begin_tscale, dev->full.ppd); - return TRUE; - } - else if(phase == GDK_TOUCHPAD_GESTURE_PHASE_END - || phase == GDK_TOUCHPAD_GESTURE_PHASE_CANCEL) - { - dt_print(DT_DEBUG_INPUT, - "[darkroom pinch] %s x=%.1f y=%.1f scale=%.6f state=0x%x", - phase == GDK_TOUCHPAD_GESTURE_PHASE_END ? "end" : "cancel", - x, y, scale, state); - pinch_begin_tscale = 0.0f; - prev_scale = 1.0f; - zoom_pan_score = 0.0f; - return TRUE; - } - - if(phase != GDK_TOUCHPAD_GESTURE_PHASE_UPDATE) - { - dt_print(DT_DEBUG_INPUT, - "[darkroom pinch] unknown phase=%d ignored", phase); - return FALSE; - } - if(pinch_begin_tscale <= 0.0f || scale <= 0.0) - { - dt_print(DT_DEBUG_INPUT, - "[darkroom pinch] update skipped: begin_tscale=%.6f scale=%.6f", - pinch_begin_tscale, scale); - return FALSE; - } - - // On macOS (GDK Quartz), NSEventTypeMagnify never populates dx/dy and the - // gesture focal-point x/y is set once at phase=BEGIN and does not update - // during the gesture — so both approaches to infer translation are zero. - // Pan on macOS therefore arrives as a separate smooth-scroll stream which is - // routed to gesture_pan by _scrolled() in gtk.c. - // On other platforms (Wayland/X11), dx/dy carry the actual translational delta. - float eff_dx = (float)dx; - float eff_dy = (float)dy; - - // Update the zoom-vs-pan dominance score and use it to decide whether the - // dx/dy component is finger-drift wobble (suppress) or real pan (allow). - const float scale_inc = (prev_scale > 0.0f) ? fabsf((float)scale / prev_scale - 1.0f) : 0.0f; - const float zoom_eq_px = scale_inc * 0.5f * (float)dev->full.width; - const float pan_eq_px = sqrtf(eff_dx * eff_dx + eff_dy * eff_dy); - zoom_pan_score = zoom_pan_score * PINCH_ZOOM_PAN_DECAY - + (zoom_eq_px - pan_eq_px); - const gboolean zoom_dominant = zoom_pan_score > PINCH_ZOOM_DOMINANT_PX; - // If zoom is dominant or the pan component is within the deadzone zero that component. - eff_dx = (zoom_dominant || fabsf(eff_dx) < PINCH_PAN_DEADZONE_PX) ? 0.0f : eff_dx; - eff_dy = (zoom_dominant || fabsf(eff_dy) < PINCH_PAN_DEADZONE_PX) ? 0.0f : eff_dy; - prev_scale = (float)scale; - - if(eff_dx != 0.0f || eff_dy != 0.0f) - { - dt_print(DT_DEBUG_INPUT, - "[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); - } - - const float ppd = dev->full.ppd; - const float fitscale = dt_dev_get_zoom_scale(&dev->full, DT_ZOOM_FIT, 1.0f, FALSE); - const float tscalefloor = MIN(0.5f * fitscale * ppd, 1.0f); - const float tscaletop = 16.0f; - const float tscale = CLAMP(pinch_begin_tscale * scale, tscalefloor, tscaletop); - - // Keep pinch fully continuous for a smartphone-like feeling, including at high zoom. - const float zoom_scale = tscale / ppd; - - // Convert root (screen-absolute) pinch coords to center-widget-local. - // This is the coord space dt_dev_zoom_move expects for its built-in cursor - // anchoring (it computes mouse_off via x - border - 0.5 * port->width). - int ox, oy; - gdk_window_get_origin(gtk_widget_get_window(dt_ui_center(darktable.gui->ui)), &ox, &oy); - const float x_local = (float)x - ox; - const float y_local = (float)y - oy; - dt_print(DT_DEBUG_INPUT, - "[darkroom pinch] update x=%.1f y=%.1f (local=%.1f,%.1f origin=%d,%d" - " border=%d port=%dx%d) raw_dx=%.3f raw_dy=%.3f" - " eff_dx=%.3f eff_dy=%.3f score=%.2f zoom_dom=%d scale=%.6f state=0x%x" - " -> tscale=%.6f (floor=%.6f top=%.1f) zoom_scale=%.6f", - x, y, x_local, y_local, ox, oy, - 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); - - return TRUE; + return dt_dev_pinch_zoom(&dev->full, "darkroom", x, y, dx, dy, phase, scale, state); } static void _change_slider_accel_precision(dt_action_t *action) @@ -4554,21 +4441,40 @@ static gboolean _second_window_draw_callback(GtkWidget *widget, return TRUE; } +static gboolean _second_window_event_callback(GtkWidget *widget, + GdkEvent *event, + dt_develop_t *dev) +{ + if(dev->gui_leaving) return FALSE; + + // Pick the same viewport the scroll/mouse handlers use: pinned if set, else preview2. + dt_develop_t *pinned_dev = dev->preview2_pinned ? dev->preview2_pinned_dev : NULL; + if(pinned_dev && pinned_dev->gui_leaving) pinned_dev = NULL; + dt_dev_viewport_t *port = pinned_dev ? &pinned_dev->preview2 : &dev->preview2; + + return dt_gui_handle_touchpad_pinch_event(widget, event, "second window", port); +} + static gboolean _second_window_scrolled_callback(GtkWidget *widget, GdkEventScroll *event, dt_develop_t *dev) { if(dev->gui_leaving) return TRUE; + // Pick the same viewport the mouse/pinch handlers use: pinned if set, else preview2. + dt_develop_t *pinned_dev = dev->preview2_pinned ? dev->preview2_pinned_dev : NULL; + if(pinned_dev && pinned_dev->gui_leaving) pinned_dev = NULL; + dt_dev_viewport_t *port = pinned_dev ? &pinned_dev->preview2 : &dev->preview2; + + // Route smooth two-finger touchpad scrolls as pan, mirroring the main window's + // _scrolled() behavior. Without this the discrete fallback below would zoom + // on every smooth-scroll frame. + if(dt_gui_handle_touchpad_scroll_pan_event(widget, event, port)) + return TRUE; + int delta_y; if(dt_gui_get_scroll_unit_delta(event, &delta_y)) { - // Use pinned viewport if pinned, otherwise main dev's preview2 - dt_develop_t *pinned_dev = dev->preview2_pinned ? dev->preview2_pinned_dev : NULL; - if(pinned_dev && pinned_dev->gui_leaving) pinned_dev = NULL; - - dt_dev_viewport_t *port = pinned_dev ? &pinned_dev->preview2 : &dev->preview2; - const gboolean constrained = !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); @@ -4994,11 +4900,14 @@ static void _darkroom_display_second_window(dt_develop_t *dev) | GDK_BUTTON_RELEASE_MASK | GDK_ENTER_NOTIFY_MASK | GDK_LEAVE_NOTIFY_MASK + | GDK_TOUCHPAD_GESTURE_MASK | darktable.gui->scroll_mask); /* connect callbacks */ g_signal_connect(G_OBJECT(dev->preview2.widget), "draw", G_CALLBACK(_second_window_draw_callback), dev); + g_signal_connect(G_OBJECT(dev->preview2.widget), "event", + G_CALLBACK(_second_window_event_callback), dev); g_signal_connect(G_OBJECT(dev->preview2.widget), "scroll-event", G_CALLBACK(_second_window_scrolled_callback), dev); g_signal_connect(G_OBJECT(dev->preview2.widget), "button-press-event",