/* This file is part of darktable, Copyright (C) 2011-2026 darktable developers. darktable is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. darktable is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with darktable. If not, see . */ #include "common/gdk_event_utils.h" #include "bauhaus/bauhaus.h" #include "common/darktable.h" #include "common/debug.h" #include "common/image_cache.h" #include "control/conf.h" #include "control/control.h" #include "develop/develop.h" #include "dtgtk/drawingarea.h" #include "gui/accelerators.h" #include "gui/gtk.h" #include "libs/lib.h" #include "libs/lib_api.h" #include DT_MODULE(1) #define DT_NAVIGATION_INSET 5 typedef struct dt_lib_navigation_t { int dragging; int zoom_w, zoom_h; GtkWidget *zoom; } dt_lib_navigation_t; /* expose function for navigation module */ static gboolean _lib_navigation_draw_callback(GtkWidget *widget, cairo_t *crf, gpointer user_data); /* motion notify callback handler*/ static void _lib_navigation_motion_notify_callback(GtkEventControllerMotion *controller, double x, double y, dt_lib_module_t *self); /* scroll callback */ static void _lib_navigation_scroll_callback(GtkEventControllerScroll *controller, double dx, double dy, dt_lib_module_t *self); /* zoom begin callback */ static void _lib_navigation_pinch_begin_callback(GtkGesture *gesture, GdkEventSequence* sequence, dt_lib_module_t *self); /* zoom scale-changed callback */ static void _lib_navigation_pinch_scale_callback(GtkGesture *gesture, gdouble scale, dt_lib_module_t *self); /* button press callback */ static void _lib_navigation_button_press_callback(GtkGestureSingle *gesture, int n_press, double x, double y, dt_lib_module_t *self); /* button release callback */ static void _lib_navigation_button_release_callback(GtkGestureSingle *gesture, int n_press, double x, double y, dt_lib_module_t *self); /* leave notify callback */ static void _lib_navigation_leave_notify_callback(GtkEventControllerMotion *controller, dt_lib_module_t *self); /* helper function for position set */ static void _lib_navigation_set_position(struct dt_lib_module_t *self, const double x, const double y, const int wd, const int ht); const char *name(dt_lib_module_t *self) { return _("navigation"); } dt_view_type_flags_t views(dt_lib_module_t *self) { return DT_VIEW_DARKROOM; } uint32_t container(dt_lib_module_t *self) { return DT_UI_CONTAINER_PANEL_LEFT_TOP; } gboolean expandable(dt_lib_module_t *self) { return FALSE; } int position(const dt_lib_module_t *self) { return 1001; } static void _lib_navigation_control_redraw_callback(gpointer instance, dt_lib_module_t *self) { dt_lib_navigation_t *d = self->data; dt_dev_viewport_t *port = &darktable.develop->full; dt_dev_zoom_t zoom; int closeup; dt_dev_get_viewport_params(port, &zoom, &closeup, NULL, NULL); const float cur_scale = dt_dev_get_zoom_scale(port, zoom, 1<ppd); DT_ENTER_GUI_UPDATE(); if(!dt_bauhaus_combobox_set_from_text(d->zoom, zoomline)) { dt_bauhaus_combobox_set_text(d->zoom, zoomline); dt_bauhaus_combobox_set(d->zoom, -1); } DT_LEAVE_GUI_UPDATE(); g_free(zoomline); gtk_widget_queue_draw(gtk_bin_get_child(GTK_BIN(self->widget))); } static void _lib_navigation_collapse_callback(dt_action_t *action) { dt_lib_module_t *self = darktable.lib->proxy.navigation.module; // Get the state const gboolean visible = dt_lib_is_visible(self); // Inverse the visibility dt_lib_set_visible(self, !visible); } static void _zoom_changed(GtkWidget *widget, gpointer user_data); int _entry_select(GtkWidget *w, const char *text, const int delta, struct dt_iop_module_t **module) { const int text_len = strlen(text); int val = -1; int index = 0; // move to first digit to ensure we skip a possible % as prefix for(int k=0; k 0) { if(val < 50) ret = 3; // 50 else if(val < 100) ret = 4; // 100 else if(val < 200) ret = 5; // 200 else if(val < 400) ret = 6; // 400 else if(val < 800) ret = 7; // 800 else if(val < 1600) ret = 8; // 1600 } else { if(val < 50) ret = 2; // fit else if(val < 100) ret = 3; // 50 else if(val < 200) ret = 4; // 100 else if(val < 400) ret = 5; // 200 else if(val < 800) ret = 6; // 400 else if(val < 1600) ret = 7; // 800 } return ret; } void gui_init(dt_lib_module_t *self) { /* initialize ui widgets */ dt_lib_navigation_t *d = g_malloc0(sizeof(dt_lib_navigation_t)); self->data = (void *)d; /* create drawingarea */ GtkWidget *thumbnail = dt_ui_resize_wrap(NULL, 0, "plugins/darkroom/navigation/graphheight"); gtk_widget_set_tooltip_text (thumbnail, _("navigation\nclick or drag to position zoomed area in center view")); /* connect callbacks */ dt_gui_add_class(thumbnail, "dt_transparent_background"); g_signal_connect(G_OBJECT(thumbnail), "draw", G_CALLBACK(_lib_navigation_draw_callback), self); dt_gui_connect_click_all(thumbnail, _lib_navigation_button_press_callback, _lib_navigation_button_release_callback, self); dt_gui_connect_scroll(thumbnail, GTK_EVENT_CONTROLLER_SCROLL_BOTH_AXES, _lib_navigation_scroll_callback, self); // FIXME: Make helper function for zoom gesture. If discrete zooming // as here is a well-used pattern, make proxy which handles // this so don't need to implement a "begin" handler here. GtkGesture *zoom_gesture = gtk_gesture_zoom_new(thumbnail); dt_gui_add_controller(thumbnail, zoom_gesture); g_signal_connect(zoom_gesture, "begin", G_CALLBACK(_lib_navigation_pinch_begin_callback), self); g_signal_connect(zoom_gesture, "scale-changed", G_CALLBACK(_lib_navigation_pinch_scale_callback), self); dt_gui_connect_motion(thumbnail, _lib_navigation_motion_notify_callback, NULL, _lib_navigation_leave_notify_callback, self); /* set size of navigation draw area */ // gtk_widget_set_size_request(thumbnail, -1, DT_PIXEL_APPLY_DPI(175)); gtk_widget_set_name(GTK_WIDGET(thumbnail), "navigation-module"); dt_action_t *ac = dt_action_define(&darktable.view_manager->proxy.darkroom.view->actions, NULL, N_("hide navigation thumbnail"), thumbnail, NULL); dt_action_register(ac, NULL, _lib_navigation_collapse_callback, GDK_KEY_N, GDK_CONTROL_MASK | GDK_SHIFT_MASK); /* connect a redraw callback to control draw all and preview pipe finish signals */ DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_DEVELOP_PREVIEW_PIPE_FINISHED, _lib_navigation_control_redraw_callback); DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_CONTROL_NAVIGATION_REDRAW, _lib_navigation_control_redraw_callback); DT_BAUHAUS_COMBOBOX_NEW_FULL(d->zoom, darktable.view_manager->proxy.darkroom.view, NULL, N_("zoom"), _("image zoom level"), -1, _zoom_changed, NULL, N_("small"), N_("fit"), NC_("navigationbox", "fill"), N_("50%"), N_("100%"), N_("200%"), N_("400%"), N_("800%"), N_("1600%")); // default zoom is fit, used if the entry_select_fct cannot get the proper zoom // (should not happen in normal behavior) dt_bauhaus_combobox_set_default(d->zoom, 1); dt_bauhaus_combobox_add_entry_select_fct(d->zoom, _entry_select); ac = dt_action_section(&darktable.view_manager->proxy.darkroom.view->actions, N_("zoom")); dt_shortcut_register(ac, 0, DT_ACTION_EFFECT_COMBO_SEPARATOR + 2, GDK_KEY_3, GDK_MOD1_MASK); dt_shortcut_register(ac, 0, DT_ACTION_EFFECT_COMBO_SEPARATOR + 3, GDK_KEY_2, GDK_MOD1_MASK); dt_bauhaus_combobox_set_editable(d->zoom, TRUE); dt_bauhaus_widget_hide_label(d->zoom); gtk_widget_set_halign(d->zoom, GTK_ALIGN_END); gtk_widget_set_valign(d->zoom, GTK_ALIGN_END); gtk_widget_set_name(d->zoom, "nav-zoom"); self->widget = gtk_overlay_new(); gtk_container_add(GTK_CONTAINER(self->widget), thumbnail); gtk_overlay_add_overlay(GTK_OVERLAY(self->widget), d->zoom); dt_gui_add_class(self->widget, "dt_plugin_ui_main"); gtk_widget_show_all(self->widget); darktable.lib->proxy.navigation.module = self; } void gui_cleanup(dt_lib_module_t *self) { g_free(self->data); self->data = NULL; } // Draw a small arrow on the thumbnail border pointing toward the viewport // centre when it has been panned outside the image (e.g. reaching off-image // mask handles), where the view box would otherwise shrink to nothing and // vanish. Expects the current transform to map the thumbnail image to // [0,wd]x[0,ht]; scale is that transform's scale (to keep the outline 1px). static void _draw_offview_arrow(cairo_t *cr, const int wd, const int ht, const float zoom_x, const float zoom_y, const float scale) { const float cx = wd * (0.5f + zoom_x); const float cy = ht * (0.5f + zoom_y); if(cx >= 0.0f && cx <= wd && cy >= 0.0f && cy <= ht) return; // view centre still inside the thumbnail: the box itself suffices const float ox = 0.5f * wd, oy = 0.5f * ht; float dx = cx - ox, dy = cy - oy; const float dlen = hypotf(dx, dy); if(dlen <= 1e-3f) return; dx /= dlen; dy /= dlen; // where the centre->view ray exits the image rectangle float t = 1e9f; if(dx > 0.0f) t = fminf(t, (wd - ox) / dx); else if(dx < 0.0f) t = fminf(t, (0.0f - ox) / dx); if(dy > 0.0f) t = fminf(t, (ht - oy) / dy); else if(dy < 0.0f) t = fminf(t, (0.0f - oy) / dy); t = fmaxf(0.0f, t); // Draw a chevron ( ">" shape ) whose apex points outward along the view // direction: an open two-armed stroke reads its direction more clearly than // a solid triangle. const float a = fminf(wd, ht) * 0.08f; // chevron reach in thumbnail units const float tipx = ox + dx * t - dx * a * 0.3f; // apex just inside the border const float tipy = oy + dy * t - dy * a * 0.3f; const float perpx = -dy, perpy = dx; const float spread = a * 0.85f; // half-width of the arms (~90 deg opening) const float ax1 = tipx - dx * a + perpx * spread, ay1 = tipy - dy * a + perpy * spread; const float ax2 = tipx - dx * a - perpx * spread, ay2 = tipy - dy * a - perpy * spread; cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND); cairo_set_line_join(cr, CAIRO_LINE_JOIN_ROUND); cairo_move_to(cr, ax1, ay1); cairo_line_to(cr, tipx, tipy); cairo_line_to(cr, ax2, ay2); // dark halo first, then a white core, so it reads on any background cairo_set_source_rgba(cr, 0., 0., 0., 0.7); cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(4) / scale); cairo_stroke_preserve(cr); cairo_set_source_rgb(cr, 1., 1., 1.); cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(2) / scale); cairo_stroke(cr); } static gboolean _lib_navigation_draw_callback(GtkWidget *widget, cairo_t *crf, gpointer user_data) { GtkAllocation allocation; gtk_widget_get_allocation(widget, &allocation); int width = allocation.width, height = allocation.height; dt_develop_t *dev = darktable.develop; /* get the current style */ cairo_surface_t *cst = dt_cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width, height); cairo_t *cr = cairo_create(cst); GtkStyleContext *context = gtk_widget_get_style_context(widget); gtk_render_background(context, cr, 0, 0, allocation.width, allocation.height); /* draw navigation image if available */ if(dev->preview_pipe->backbuf && dev->image_storage.id == dev->preview_pipe->output_imgid) { dt_pthread_mutex_t *mutex = &dev->preview_pipe->backbuf_mutex; dt_pthread_mutex_lock(mutex); cairo_save(cr); const int wd = dev->preview_pipe->backbuf_width; const int ht = dev->preview_pipe->backbuf_height; const float scale = fminf(width / (float)wd, height / (float)ht); const int stride = cairo_format_stride_for_width(CAIRO_FORMAT_RGB24, wd); cairo_surface_t *surface = cairo_image_surface_create_for_data(dev->preview_pipe->backbuf, CAIRO_FORMAT_RGB24, wd, ht, stride); cairo_translate(cr, width / 2.0, height / 2.0f); cairo_scale(cr, scale, scale); cairo_translate(cr, -.5f * wd, -.5f * ht); cairo_rectangle(cr, 0, 0, wd, ht); cairo_set_source_surface(cr, surface, 0, 0); cairo_pattern_set_filter(cairo_get_source(cr), CAIRO_FILTER_GOOD); cairo_fill(cr); // draw box where we are float zoom_x, zoom_y, boxw, boxh; if(dt_dev_get_zoom_bounds(&dev->full, &zoom_x, &zoom_y, &boxw, &boxh)) { // Add a dark overlay on the picture to make it fade cairo_rectangle(cr, 0, 0, wd, ht); cairo_set_source_rgba(cr, 0, 0, 0, 0.5); cairo_fill(cr); // Repaint the original image in the area of interest. Isolate the box // clip/translate in its own save so the off-view arrow below is drawn in // plain thumbnail coordinates. cairo_save(cr); cairo_set_source_surface(cr, surface, 0, 0); cairo_translate(cr, wd * (.5f + zoom_x), ht * (.5f + zoom_y)); boxw *= wd; boxh *= ht; cairo_rectangle(cr, -boxw / 2 - 1, -boxh / 2 - 1, boxw + 2, boxh + 2); cairo_clip_preserve(cr); cairo_fill_preserve(cr); // Paint the external border in black cairo_set_source_rgb(cr, 0., 0., 0.); cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(1)); cairo_stroke(cr); // Paint the internal border in white cairo_set_source_rgb(cr, 1., 1., 1.); cairo_rectangle(cr, -boxw / 2, -boxh / 2, boxw, boxh); cairo_stroke(cr); cairo_restore(cr); // While editing a mask the viewport can be panned outside the image; the // view box then shrinks against the thumbnail edge and disappears, losing // any sense of where one is looking. Point an arrow the way it has gone. _draw_offview_arrow(cr, wd, ht, zoom_x, zoom_y, scale); } cairo_restore(cr); dt_pthread_mutex_unlock(mutex); } /* blit memsurface into widget */ cairo_destroy(cr); cairo_set_source_surface(crf, cst, 0, 0); cairo_paint(crf); cairo_surface_destroy(cst); return FALSE; } static void _lib_navigation_set_position(dt_lib_module_t *self, const double x, const double y, const int wd, const int ht) { dt_lib_navigation_t *d = self->data; if(d->dragging) { const int inset = DT_NAVIGATION_INSET; const float width = wd - 2 * inset; const float height = ht - 2 * inset; dt_dev_viewport_t *port = &darktable.develop->full; int iwd, iht; dt_dev_get_processed_size(port, &iwd, &iht); float zoom_x = fmaxf( -.5, fminf(((x - inset) / width - .5f) / (iwd * fminf(wd / (float)iwd, ht / (float)iht) / (float)wd), .5)); float zoom_y = fmaxf( -.5, fminf(((y - inset) / height - .5f) / (iht * fminf(wd / (float)iwd, ht / (float)iht) / (float)ht), .5)); dt_dev_zoom_move(port, DT_ZOOM_POSITION, 0.0f, 0, zoom_x, zoom_y, TRUE); } } static void _lib_navigation_motion_notify_callback(GtkEventControllerMotion *controller, double x, double y, dt_lib_module_t *self) { GtkWidget *widget = dt_gui_get_widget(controller); GtkAllocation allocation; gtk_widget_get_allocation(widget, &allocation); _lib_navigation_set_position(self, x, y, allocation.width, allocation.height); } static void _zoom_changed(GtkWidget *widget, gpointer user_data) { int val = dt_bauhaus_combobox_get(widget); if(val == -1 && 1 != sscanf(dt_bauhaus_combobox_get_text(widget), "%d", &val)) return; dt_develop_t *dev = darktable.develop; if(!dev) return; const float ppd = darktable.gui->ppd; dt_dev_viewport_t *port = &dev->full; float scale = 1.0f; int closeup = 0; dt_dev_zoom_t zoom = DT_ZOOM_FREE; if(val == 0u) // small scale = 0.5 * dt_dev_get_zoom_scale(port, DT_ZOOM_FIT, 1.0, FALSE); else if(val == 1u || val == -1u) // fit to screen zoom = DT_ZOOM_FIT; else if(val == 2u) // fill screen zoom = DT_ZOOM_FILL; else if(val == 3u) // 50% scale = 0.5f / ppd; else if(val == 4u && ppd != 1.0f) // 100% scale = 1.0f / ppd; else if(val >= 4u && val <= 8u) // 100%, 200%, 400%, 800%, 1600% { zoom = DT_ZOOM_1; closeup = val - 5 + (ppd == 1.0f); } else scale = val / 100.0f * ppd; // 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, const gdouble in_x, const gdouble in_y, gdouble *out_x, gdouble *out_y) { dt_develop_t *dev = darktable.develop; if(!dev->preview_pipe->backbuf || dev->image_storage.id != dev->preview_pipe->output_imgid) return FALSE; dt_dev_viewport_t *port = &dev->full; GtkAllocation alloc_nav, alloc_center; GtkWidget *nav = gtk_event_controller_get_widget(controller); GtkWidget *center = dt_ui_center(darktable.gui->ui); gtk_widget_get_allocation(nav, &alloc_nav); gtk_widget_get_allocation(center, &alloc_center); int procw, proch; dt_dev_get_processed_size(port, &procw, &proch); // navigation widget image dimensions const double nav_scale = MIN((double)alloc_nav.width / procw, (double)alloc_nav.height / proch); const double nav_img_w = procw * nav_scale; const double nav_img_h = proch * nav_scale; // nav widget coords to nav widget's image coords const double x_nav_img = in_x - (alloc_nav.width - nav_img_w) / 2.0; const double y_nav_img = in_y - (alloc_nav.height - nav_img_h) / 2.0; // visible part of image in center widget in navigation widget // coords with zoom-to-fit defaults float zoom_x, zoom_y, boxw, boxh; if(!dt_dev_get_zoom_bounds(port, &zoom_x, &zoom_y, &boxw, &boxh)) { // zoom-to-fit zoom_x = zoom_y = 0.0; boxw = boxh = 1.0; } const double vis_w = nav_img_w * boxw; const double vis_h = nav_img_h * boxh; const double vis_x = nav_img_w * (0.5 + zoom_x) - vis_w * 0.5; const double vis_y = nav_img_h * (0.5 + zoom_y) - vis_h * 0.5; // navigation image coords to center widget coords *out_x = (x_nav_img - vis_x) / vis_w * alloc_center.width; *out_y = (y_nav_img - vis_y) / vis_h * alloc_center.height; return TRUE; } static void _lib_navigation_scroll_callback(GtkEventControllerScroll *controller, const double dx, const double dy, // FIXME: if unused don't pass dt_lib_module_t *self) { GdkEvent *event = dt_gui_get_current_event(GTK_EVENT_CONTROLLER(controller)); if(event) { GdkDevice *device = dt_gdk_event_get_source_device(event); if(device && gdk_device_get_source(device) == GDK_SOURCE_TOUCHPAD && dt_gdk_event_get_scroll_direction(event) == GDK_SCROLL_SMOOTH) { dt_dev_zoom_move(&darktable.develop->full, DT_ZOOM_MOVE, 15.0, 0, dx, dy, TRUE); } else { const gboolean constrain = !dt_modifier_eq(controller, GDK_CONTROL_MASK); gdouble x, y; if(_lib_navigation_widget_to_center(GTK_EVENT_CONTROLLER(controller), dt_gdk_event_get_x(event), dt_gdk_event_get_y(event), &x, &y)) { const gboolean zoom_in = dt_gui_scroll_zoom_delta(event, dx, dy) > 0.0f; dt_dev_zoom_move(&darktable.develop->full, DT_ZOOM_SCROLL, 0.0f, zoom_in ? 1 : 0, x, y, constrain); } } #if !GTK_CHECK_VERSION(4, 0, 0) gdk_event_free(event); #endif } } static gdouble pinch_last_scale = 1.0; static void _lib_navigation_pinch_begin_callback(GtkGesture *gesture, GdkEventSequence* sequence, dt_lib_module_t *self) { gtk_gesture_set_state(gesture, GTK_EVENT_SEQUENCE_CLAIMED); pinch_last_scale = 1.0; } static void _lib_navigation_pinch_scale_callback(GtkGesture *gesture, const gdouble scale, dt_lib_module_t *self) { gdouble wx, wy, cx, cy; if(gtk_gesture_get_bounding_box_center(gesture, &wx, &wy) && _lib_navigation_widget_to_center(GTK_EVENT_CONTROLLER(gesture), wx, wy, &cx, &cy)) { const double pinch_step_ratio = 1.1; const double ratio = scale / pinch_last_scale; int zoom_step = -1; if(ratio > pinch_step_ratio) zoom_step = 1; else if(ratio < 1.0 / pinch_step_ratio) zoom_step = 0; if(zoom_step >= 0) { dt_dev_zoom_move(&darktable.develop->full, DT_ZOOM_SCROLL, 0.0f, zoom_step, cx, cy, TRUE); pinch_last_scale = scale; } } } static void _lib_navigation_button_press_callback(GtkGestureSingle *gesture, int n_press, double x, double y, dt_lib_module_t *self) { dt_lib_navigation_t *d = self->data; GtkWidget *widget = dt_gui_get_widget(gesture); GtkAllocation allocation; gtk_widget_get_allocation(widget, &allocation); const guint button = gtk_gesture_single_get_current_button(gesture); if(button == GDK_BUTTON_PRIMARY || button == GDK_BUTTON_SECONDARY) { d->dragging = 1; _lib_navigation_set_position(self, x, y, allocation.width, allocation.height); } else if(button == GDK_BUTTON_MIDDLE) { GtkWidget *center = dt_ui_center(darktable.gui->ui); GtkAllocation center_alloc; gtk_widget_get_allocation(center, ¢er_alloc); GdkEvent *ev = gdk_event_new(GDK_BUTTON_PRESS); gdk_event_set_screen(ev, gtk_widget_get_screen(center)); ev->button.window = gtk_widget_get_window(center); ev->button.button = GDK_BUTTON_MIDDLE; ev->button.x = x * (gdouble)center_alloc.width / allocation.width; ev->button.y = y * (gdouble)center_alloc.height / allocation.height; ev->button.type = GDK_BUTTON_PRESS; ev->button.time = GDK_CURRENT_TIME; if(ev->button.window) g_object_ref(ev->button.window); gtk_widget_event(center, ev); gdk_event_free(ev); } } static void _lib_navigation_button_release_callback(GtkGestureSingle *gesture, int n_press, double x, double y, dt_lib_module_t *self) { dt_lib_navigation_t *d = self->data; d->dragging = 0; } static void _lib_navigation_leave_notify_callback(GtkEventControllerMotion *controller, dt_lib_module_t *self) { } // clang-format off // modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py // vim: shiftwidth=2 expandtab tabstop=2 cindent // kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified; // clang-format on