From dca79290e89667948c1b627e68eaf78f5948ab17 Mon Sep 17 00:00:00 2001 From: Daniele Pighin Date: Mon, 8 Dec 2025 16:56:24 +0100 Subject: [PATCH 1/2] Adds controls to pin an image in the 2nd darkroom window and to keep the 2nd window on top. --- data/themes/darktable.css | 6 +- src/develop/develop.c | 180 +++++++++++++++- src/develop/develop.h | 26 ++- src/views/darkroom.c | 444 +++++++++++++++++++++++++++++++++++--- 4 files changed, 625 insertions(+), 31 deletions(-) diff --git a/data/themes/darktable.css b/data/themes/darktable.css index 2acfb1764e97..fa902b804ff6 100644 --- a/data/themes/darktable.css +++ b/data/themes/darktable.css @@ -517,14 +517,16 @@ separator .dt_accels_stick, #filter-colors-box .dt_module_btn, #header-toolbar .dt_module_btn, -#footer-toolbar .dt_module_btn +#footer-toolbar .dt_module_btn, +#second_window .dt_accels_btn { min-height: 1.7em; min-width: 1.7em; border: 0.08em solid transparent; } -#footer-toolbar button:checked +#footer-toolbar button:checked, +#second_window button:checked { border: 0.08em solid shade(@button_border, 1.6); } diff --git a/src/develop/develop.c b/src/develop/develop.c index 58b0c296eaaf..0ef1a5886830 100644 --- a/src/develop/develop.c +++ b/src/develop/develop.c @@ -152,6 +152,15 @@ void dt_dev_init(dt_develop_t *dev, 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; dev->full.zoom_scale = dev->preview2.zoom_scale = 1.0f; + + // Initialize pinned image state + dev->preview2_pinned = FALSE; + dev->preview2_pinned_imgid = -1; + dev->preview2_pinned_surface = NULL; + dev->preview2_pinned_base_scale = 1.0f; + dev->preview2_pinned_scale = 1.0f; + dev->preview2_pinned_off_x = 0.0f; + dev->preview2_pinned_off_y = 0.0f; } void dt_dev_cleanup(dt_develop_t *dev) @@ -201,8 +210,16 @@ void dt_dev_cleanup(dt_develop_t *dev) dev->allprofile_info = g_list_delete_link(dev->allprofile_info, dev->allprofile_info); } dt_pthread_mutex_destroy(&dev->history_mutex); - free(dev->histogram_pre_tonecurve); - free(dev->histogram_pre_levels); + if(dev->histogram_pre_tonecurve) free(dev->histogram_pre_tonecurve); + if(dev->histogram_pre_levels) free(dev->histogram_pre_levels); + dev->histogram_pre_tonecurve = dev->histogram_pre_levels = NULL; + + // Clean up pinned image surface + if(dev->preview2_pinned_surface) + { + cairo_surface_destroy(dev->preview2_pinned_surface); + dev->preview2_pinned_surface = NULL; + } g_list_free_full(dev->forms, (void (*)(void *))dt_masks_free_form); g_list_free_full(dev->allforms, (void (*)(void *))dt_masks_free_form); @@ -263,6 +280,165 @@ void dt_dev_invalidate_all(dt_develop_t *dev) dev->timestamp++; } +void dt_dev_toggle_preview2_pinned(dt_develop_t *dev) +{ + if(!dev) return; + + dev->preview2_pinned = !dev->preview2_pinned; + + if(dev->preview2_pinned) + { + // Pinning the current image + dev->preview2_pinned_imgid = dev->image_storage.id; + + // If we already have a surface, clean it up first + if(dev->preview2_pinned_surface) + { + cairo_surface_destroy(dev->preview2_pinned_surface); + dev->preview2_pinned_surface = NULL; + } + + // Get the window dimensions for computing the base scale + gint window_width = 800; + gint window_height = 600; + if(dev->preview2.widget && gtk_widget_get_window(dev->preview2.widget)) + { + GdkWindow *window = gtk_widget_get_window(dev->preview2.widget); + window_width = gdk_window_get_width(window); + window_height = gdk_window_get_height(window); + } + + // Get the actual final image dimensions after all transformations (crop, rotate, etc.) + int final_width = 0; + int final_height = 0; + if(!dt_image_get_final_size(dev->image_storage.id, &final_width, &final_height)) + { + // Fallback to image storage dimensions if we can't get final size + final_width = dev->image_storage.final_width; + final_height = dev->image_storage.final_height; + } + + // Use the actual image dimensions for rendering at native resolution + const size_t max_width = MAX(final_width, 800); // Use actual width, minimum 800 + const size_t max_height = MAX(final_height, 600); // Use actual height, minimum 600 + + // Show toast message while rendering + dt_toast_log(_("rendering pinned image...")); + + uint8_t *buf = NULL; + size_t buf_width = 0; + size_t buf_height = 0; + float scale = 1.0f; + + // Render the full image with current develop settings up to the current history position + dt_dev_image(dev->image_storage.id, + max_width, max_height, + dev->history_end, // use current history position + &buf, &scale, + &buf_width, &buf_height, + NULL, // no zoom position (render whole image) + -1, // no snapshot + NULL, // no module filter + DT_DEVICE_NONE, // CPU processing + FALSE); // don't use finalscale + + if(buf && buf_width > 0 && buf_height > 0) + { + // Create a cairo surface from the rendered buffer + // The buffer is BGRA32 format (uint8_t) + cairo_surface_t *surface = cairo_image_surface_create( + CAIRO_FORMAT_RGB24, + buf_width, + buf_height); + + if(cairo_surface_status(surface) == CAIRO_STATUS_SUCCESS) + { + unsigned char *surface_data = cairo_image_surface_get_data(surface); + const int stride = cairo_image_surface_get_stride(surface); + + // Copy the rendered buffer to the cairo surface + // dt_dev_image returns BGRA data in uint8_t format + for(size_t y = 0; y < buf_height; y++) + { + uint8_t *src = buf + y * buf_width * 4; + uint8_t *dst = surface_data + y * stride; + memcpy(dst, src, buf_width * 4); + } + + cairo_surface_mark_dirty(surface); + + dev->preview2_pinned_surface = surface; + + /* compute base scale to fit the rendered image into window */ + if(window_width > 0 && window_height > 0) + { + const float scale_w = (float)window_width / (float)buf_width; + const float scale_h = (float)window_height / (float)buf_height; + dev->preview2_pinned_base_scale = MIN(scale_w, scale_h); + } + else + { + dev->preview2_pinned_base_scale = 1.0f; + } + + // Copy current second-window zoom and position to pinned image for seamless transition + dt_dev_zoom_t cur_zoom; + int cur_closeup; + float cur_zoom_x = 0.0f, cur_zoom_y = 0.0f; + dt_dev_get_viewport_params(&dev->preview2, &cur_zoom, &cur_closeup, &cur_zoom_x, &cur_zoom_y); + // Current zoom scale without ppd to match cairo logical coordinates + const float cur_scale = dt_dev_get_zoom_scale(&dev->preview2, cur_zoom, 1 << cur_closeup, FALSE); + + // User scale relative to base fit scale so that base*user == current scale + dev->preview2_pinned_scale = (dev->preview2_pinned_base_scale > 0.0f) + ? (cur_scale / dev->preview2_pinned_base_scale) + : 1.0f; + + // Offsets in window pixels: match original pan exactly. + // Window offset equals zoom_x * (scaled image width) and similarly for height. + const float total_scale = dev->preview2_pinned_base_scale * dev->preview2_pinned_scale; + // Note: positive zoom_x means the image center moves right in viewport; + // to match that, the image must translate left. Hence the negative sign. + dev->preview2_pinned_off_x = -cur_zoom_x * ((float)buf_width * total_scale); + dev->preview2_pinned_off_y = -cur_zoom_y * ((float)buf_height * total_scale); + + // If image fits entirely, keep centered (ignore offsets) + const float scaled_img_w = buf_width * total_scale; + const float scaled_img_h = buf_height * total_scale; + if(scaled_img_w <= window_width) dev->preview2_pinned_off_x = 0.0f; + if(scaled_img_h <= window_height) dev->preview2_pinned_off_y = 0.0f; + + dt_toast_log(_("pinned image rendered")); + } + else + { + dt_toast_log(_("failed to create surface for pinned image")); + } + + // Free the rendered buffer + dt_free_align(buf); + } + else + { + dt_toast_log(_("failed to render pinned image")); + } + } + else + { + // Unpinning - clear the pinned image ID and surface + dev->preview2_pinned_imgid = -1; + if(dev->preview2_pinned_surface) + { + cairo_surface_destroy(dev->preview2_pinned_surface); + dev->preview2_pinned_surface = NULL; + } + } + + // Force a redraw of the second window + if(dev->preview2.widget) + gtk_widget_queue_draw(dev->preview2.widget); +} + void dt_dev_invalidate_preview(dt_develop_t *dev) { dev->preview_pipe->status = DT_DEV_PIXELPIPE_DIRTY; diff --git a/src/develop/develop.h b/src/develop/develop.h index ab2bfb45a59b..823c2b5a95b8 100644 --- a/src/develop/develop.h +++ b/src/develop/develop.h @@ -126,6 +126,9 @@ typedef struct dt_dev_viewport_t // image processing pipeline with caching struct dt_dev_pixelpipe_t *pipe; + + // Pin button for the second window + GtkWidget *pin_button; } dt_dev_viewport_t; /* keep track on what and where we do chromatic adaptation, used @@ -352,6 +355,20 @@ typedef struct dt_develop_t gboolean darkroom_mouse_in_center_area; // TRUE if the mouse cursor is in center area GList *module_filter_out; + + // Pinned image for second window + gboolean preview2_pinned; // Whether the second window is pinned to a specific image + dt_imgid_t preview2_pinned_imgid; // The ID of the pinned image + cairo_surface_t *preview2_pinned_surface; // Snapshot of the pinned image + /* transform state for pinned surface (independent of preview2 viewport) + * base_scale: scale used to fit the image into the window at pin time + * scale: user-applied zoom multiplier on top of base_scale + * off_x/off_y: pan offsets in image pixels (applied after scaling) + */ + float preview2_pinned_base_scale; + float preview2_pinned_scale; + float preview2_pinned_off_x; + float preview2_pinned_off_y; } dt_develop_t; void dt_dev_init(dt_develop_t *dev, gboolean gui_attached); @@ -413,8 +430,15 @@ void dt_dev_invalidate_history_module(GList *list, void dt_dev_invalidate(dt_develop_t *dev); // also invalidates preview (which is unaffected by resize/zoom/pan) +void dt_dev_invalidate_preview(dt_develop_t *dev); void dt_dev_invalidate_all(dt_develop_t *dev); -void dt_dev_set_histogram(dt_develop_t *dev); + +/** + * Toggle the pinned state of the second preview window. + * When pinned, the second window will continue to show the current image + * even when the user navigates to other images. + */ +void dt_dev_toggle_preview2_pinned(dt_develop_t *dev); void dt_dev_set_histogram_pre(dt_develop_t *dev); void dt_dev_reprocess_all(dt_develop_t *dev); void dt_dev_reprocess_center(dt_develop_t *dev); diff --git a/src/views/darkroom.c b/src/views/darkroom.c index 79024b965534..7e2f8eb400e2 100644 --- a/src/views/darkroom.c +++ b/src/views/darkroom.c @@ -3805,19 +3805,77 @@ static gboolean _second_window_draw_callback(GtkWidget *widget, cairo_t *cri, dt_develop_t *dev) { - cairo_set_source_rgb(cri, 0.2, 0.2, 0.2); + // Set background + dt_gui_gtk_set_source_rgb(cri, DT_GUI_COLOR_DARKROOM_BG); + cairo_paint(cri); - if(dev->preview2.pipe->backbuf) // do we have an image? + // Check if we should show a pinned image or the current preview + if(dev->preview2_pinned && dev->preview2_pinned_surface) { - // draw image - dt_gui_gtk_set_source_rgb(cri, DT_GUI_COLOR_DARKROOM_BG); - cairo_paint(cri); + // Get surface dimensions (already in logical pixels for RGB24 surface) + int img_width = cairo_image_surface_get_width(dev->preview2_pinned_surface); + int img_height = cairo_image_surface_get_height(dev->preview2_pinned_surface); + + if (img_width > 0 && img_height > 0) + { + // Get widget dimensions in logical (widget) pixel space + GtkAllocation allocation; + gtk_widget_get_allocation(widget, &allocation); + const float width = (float)allocation.width; // logical pixels + const float height = (float)allocation.height; // logical pixels + + // compute total scale and offsets from pinned transform stored in dev + const float base = dev->preview2_pinned_base_scale > 0.0f ? dev->preview2_pinned_base_scale : 1.0f; + const float user_scale = dev->preview2_pinned_scale > 0.0f ? dev->preview2_pinned_scale : 1.0f; + const float total_scale = base * user_scale; + + cairo_save(cri); + + // Step 1: Translate to widget center (in logical pixels) + cairo_translate(cri, width * 0.5f, height * 0.5f); + + // Step 2: Apply zoom scale (in logical pixel space) + cairo_scale(cri, total_scale, total_scale); + + // Step 3: Apply pan offsets (in logical pixels, scaled by total_scale) + // When image is smaller than window (total_scale < 1.0), center it by ignoring offsets + const float scaled_img_width = img_width * total_scale; + const float scaled_img_height = img_height * total_scale; + if(scaled_img_width < width && scaled_img_height < height) + { + // Image is smaller than window in both dimensions - keep it centered (no offset) + // Offsets are ignored to keep the image centered + } + else + { + // Image is larger than window - apply pan offsets + // Offsets are already in pixels, divide by total_scale to get unscaled coordinates + cairo_translate(cri, dev->preview2_pinned_off_x / total_scale, + dev->preview2_pinned_off_y / total_scale); + } + + // Step 4: Draw the surface centered at origin + cairo_set_source_surface(cri, dev->preview2_pinned_surface, + -(float)img_width * 0.5f, + -(float)img_height * 0.5f); + cairo_pattern_set_filter(cairo_get_source(cri), CAIRO_FILTER_BEST); + cairo_paint(cri); + cairo_restore(cri); + } + } + else if(dev->preview2.pipe->backbuf) // do we have a regular preview image? + { + // draw the current preview image _view_paint_surface(cri, dev->preview2.orig_width, dev->preview2.orig_height, - &dev->preview2, DT_WINDOW_SECOND); + &dev->preview2, DT_WINDOW_SECOND); } - if(_preview2_request(dev)) dt_dev_process_preview2(dev); + // Only process preview if not pinned or if we don't have a pinned image yet + if(!dev->preview2_pinned || !dev->preview2_pinned_surface) + { + if(_preview2_request(dev)) dt_dev_process_preview2(dev); + } return TRUE; } @@ -3829,6 +3887,103 @@ static gboolean _second_window_scrolled_callback(GtkWidget *widget, int delta_y; if(dt_gui_get_scroll_unit_delta(event, &delta_y)) { + // If pinned, handle zooming/panning on the pinned surface directly + if(dev->preview2_pinned && dev->preview2_pinned_surface) + { + // get widget size in logical pixels + GtkAllocation allocation; + gtk_widget_get_allocation(widget, &allocation); + const float width = (float)allocation.width; // logical pixels + const float height = (float)allocation.height; // logical pixels + + // get surface dimensions + int img_width = cairo_image_surface_get_width(dev->preview2_pinned_surface); + int img_height = cairo_image_surface_get_height(dev->preview2_pinned_surface); + + // current total scale + const float base = dev->preview2_pinned_base_scale > 0.0f ? dev->preview2_pinned_base_scale : 1.0f; + const float total_scale = base * dev->preview2_pinned_scale; + + // simple step scale factor + const float step = 1.1f; + const float f = (delta_y < 0) ? step : (1.0f / step); + + // event coordinates in logical pixels (as delivered by GTK) + const float ex = event->x; + const float ey = event->y; + + // Calculate new scale, but don't allow zooming out smaller than fit-to-window + float new_scale = dev->preview2_pinned_scale * f; + if(new_scale < 1.0f) + { + new_scale = 1.0f; + } + + const float new_total_scale = base * new_scale; + + // Convert offsets to normalized coordinates [-0.5, 0.5] like unpinned images + float zoom_x = dev->preview2_pinned_off_x / img_width; + float zoom_y = dev->preview2_pinned_off_y / img_height; + + // Only adjust zoom position if scale actually changed + if(fabsf(new_scale - dev->preview2_pinned_scale) > 0.001f) + { + // Adjust zoom position to keep same point under cursor (like unpinned images) + const float mouse_off_x = (ex - 0.5f * width) / img_width; + const float mouse_off_y = (ey - 0.5f * height) / img_height; + zoom_x += mouse_off_x / total_scale - mouse_off_x / new_total_scale; + zoom_y += mouse_off_y / total_scale - mouse_off_y / new_total_scale; + } + + // Apply new scale first + dev->preview2_pinned_scale = new_scale; + + // Calculate scaled dimensions with new scale + const float scaled_img_width = img_width * new_total_scale; + const float scaled_img_height = img_height * new_total_scale; + + // Convert back to offset system + dev->preview2_pinned_off_x = zoom_x * img_width; + dev->preview2_pinned_off_y = zoom_y * img_height; + + // Clamp offsets to ensure image edges never leave the window + // The coordinate system: offsets are translations of the image center from window center + + // For X dimension: + if(scaled_img_width <= width) + { + // Image narrower than window - center it (no offset) + dev->preview2_pinned_off_x = 0.0f; + } + else + { + // Image wider than window - clamp to keep edges within window + // Offsets are in pixels. When offset=0, image is centered. + // Max positive offset: left edge touches left window edge + // Min negative offset: right edge touches right window edge + const float max_offset_x = (scaled_img_width - width) * 0.5f; + const float min_offset_x = -max_offset_x; + dev->preview2_pinned_off_x = CLAMP(dev->preview2_pinned_off_x, min_offset_x, max_offset_x); + } + + // For Y dimension: + if(scaled_img_height <= height) + { + // Image shorter than window - center it (no offset) + dev->preview2_pinned_off_y = 0.0f; + } + else + { + // Image taller than window - clamp to keep edges within window + const float max_offset_y = (scaled_img_height - height) * 0.5f; + const float min_offset_y = -max_offset_y; + dev->preview2_pinned_off_y = CLAMP(dev->preview2_pinned_off_y, min_offset_y, max_offset_y); + } + + if(dev->preview2.widget) gtk_widget_queue_draw(dev->preview2.widget); + return TRUE; + } + const gboolean constrained = !dt_modifier_is(event->state, GDK_CONTROL_MASK); dt_dev_zoom_move(&dev->preview2, DT_ZOOM_SCROLL, 0.0f, delta_y < 0, event->x, event->y, constrained); @@ -3841,9 +3996,23 @@ static gboolean _second_window_button_pressed_callback(GtkWidget *w, GdkEventButton *event, dt_develop_t *dev) { - if(event->type == GDK_2BUTTON_PRESS) return 0; + // Handle double-click on pinned images to reset zoom and center + if(event->type == GDK_2BUTTON_PRESS && event->button == GDK_BUTTON_PRIMARY) + { + if(dev->preview2_pinned && dev->preview2_pinned_surface && dev->preview2.widget) + { + // Reset to fit-to-window scale and center + dev->preview2_pinned_scale = 1.0f; + dev->preview2_pinned_off_x = 0.0f; + dev->preview2_pinned_off_y = 0.0f; + if(dev->preview2.widget) gtk_widget_queue_draw(dev->preview2.widget); + return TRUE; + } + return FALSE; + } if(event->button == GDK_BUTTON_PRIMARY) { + // store coordinates in logical pixels (as delivered by event) darktable.control->button_x = event->x; darktable.control->button_y = event->y; _dt_second_window_change_cursor(dev, "grabbing"); @@ -3851,6 +4020,15 @@ static gboolean _second_window_button_pressed_callback(GtkWidget *w, } if(event->button == GDK_BUTTON_MIDDLE) { + if(dev->preview2_pinned && dev->preview2_pinned_surface && dev->preview2.widget) + { + // reset pinned zoom/position similar to zoom-to-1 behaviour + dev->preview2_pinned_scale = 1.0f; + dev->preview2_pinned_off_x = 0.0f; + dev->preview2_pinned_off_y = 0.0f; + if(dev->preview2.widget) gtk_widget_queue_draw(dev->preview2.widget); + return TRUE; + } dt_dev_zoom_move(&dev->preview2, DT_ZOOM_1, 0.0f, -2, event->x, event->y, !dt_modifier_is(event->state, GDK_CONTROL_MASK)); return TRUE; @@ -3875,6 +4053,81 @@ static gboolean _second_window_mouse_moved_callback(GtkWidget *w, if(event->state & GDK_BUTTON1_MASK) { dt_control_t *ctl = darktable.control; + if(dev->preview2_pinned && dev->preview2_pinned_surface && dev->preview2.widget) + { + // event coordinates are in logical pixels (as delivered by GTK) + const float ex = event->x; + const float ey = event->y; + + const float dx = ex - ctl->button_x; + const float dy = ey - ctl->button_y; + + // Get widget and image dimensions + GtkAllocation allocation; + gtk_widget_get_allocation(w, &allocation); + const float width = (float)allocation.width; + const float height = (float)allocation.height; + + int img_width = cairo_image_surface_get_width(dev->preview2_pinned_surface); + int img_height = cairo_image_surface_get_height(dev->preview2_pinned_surface); + + const float base = dev->preview2_pinned_base_scale > 0.0f ? dev->preview2_pinned_base_scale : 1.0f; + const float total_scale = base * dev->preview2_pinned_scale; + + // Calculate scaled image dimensions + const float scaled_img_width = img_width * total_scale; + const float scaled_img_height = img_height * total_scale; + + // Only allow panning if image is larger than window in at least one dimension + if(scaled_img_width >= width || scaled_img_height >= height) + { + // Only accumulate pan delta in dimensions where image is larger than window + if(scaled_img_width >= width) + dev->preview2_pinned_off_x += dx; + if(scaled_img_height >= height) + dev->preview2_pinned_off_y += dy; + + // Clamp offsets to ensure image edges never leave the window + // The coordinate system: offsets are translations of the image center from window center + // Positive offset moves image right/down, negative offset moves image left/up + + // For X dimension: + if(scaled_img_width <= width) + { + // Image narrower than window - center it (no offset) + dev->preview2_pinned_off_x = 0.0f; + } + else + { + // Image wider than window - clamp to keep edges within window + const float max_offset_x = (scaled_img_width - width) * 0.5f; + const float min_offset_x = -max_offset_x; + dev->preview2_pinned_off_x = CLAMP(dev->preview2_pinned_off_x, min_offset_x, max_offset_x); + } + + // For Y dimension: + if(scaled_img_height <= height) + { + // Image shorter than window - center it (no offset) + dev->preview2_pinned_off_y = 0.0f; + } + else + { + // Image taller than window - clamp to keep edges within window + const float max_offset_y = (scaled_img_height - height) * 0.5f; + const float min_offset_y = -max_offset_y; + dev->preview2_pinned_off_y = CLAMP(dev->preview2_pinned_off_y, min_offset_y, max_offset_y); + } + } + + // Always update button position to prevent delta accumulation + ctl->button_x = ex; + ctl->button_y = ey; + + if(dev->preview2.widget) gtk_widget_queue_draw(dev->preview2.widget); + return TRUE; + } + dt_dev_zoom_move(&dev->preview2, DT_ZOOM_MOVE, -1.f, 0, event->x - ctl->button_x, event->y - ctl->button_y, TRUE); ctl->button_x = event->x; @@ -3896,8 +4149,10 @@ static gboolean _second_window_configure_callback(GtkWidget *da, GdkEventConfigure *event, dt_develop_t *dev) { - if(dev->preview2.orig_width != event->width - || dev->preview2.orig_height != event->height) + gboolean size_changed = (dev->preview2.orig_width != event->width || + dev->preview2.orig_height != event->height); + + if(size_changed) { dev->preview2.width = event->width; dev->preview2.height = event->height; @@ -3908,6 +4163,53 @@ static gboolean _second_window_configure_callback(GtkWidget *da, dev->preview2.pipe->status = DT_DEV_PIXELPIPE_DIRTY; dev->preview2.pipe->changed |= DT_DEV_PIPE_REMOVE; dev->preview2.pipe->cache_obsolete = TRUE; + + // If we have a pinned image, update the viewport dimensions + if(dev->preview2_pinned) + { + // Recompute base fit scale for pinned image to follow window size + if(dev->preview2_pinned_surface) + { + const int img_width = cairo_image_surface_get_width(dev->preview2_pinned_surface); + const int img_height = cairo_image_surface_get_height(dev->preview2_pinned_surface); + if(img_width > 0 && img_height > 0) + { + const float scale_w = (float)event->width / (float)img_width; + const float scale_h = (float)event->height / (float)img_height; + dev->preview2_pinned_base_scale = MIN(scale_w, scale_h); + + // If completely zoomed out (user scale == 1), keep image fit to window + if(fabsf(dev->preview2_pinned_scale - 1.0f) < 1e-6f) + { + dev->preview2_pinned_off_x = 0.0f; + dev->preview2_pinned_off_y = 0.0f; + } + else + { + // Clamp offsets against new window size + const float total_scale = dev->preview2_pinned_base_scale * dev->preview2_pinned_scale; + const float scaled_img_w = img_width * total_scale; + const float scaled_img_h = img_height * total_scale; + if(scaled_img_w <= event->width) dev->preview2_pinned_off_x = 0.0f; + else + { + const float max_x = (scaled_img_w - event->width) * 0.5f; + dev->preview2_pinned_off_x = CLAMP(dev->preview2_pinned_off_x, -max_x, max_x); + } + if(scaled_img_h <= event->height) dev->preview2_pinned_off_y = 0.0f; + else + { + const float max_y = (scaled_img_h - event->height) * 0.5f; + dev->preview2_pinned_off_y = CLAMP(dev->preview2_pinned_off_y, -max_y, max_y); + } + } + } + } + + // Force a redraw + if(dev->preview2.widget) + gtk_widget_queue_draw(dev->preview2.widget); + } } dt_colorspaces_set_display_profile(DT_COLORSPACE_DISPLAY2); @@ -3921,31 +4223,115 @@ static gboolean _second_window_configure_callback(GtkWidget *da, return TRUE; } -static void _darkroom_ui_second_window_init(GtkWidget *widget, +// Query tooltip handler for the pin button +static gboolean _preview2_pin_button_query_tooltip(GtkWidget *widget, + gint x, gint y, + gboolean keyboard_mode, + GtkTooltip *tooltip, + gpointer user_data) +{ + gboolean is_pinned = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(widget)); + const char *tooltip_text = is_pinned ? _("Unpin image") : _("Pin current image"); + gtk_tooltip_set_text(tooltip, tooltip_text); + return TRUE; +} + +static gboolean _preview2_on_top_button_query_tooltip(GtkWidget *widget, + gint x, gint y, + gboolean keyboard_mode, + GtkTooltip *tooltip, + gpointer user_data) +{ + gboolean is_on_top = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(widget)); + const char *tooltip_text = is_on_top ? _("Do not keep second window on top") : _("Keep second window on top"); + gtk_tooltip_set_text(tooltip, tooltip_text); + return TRUE; +} + +// Callback for the pin button in the overlay +static void _preview2_pin_button_clicked(GtkToggleButton *button, + dt_develop_t *dev) +{ + dt_dev_toggle_preview2_pinned(dev); +} + +static void _preview2_on_top_button_clicked(GtkToggleButton *button, + dt_develop_t *dev) +{ + gtk_window_set_keep_above(GTK_WINDOW(dev->second_wnd), + gtk_toggle_button_get_active(button)); +} + +static void _darkroom_ui_second_window_init(GtkWidget *overlay, dt_develop_t *dev) { + // Get the window that contains this overlay + GtkWidget *window = gtk_widget_get_toplevel(overlay); + const int width = MAX(10, dt_conf_get_int("second_window/window_w")); const int height = MAX(10, dt_conf_get_int("second_window/window_h")); - - dev->preview2.border_size = 0; - const gint x = MAX(0, dt_conf_get_int("second_window/window_x")); const gint y = MAX(0, dt_conf_get_int("second_window/window_y")); - gtk_window_set_default_size(GTK_WINDOW(widget), width, height); - gtk_widget_show_all(widget); - gtk_window_move(GTK_WINDOW(widget), x, y); - gtk_window_resize(GTK_WINDOW(widget), width, height); + + // Create the pin button for the overlay + GtkWidget *pin_button = dtgtk_togglebutton_new(dtgtk_cairo_paint_pin, 0, NULL); + gtk_widget_set_name(pin_button, "dt_window2_pin_button"); + gtk_widget_set_size_request(pin_button, 24, 24); + gtk_widget_set_has_tooltip(pin_button, TRUE); + g_signal_connect(G_OBJECT(pin_button), "query-tooltip", + G_CALLBACK(_preview2_pin_button_query_tooltip), dev); + g_signal_connect(G_OBJECT(pin_button), "toggled", + G_CALLBACK(_preview2_pin_button_clicked), dev); + gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(overlay), pin_button, TRUE); + gtk_widget_set_halign(pin_button, GTK_ALIGN_END); + gtk_widget_set_valign(pin_button, GTK_ALIGN_START); + gtk_widget_set_margin_top(pin_button, 10); + gtk_widget_set_margin_end(pin_button, 10); + gtk_widget_show(pin_button); + gtk_overlay_add_overlay(GTK_OVERLAY(overlay), pin_button); + + // Create keep-on-top button for the overlay + GtkWidget *on_top_button = dtgtk_togglebutton_new(dtgtk_cairo_paint_eye, 0, NULL); + gtk_widget_set_name(on_top_button, "dt_window2_on_top_button"); + gtk_widget_set_size_request(on_top_button, 24, 24); + gtk_widget_set_has_tooltip(on_top_button, TRUE); + g_signal_connect(G_OBJECT(on_top_button), "query-tooltip", + G_CALLBACK(_preview2_on_top_button_query_tooltip), dev); + g_signal_connect(G_OBJECT(on_top_button), "toggled", + G_CALLBACK(_preview2_on_top_button_clicked), dev); + gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(overlay), on_top_button, TRUE); + gtk_widget_set_halign(on_top_button, GTK_ALIGN_END); + gtk_widget_set_valign(on_top_button, GTK_ALIGN_START); + gtk_widget_set_margin_top(on_top_button, 10); + gtk_widget_set_margin_end(on_top_button, 10 + 24 + 5); + gtk_widget_show(on_top_button); + gtk_overlay_add_overlay(GTK_OVERLAY(overlay), on_top_button); + + // Store the button in the dev structure for later access if needed + dev->preview2.pin_button = pin_button; + + // Make sure the button is above other widgets + gtk_widget_show(pin_button); + + dev->preview2.border_size = 0; + + // Set window size and position + gtk_window_set_default_size(GTK_WINDOW(window), width, height); + gtk_window_move(GTK_WINDOW(window), x, y); + gtk_window_resize(GTK_WINDOW(window), width, height); + + // Handle window state (fullscreen/maximized) const int fullscreen = dt_conf_get_bool("second_window/fullscreen"); - if(fullscreen) - gtk_window_fullscreen(GTK_WINDOW(widget)); + if (fullscreen) + gtk_window_fullscreen(GTK_WINDOW(window)); else { - gtk_window_unfullscreen(GTK_WINDOW(widget)); + gtk_window_unfullscreen(GTK_WINDOW(window)); const int maximized = dt_conf_get_bool("second_window/maximized"); - if(maximized) - gtk_window_maximize(GTK_WINDOW(widget)); + if (maximized) + gtk_window_maximize(GTK_WINDOW(window)); else - gtk_window_unmaximize(GTK_WINDOW(widget)); + gtk_window_unmaximize(GTK_WINDOW(window)); } } @@ -4000,8 +4386,13 @@ static void _darkroom_display_second_window(dt_develop_t *dev) gtk_window_set_icon_name(GTK_WINDOW(dev->second_wnd), "darktable"); gtk_window_set_title(GTK_WINDOW(dev->second_wnd), _("darktable - darkroom preview")); + // Create the overlay for the window + GtkWidget *overlay = gtk_overlay_new(); + gtk_container_add(GTK_CONTAINER(dev->second_wnd), overlay); + + // Create the drawing area and add it to the overlay dev->preview2.widget = gtk_drawing_area_new(); - gtk_container_add(GTK_CONTAINER(dev->second_wnd), dev->preview2.widget); + gtk_container_add(GTK_CONTAINER(overlay), dev->preview2.widget); gtk_widget_set_size_request(dev->preview2.widget, DT_PIXEL_APPLY_DPI_2ND_WND(dev, 50), DT_PIXEL_APPLY_DPI_2ND_WND(dev, 200)); gtk_widget_set_hexpand(dev->preview2.widget, TRUE); gtk_widget_set_vexpand(dev->preview2.widget, TRUE); @@ -4036,9 +4427,10 @@ static void _darkroom_display_second_window(dt_develop_t *dev) g_signal_connect(G_OBJECT(dev->second_wnd), "event", G_CALLBACK(dt_shortcut_dispatcher), NULL); - _darkroom_ui_second_window_init(dev->second_wnd, dev); + _darkroom_ui_second_window_init(overlay, dev); } + // Show all widgets in the window gtk_widget_show_all(dev->second_wnd); } From a368a8fa9dd333652979d7e665450bb9816c9f7f Mon Sep 17 00:00:00 2001 From: Daniele Pighin Date: Sat, 13 Dec 2025 13:27:10 +0100 Subject: [PATCH 2/2] Re-implement on top of dt_develop_t --- data/themes/darktable.css | 5 + src/develop/develop.c | 410 ++++++++++++++++----------- src/develop/develop.h | 21 +- src/views/darkroom.c | 572 +++++++++++++++----------------------- src/views/view.c | 21 +- 5 files changed, 504 insertions(+), 525 deletions(-) diff --git a/data/themes/darktable.css b/data/themes/darktable.css index fa902b804ff6..e14e83f73b34 100644 --- a/data/themes/darktable.css +++ b/data/themes/darktable.css @@ -525,6 +525,11 @@ separator border: 0.08em solid transparent; } +#second_window button +{ + background-color: @darkroom_bg_color; +} + #footer-toolbar button:checked, #second_window button:checked { diff --git a/src/develop/develop.c b/src/develop/develop.c index 0ef1a5886830..621cf895092a 100644 --- a/src/develop/develop.c +++ b/src/develop/develop.c @@ -52,6 +52,9 @@ #define DT_DEV_AVERAGE_DELAY_COUNT 5 +// Forward declaration +static inline void _dt_dev_load_raw(dt_develop_t *dev, const dt_imgid_t imgid); + void dt_dev_init(dt_develop_t *dev, const gboolean gui_attached) { @@ -153,14 +156,40 @@ void dt_dev_init(dt_develop_t *dev, dev->full.zoom_x = dev->full.zoom_y = dev->preview2.zoom_x = dev->preview2.zoom_y = 0.0f; dev->full.zoom_scale = dev->preview2.zoom_scale = 1.0f; + // Set back-pointers from viewports to their owning develop + dev->full.dev = dev; + dev->preview2.dev = dev; + // Initialize pinned image state dev->preview2_pinned = FALSE; - dev->preview2_pinned_imgid = -1; - dev->preview2_pinned_surface = NULL; - dev->preview2_pinned_base_scale = 1.0f; - dev->preview2_pinned_scale = 1.0f; - dev->preview2_pinned_off_x = 0.0f; - dev->preview2_pinned_off_y = 0.0f; + dev->preview2_pinned_dev = NULL; +} + +// Shutdown and cleanup a pinned dev, waiting for any in-progress jobs +static void _cleanup_pinned_dev(dt_develop_t *pinned_dev) +{ + if(!pinned_dev) return; + + pinned_dev->gui_leaving = TRUE; + pinned_dev->preview2.widget = NULL; + + if(pinned_dev->preview2.pipe) + dt_atomic_set_int(&pinned_dev->preview2.pipe->shutdown, DT_DEV_PIXELPIPE_STOP_NODES); + if(pinned_dev->preview_pipe) + dt_atomic_set_int(&pinned_dev->preview_pipe->shutdown, DT_DEV_PIXELPIPE_STOP_NODES); + if(pinned_dev->full.pipe) + dt_atomic_set_int(&pinned_dev->full.pipe->shutdown, DT_DEV_PIXELPIPE_STOP_NODES); + + if(pinned_dev->preview2.pipe) + { + dt_pthread_mutex_lock(&pinned_dev->preview2.pipe->mutex); + dt_pthread_mutex_unlock(&pinned_dev->preview2.pipe->mutex); + dt_pthread_mutex_lock(&pinned_dev->preview2.pipe->busy_mutex); + dt_pthread_mutex_unlock(&pinned_dev->preview2.pipe->busy_mutex); + } + + dt_dev_cleanup(pinned_dev); + free(pinned_dev); } void dt_dev_cleanup(dt_develop_t *dev) @@ -214,12 +243,14 @@ void dt_dev_cleanup(dt_develop_t *dev) if(dev->histogram_pre_levels) free(dev->histogram_pre_levels); dev->histogram_pre_tonecurve = dev->histogram_pre_levels = NULL; - // Clean up pinned image surface - if(dev->preview2_pinned_surface) + // Clean up pinned develop + // Clean up pinned develop + if(dev->preview2_pinned_dev) { - cairo_surface_destroy(dev->preview2_pinned_surface); - dev->preview2_pinned_surface = NULL; + _cleanup_pinned_dev(dev->preview2_pinned_dev); + dev->preview2_pinned_dev = NULL; } + dev->preview2_pinned = FALSE; g_list_free_full(dev->forms, (void (*)(void *))dt_masks_free_form); g_list_free_full(dev->allforms, (void (*)(void *))dt_masks_free_form); @@ -280,160 +311,208 @@ void dt_dev_invalidate_all(dt_develop_t *dev) dev->timestamp++; } +// Helper to find the cloned module in pinned_dev that corresponds to a module in src_dev +static dt_iop_module_t *_find_cloned_module(dt_develop_t *dev, dt_iop_module_t *src_mod) +{ + if(!src_mod) return NULL; + for(GList *iter = dev->iop; iter; iter = g_list_next(iter)) + { + dt_iop_module_t *mod = (dt_iop_module_t *)iter->data; + // During cloning we preserve the instance ID and other unique fields + if(mod->instance == src_mod->instance && g_strcmp0(mod->op, src_mod->op) == 0 && + mod->multi_priority == src_mod->multi_priority) + return mod; + } + return NULL; +} + +// Helper to clone a module for pinned dev +static dt_iop_module_t *_clone_module(dt_develop_t *dev, dt_iop_module_t *src_mod) +{ + dt_iop_module_t *new_mod = calloc(1, sizeof(dt_iop_module_t)); + if(dt_iop_load_module_by_so(new_mod, src_mod->so, dev)) + { + free(new_mod); + return NULL; + } + + new_mod->instance = src_mod->instance; + new_mod->enabled = src_mod->enabled; + new_mod->iop_order = src_mod->iop_order; + new_mod->multi_priority = src_mod->multi_priority; + g_strlcpy(new_mod->multi_name, src_mod->multi_name, sizeof(new_mod->multi_name)); + new_mod->multi_name_hand_edited = src_mod->multi_name_hand_edited; + new_mod->hide_enable_button = src_mod->hide_enable_button; + + if(src_mod->params) + memcpy(new_mod->params, src_mod->params, src_mod->params_size); + + if(new_mod->blend_params && src_mod->blend_params) + memcpy(new_mod->blend_params, src_mod->blend_params, sizeof(dt_develop_blend_params_t)); + + return new_mod; +} + +static GList *_duplicate_iop_list(dt_develop_t *pinned_dev, dt_develop_t *main_dev) +{ + GList *new_list = NULL; + for(GList *iter = main_dev->iop; iter; iter = g_list_next(iter)) + { + dt_iop_module_t *src_mod = (dt_iop_module_t *)iter->data; + dt_iop_module_t *new_mod = _clone_module(pinned_dev, src_mod); + if(new_mod) + new_list = g_list_append(new_list, new_mod); + } + return new_list; +} + +static GList *_duplicate_history_list(dt_develop_t *pinned_dev, GList *src_history) +{ + GList *new_history = dt_history_duplicate(src_history); + for(GList *iter = new_history; iter; iter = g_list_next(iter)) + { + dt_dev_history_item_t *item = (dt_dev_history_item_t *)iter->data; + item->module = _find_cloned_module(pinned_dev, item->module); + } + return new_history; +} + +// Helper function to initialize a pinned develop structure +static void _init_pinned_dev(dt_develop_t *pinned_dev, dt_develop_t *main_dev, + const dt_imgid_t imgid) +{ + // Initialize without GUI to avoid GUI callbacks during module loading + dt_dev_init(pinned_dev, FALSE); + + // Copy viewport settings from main dev's preview2 + pinned_dev->preview2.width = main_dev->preview2.width; + pinned_dev->preview2.height = main_dev->preview2.height; + pinned_dev->preview2.orig_width = main_dev->preview2.orig_width; + pinned_dev->preview2.orig_height = main_dev->preview2.orig_height; + pinned_dev->preview2.border_size = main_dev->preview2.border_size; + pinned_dev->preview2.dpi = main_dev->preview2.dpi; + pinned_dev->preview2.dpi_factor = main_dev->preview2.dpi_factor; + pinned_dev->preview2.ppd = main_dev->preview2.ppd; + pinned_dev->preview2.color_assessment = main_dev->preview2.color_assessment; + pinned_dev->preview2.zoom = main_dev->preview2.zoom; + pinned_dev->preview2.closeup = main_dev->preview2.closeup; + pinned_dev->preview2.zoom_x = main_dev->preview2.zoom_x; + pinned_dev->preview2.zoom_y = main_dev->preview2.zoom_y; + pinned_dev->preview2.zoom_scale = main_dev->preview2.zoom_scale; + + // Share the widget reference for redraw triggers + pinned_dev->preview2.widget = main_dev->preview2.widget; + pinned_dev->preview2.pin_button = NULL; + + // Ensure the dev pointer is set to the pinned_dev + pinned_dev->preview2.dev = pinned_dev; + + // Manually create the pipes (since gui_attached=FALSE doesn't create them) + pinned_dev->full.pipe = malloc(sizeof(dt_dev_pixelpipe_t)); + pinned_dev->preview_pipe = malloc(sizeof(dt_dev_pixelpipe_t)); + pinned_dev->preview2.pipe = malloc(sizeof(dt_dev_pixelpipe_t)); + dt_dev_pixelpipe_init(pinned_dev->full.pipe); + dt_dev_pixelpipe_init_preview(pinned_dev->preview_pipe); + dt_dev_pixelpipe_init_preview2(pinned_dev->preview2.pipe); + + // Load raw image data + dt_lock_image(imgid); + _dt_dev_load_raw(pinned_dev, imgid); + pinned_dev->full.pipe->loading = FALSE; // Mark full pipe as not loading to avoid blocking preview2 + pinned_dev->preview_pipe->loading = FALSE; + pinned_dev->preview2.pipe->loading = TRUE; + pinned_dev->preview2.pipe->status = DT_DEV_PIXELPIPE_DIRTY; + + // Load modules (gui_attached is FALSE so no GUI widgets created) + dt_pthread_mutex_lock(&darktable.dev_threadsafe); + // Clone modules and forms from the main develop instance directly + // This ensures we get exactly what the user sees, including unsaved changes + // and specific history state, instead of reloading from database. + pinned_dev->iop = _duplicate_iop_list(pinned_dev, main_dev); + pinned_dev->forms = dt_masks_dup_forms_deep(main_dev->forms, NULL); + pinned_dev->history_end = main_dev->history_end; + pinned_dev->iop_instance = main_dev->iop_instance; + pinned_dev->history = _duplicate_history_list(pinned_dev, main_dev->history); + pinned_dev->history_last_module = _find_cloned_module(pinned_dev, main_dev->history_last_module); + + // Copy iop order information + pinned_dev->iop_order_version = main_dev->iop_order_version; + pinned_dev->iop_order_list = dt_ioppr_iop_order_copy_deep(main_dev->iop_order_list); + + // Copy chroma state and handle module pointers + memcpy(&pinned_dev->chroma, &main_dev->chroma, sizeof(dt_dev_chroma_t)); + pinned_dev->chroma.temperature = _find_cloned_module(pinned_dev, main_dev->chroma.temperature); + pinned_dev->chroma.adaptation = _find_cloned_module(pinned_dev, main_dev->chroma.adaptation); + + dt_pthread_mutex_unlock(&darktable.dev_threadsafe); + + dt_unlock_image(imgid); +} + +void _pin_image(dt_develop_t *dev) +{ + const dt_imgid_t pinned_imgid = dev->image_storage.id; + if(dev->preview2_pinned_dev) + { + _cleanup_pinned_dev(dev->preview2_pinned_dev); + dev->preview2_pinned_dev = NULL; + } + + dev->preview2_pinned_dev = malloc(sizeof(dt_develop_t)); + if(!dev->preview2_pinned_dev) + { + dev->preview2_pinned = FALSE; + dt_toast_log(_("failed to create pinned develop")); + return; + } + + _init_pinned_dev(dev->preview2_pinned_dev, dev, pinned_imgid); + dev->preview2_pinned_dev->preview2.pipe->status = DT_DEV_PIXELPIPE_DIRTY; + dev->preview2_pinned_dev->preview2.pipe->changed |= DT_DEV_PIPE_SYNCH; + dt_dev_process_preview2(dev->preview2_pinned_dev); + dt_toast_log(_("image pinned")); +} + +void _unpin_image(dt_develop_t *dev) +{ + if(dev->preview2_pinned_dev) + { + _cleanup_pinned_dev(dev->preview2_pinned_dev); + dev->preview2_pinned_dev = NULL; + } + // Force main dev's preview2 pipe to update + dev->preview2.pipe->status = DT_DEV_PIXELPIPE_DIRTY; + dev->preview2.pipe->changed |= DT_DEV_PIPE_SYNCH; + dt_toast_log(_("image unpinned")); +} + void dt_dev_toggle_preview2_pinned(dt_develop_t *dev) { if(!dev) return; - dev->preview2_pinned = !dev->preview2_pinned; - - if(dev->preview2_pinned) + // If we're trying to pin, validate the image first + if(!dev->preview2_pinned) { - // Pinning the current image - dev->preview2_pinned_imgid = dev->image_storage.id; - - // If we already have a surface, clean it up first - if(dev->preview2_pinned_surface) + if(!dt_is_valid_imgid(dev->image_storage.id)) { - cairo_surface_destroy(dev->preview2_pinned_surface); - dev->preview2_pinned_surface = NULL; - } - - // Get the window dimensions for computing the base scale - gint window_width = 800; - gint window_height = 600; - if(dev->preview2.widget && gtk_widget_get_window(dev->preview2.widget)) - { - GdkWindow *window = gtk_widget_get_window(dev->preview2.widget); - window_width = gdk_window_get_width(window); - window_height = gdk_window_get_height(window); - } - - // Get the actual final image dimensions after all transformations (crop, rotate, etc.) - int final_width = 0; - int final_height = 0; - if(!dt_image_get_final_size(dev->image_storage.id, &final_width, &final_height)) - { - // Fallback to image storage dimensions if we can't get final size - final_width = dev->image_storage.final_width; - final_height = dev->image_storage.final_height; + dt_toast_log(_("no valid image to pin")); + return; } - // Use the actual image dimensions for rendering at native resolution - const size_t max_width = MAX(final_width, 800); // Use actual width, minimum 800 - const size_t max_height = MAX(final_height, 600); // Use actual height, minimum 600 - - // Show toast message while rendering - dt_toast_log(_("rendering pinned image...")); - - uint8_t *buf = NULL; - size_t buf_width = 0; - size_t buf_height = 0; - float scale = 1.0f; - - // Render the full image with current develop settings up to the current history position - dt_dev_image(dev->image_storage.id, - max_width, max_height, - dev->history_end, // use current history position - &buf, &scale, - &buf_width, &buf_height, - NULL, // no zoom position (render whole image) - -1, // no snapshot - NULL, // no module filter - DT_DEVICE_NONE, // CPU processing - FALSE); // don't use finalscale - - if(buf && buf_width > 0 && buf_height > 0) - { - // Create a cairo surface from the rendered buffer - // The buffer is BGRA32 format (uint8_t) - cairo_surface_t *surface = cairo_image_surface_create( - CAIRO_FORMAT_RGB24, - buf_width, - buf_height); - - if(cairo_surface_status(surface) == CAIRO_STATUS_SUCCESS) - { - unsigned char *surface_data = cairo_image_surface_get_data(surface); - const int stride = cairo_image_surface_get_stride(surface); - - // Copy the rendered buffer to the cairo surface - // dt_dev_image returns BGRA data in uint8_t format - for(size_t y = 0; y < buf_height; y++) - { - uint8_t *src = buf + y * buf_width * 4; - uint8_t *dst = surface_data + y * stride; - memcpy(dst, src, buf_width * 4); - } - - cairo_surface_mark_dirty(surface); - - dev->preview2_pinned_surface = surface; - - /* compute base scale to fit the rendered image into window */ - if(window_width > 0 && window_height > 0) - { - const float scale_w = (float)window_width / (float)buf_width; - const float scale_h = (float)window_height / (float)buf_height; - dev->preview2_pinned_base_scale = MIN(scale_w, scale_h); - } - else - { - dev->preview2_pinned_base_scale = 1.0f; - } - - // Copy current second-window zoom and position to pinned image for seamless transition - dt_dev_zoom_t cur_zoom; - int cur_closeup; - float cur_zoom_x = 0.0f, cur_zoom_y = 0.0f; - dt_dev_get_viewport_params(&dev->preview2, &cur_zoom, &cur_closeup, &cur_zoom_x, &cur_zoom_y); - // Current zoom scale without ppd to match cairo logical coordinates - const float cur_scale = dt_dev_get_zoom_scale(&dev->preview2, cur_zoom, 1 << cur_closeup, FALSE); - - // User scale relative to base fit scale so that base*user == current scale - dev->preview2_pinned_scale = (dev->preview2_pinned_base_scale > 0.0f) - ? (cur_scale / dev->preview2_pinned_base_scale) - : 1.0f; - - // Offsets in window pixels: match original pan exactly. - // Window offset equals zoom_x * (scaled image width) and similarly for height. - const float total_scale = dev->preview2_pinned_base_scale * dev->preview2_pinned_scale; - // Note: positive zoom_x means the image center moves right in viewport; - // to match that, the image must translate left. Hence the negative sign. - dev->preview2_pinned_off_x = -cur_zoom_x * ((float)buf_width * total_scale); - dev->preview2_pinned_off_y = -cur_zoom_y * ((float)buf_height * total_scale); - - // If image fits entirely, keep centered (ignore offsets) - const float scaled_img_w = buf_width * total_scale; - const float scaled_img_h = buf_height * total_scale; - if(scaled_img_w <= window_width) dev->preview2_pinned_off_x = 0.0f; - if(scaled_img_h <= window_height) dev->preview2_pinned_off_y = 0.0f; - - dt_toast_log(_("pinned image rendered")); - } - else - { - dt_toast_log(_("failed to create surface for pinned image")); - } - - // Free the rendered buffer - dt_free_align(buf); - } - else + if(dev->full.pipe && dev->full.pipe->loading) { - dt_toast_log(_("failed to render pinned image")); - } - } - else - { - // Unpinning - clear the pinned image ID and surface - dev->preview2_pinned_imgid = -1; - if(dev->preview2_pinned_surface) - { - cairo_surface_destroy(dev->preview2_pinned_surface); - dev->preview2_pinned_surface = NULL; + dt_toast_log(_("please wait for image to load")); + return; } } + dev->preview2_pinned = !dev->preview2_pinned; + + if(dev->preview2_pinned) + _pin_image(dev); + else + _unpin_image(dev); + // Force a redraw of the second window if(dev->preview2.widget) gtk_widget_queue_draw(dev->preview2.widget); @@ -478,7 +557,7 @@ void dt_dev_process_image_job(dt_develop_t *dev, dt_pthread_mutex_lock(&pipe->mutex); - if(dev->gui_leaving) + if(dev->gui_leaving || dt_pipe_shutdown(pipe)) { dt_pthread_mutex_unlock(&pipe->mutex); return; @@ -541,6 +620,17 @@ void dt_dev_process_image_job(dt_develop_t *dev, dev->gui_synch = TRUE; // notify gui thread we want to synch // (call gui_update on the modules) } + else + { + // Explicitly set loading to FALSE to avoid a race condition + // where the pipe is still marked as loading when the caller + // checks it. Without this, the caller might wait for the + // pipe to finish loading, but it never will. This was causing + // failure to render composites in overlay.c and watermark.c. + pipe->loading = FALSE; + if(dev->preview_pipe) dev->preview_pipe->loading = FALSE; + if(dev->preview2.pipe) dev->preview2.pipe->loading = FALSE; + } pipe->changed |= DT_DEV_PIPE_SYNCH; } else @@ -686,6 +776,9 @@ void dt_dev_process_image_job(dt_develop_t *dev, { if(signalling && signal != DT_SIGNAL_DEVELOP_PREVIEW_PIPE_FINISHED) DT_CONTROL_SIGNAL_RAISE(signal); + else if(port->widget && !dev->gui_attached) + // pinned dev has gui_attached=FALSE, so manually queue redraw + dt_control_queue_redraw_widget(port->widget); return; } @@ -2843,13 +2936,14 @@ void dt_dev_zoom_move(dt_dev_viewport_t *port, const float y, const gboolean constrain) { - dt_develop_t *dev = darktable.develop; + // Use the viewport's own develop, or fall back to global + dt_develop_t *dev = port->dev ? port->dev : darktable.develop; dt_pthread_mutex_lock(&darktable.control->global_mutex); dt_pthread_mutex_lock(&dev->history_mutex); float pts[2] = { port->zoom_x, port->zoom_y }; - _dev_distort_transform_locked(darktable.develop, port->pipe, FALSE, 0.0f, DT_DEV_TRANSFORM_DIR_ALL_GEOMETRY, pts, 1); + _dev_distort_transform_locked(dev, port->pipe, FALSE, 0.0f, DT_DEV_TRANSFORM_DIR_ALL_GEOMETRY, pts, 1); const float old_pts0 = pts[0]; const float old_pts1 = pts[1]; @@ -3009,6 +3103,9 @@ void dt_dev_zoom_move(dt_dev_viewport_t *port, && old_closeup == port->closeup) return; + // 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) @@ -3079,7 +3176,7 @@ void dt_dev_get_viewport_params(dt_dev_viewport_t *port, if(x && y && port->pipe) { float pts[2] = { port->zoom_x, port->zoom_y }; - dt_dev_distort_transform_plus(darktable.develop, port->pipe, + dt_dev_distort_transform_plus(port->dev ? port->dev : darktable.develop, port->pipe, 0.0f, DT_DEV_TRANSFORM_DIR_ALL_GEOMETRY, pts, 1); *x = pts[0] / (float)port->pipe->processed_width - 0.5f; *y = pts[1] / (float)port->pipe->processed_height - 0.5f; @@ -3685,6 +3782,7 @@ void dt_dev_image(const dt_imgid_t imgid, dt_dev_pop_history_items_ext(&dev, history_end); dev.full = darktable.develop->full; + dev.full.dev = &dev; dev.full.pipe = pipe; if(!zoom_pos) diff --git a/src/develop/develop.h b/src/develop/develop.h index 823c2b5a95b8..f230e9797fd2 100644 --- a/src/develop/develop.h +++ b/src/develop/develop.h @@ -107,6 +107,7 @@ typedef struct dt_dev_proxy_exposure_t } dt_dev_proxy_exposure_t; struct dt_dev_pixelpipe_t; +struct dt_develop_t; typedef struct dt_dev_viewport_t { GtkWidget *widget; // TODO (#18559): remove gtk stuff from here @@ -129,6 +130,9 @@ typedef struct dt_dev_viewport_t // Pin button for the second window GtkWidget *pin_button; + + // Back-pointer to the owning develop structure + struct dt_develop_t *dev; } dt_dev_viewport_t; /* keep track on what and where we do chromatic adaptation, used @@ -356,19 +360,10 @@ typedef struct dt_develop_t GList *module_filter_out; - // Pinned image for second window - gboolean preview2_pinned; // Whether the second window is pinned to a specific image - dt_imgid_t preview2_pinned_imgid; // The ID of the pinned image - cairo_surface_t *preview2_pinned_surface; // Snapshot of the pinned image - /* transform state for pinned surface (independent of preview2 viewport) - * base_scale: scale used to fit the image into the window at pin time - * scale: user-applied zoom multiplier on top of base_scale - * off_x/off_y: pan offsets in image pixels (applied after scaling) - */ - float preview2_pinned_base_scale; - float preview2_pinned_scale; - float preview2_pinned_off_x; - float preview2_pinned_off_y; + // Pinned image for second window: a separate develop structure for the pinned image + // When pinned, this holds its own image, history, iop modules, and pipeline + gboolean preview2_pinned; // Whether the second window is pinned to a specific image + struct dt_develop_t *preview2_pinned_dev; // Separate develop for pinned image (NULL when not pinned) } dt_develop_t; void dt_dev_init(dt_develop_t *dev, gboolean gui_attached); diff --git a/src/views/darkroom.c b/src/views/darkroom.c index 7e2f8eb400e2..7ff67c88e879 100644 --- a/src/views/darkroom.c +++ b/src/views/darkroom.c @@ -85,6 +85,7 @@ static void _dev_change_image(dt_develop_t *dev, const dt_imgid_t imgid); static void _darkroom_display_second_window(dt_develop_t *dev); static void _darkroom_ui_second_window_write_config(GtkWidget *widget); +static void _darkroom_ui_second_window_cleanup(dt_develop_t *dev); const char *name(const dt_view_t *self) { @@ -184,17 +185,19 @@ void cleanup(dt_view_t *self) if(dev->second_wnd) { - if(gtk_widget_is_visible(dev->second_wnd)) + GtkWidget *wnd = dev->second_wnd; + + if(gtk_widget_is_visible(wnd)) { dt_conf_set_bool("second_window/last_visible", TRUE); - _darkroom_ui_second_window_write_config(dev->second_wnd); + _darkroom_ui_second_window_write_config(wnd); } else dt_conf_set_bool("second_window/last_visible", FALSE); - gtk_window_close(GTK_WINDOW(dev->second_wnd)); // Use close so that _second_window_delete_callback can clean up - dev->second_wnd = NULL; - dev->preview2.widget = NULL; + _darkroom_ui_second_window_cleanup(dev); + gtk_widget_hide(wnd); + gtk_widget_destroy(wnd); } else { @@ -1502,13 +1505,20 @@ static void _second_window_quickbutton_clicked(GtkWidget *w, { if(dev->second_wnd && !gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(w))) { - _darkroom_ui_second_window_write_config(dev->second_wnd); - - gtk_window_close(GTK_WINDOW(dev->second_wnd)); // Use close so that _second_window_delete_callback can clean up - dev->second_wnd = NULL; - dev->preview2.widget = NULL; + GtkWidget *wnd = dev->second_wnd; + + _darkroom_ui_second_window_write_config(wnd); + dt_conf_set_bool("second_window/last_visible", FALSE); + _darkroom_ui_second_window_cleanup(dev); + gtk_widget_hide(wnd); + + // Flush pending events to let macOS process the hide before destroy + while(gtk_events_pending()) + gtk_main_iteration_do(FALSE); + + gtk_widget_destroy(wnd); } - else if(gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(w))) + else if(dev->second_wnd == NULL && gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(w))) _darkroom_display_second_window(dev); } @@ -3012,6 +3022,15 @@ void enter(dt_view_t *self) dt_print(DT_DEBUG_CONTROL, "[run_job+] 11 %f in darkroom mode", dt_get_wtime()); dt_develop_t *dev = self->data; + + // Reset shutdown flags on all pipes - they may still be set from previous session + if(dev->full.pipe) + dt_atomic_set_int(&dev->full.pipe->shutdown, DT_DEV_PIXELPIPE_STOP_NO); + if(dev->preview_pipe) + dt_atomic_set_int(&dev->preview_pipe->shutdown, DT_DEV_PIXELPIPE_STOP_NO); + if(dev->preview2.pipe) + dt_atomic_set_int(&dev->preview2.pipe->shutdown, DT_DEV_PIXELPIPE_STOP_NO); + if(!dev->form_gui) { dev->form_gui = (dt_masks_form_gui_t *)calloc(1, sizeof(dt_masks_form_gui_t)); @@ -3156,6 +3175,23 @@ void leave(dt_view_t *self) dt_develop_t *dev = self->data; + // Close second window when leaving darkroom (save state first) + if(dev->second_wnd) + { + GtkWidget *wnd = dev->second_wnd; + + if(gtk_widget_is_visible(wnd)) + { + dt_conf_set_bool("second_window/last_visible", TRUE); + _darkroom_ui_second_window_write_config(wnd); + } + + _darkroom_ui_second_window_cleanup(dev); + gtk_widget_hide(wnd); + gtk_widget_destroy(wnd); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(dev->second_wnd_button), FALSE); + } + // reset color assessment mode if(dev->full.color_assessment) { @@ -3809,71 +3845,58 @@ static gboolean _second_window_draw_callback(GtkWidget *widget, dt_gui_gtk_set_source_rgb(cri, DT_GUI_COLOR_DARKROOM_BG); cairo_paint(cri); - // Check if we should show a pinned image or the current preview - if(dev->preview2_pinned && dev->preview2_pinned_surface) - { - // Get surface dimensions (already in logical pixels for RGB24 surface) - int img_width = cairo_image_surface_get_width(dev->preview2_pinned_surface); - int img_height = cairo_image_surface_get_height(dev->preview2_pinned_surface); - - if (img_width > 0 && img_height > 0) - { - // Get widget dimensions in logical (widget) pixel space - GtkAllocation allocation; - gtk_widget_get_allocation(widget, &allocation); - const float width = (float)allocation.width; // logical pixels - const float height = (float)allocation.height; // logical pixels - - // compute total scale and offsets from pinned transform stored in dev - const float base = dev->preview2_pinned_base_scale > 0.0f ? dev->preview2_pinned_base_scale : 1.0f; - const float user_scale = dev->preview2_pinned_scale > 0.0f ? dev->preview2_pinned_scale : 1.0f; - const float total_scale = base * user_scale; - - cairo_save(cri); - - // Step 1: Translate to widget center (in logical pixels) - cairo_translate(cri, width * 0.5f, height * 0.5f); - - // Step 2: Apply zoom scale (in logical pixel space) - cairo_scale(cri, total_scale, total_scale); - - // Step 3: Apply pan offsets (in logical pixels, scaled by total_scale) - // When image is smaller than window (total_scale < 1.0), center it by ignoring offsets - const float scaled_img_width = img_width * total_scale; - const float scaled_img_height = img_height * total_scale; - if(scaled_img_width < width && scaled_img_height < height) - { - // Image is smaller than window in both dimensions - keep it centered (no offset) - // Offsets are ignored to keep the image centered - } - else - { - // Image is larger than window - apply pan offsets - // Offsets are already in pixels, divide by total_scale to get unscaled coordinates - cairo_translate(cri, dev->preview2_pinned_off_x / total_scale, - dev->preview2_pinned_off_y / total_scale); - } - - // Step 4: Draw the surface centered at origin - cairo_set_source_surface(cri, dev->preview2_pinned_surface, - -(float)img_width * 0.5f, - -(float)img_height * 0.5f); - cairo_pattern_set_filter(cairo_get_source(cri), CAIRO_FILTER_BEST); - cairo_paint(cri); + // Early exit if we're in an inconsistent state + if(!dev->preview2.widget || dev->gui_leaving) + return TRUE; - cairo_restore(cri); - } + // Determine which develop and viewport to use + // Take a local copy of the pointer to avoid race conditions + dt_develop_t *pinned_dev = dev->preview2_pinned ? dev->preview2_pinned_dev : NULL; + dt_develop_t *render_dev = pinned_dev ? pinned_dev : dev; + dt_dev_viewport_t *port = &render_dev->preview2; + + // Check if pinned dev is being cleaned up + if(pinned_dev && pinned_dev->gui_leaving) + { + render_dev = dev; + port = &dev->preview2; + pinned_dev = NULL; } - else if(dev->preview2.pipe->backbuf) // do we have a regular preview image? + + // For pinned images, sync viewport dimensions from main dev + if(pinned_dev) { - // draw the current preview image + port->width = dev->preview2.width; + port->height = dev->preview2.height; + port->orig_width = dev->preview2.orig_width; + port->orig_height = dev->preview2.orig_height; + port->ppd = dev->preview2.ppd; + port->dpi = dev->preview2.dpi; + port->dpi_factor = dev->preview2.dpi_factor; + } + + if(port->pipe && port->pipe->backbuf) // do we have a preview image? + { + // draw the preview image using the appropriate viewport _view_paint_surface(cri, dev->preview2.orig_width, dev->preview2.orig_height, - &dev->preview2, DT_WINDOW_SECOND); + port, DT_WINDOW_SECOND); } - // Only process preview if not pinned or if we don't have a pinned image yet - if(!dev->preview2_pinned || !dev->preview2_pinned_surface) + // Request processing if needed + if(pinned_dev && !pinned_dev->gui_leaving) + { + // Process pinned image pipeline - process if no backbuf, pipe dirty, or zoom/pan changed + if(!port->pipe->backbuf + || port->pipe->status == DT_DEV_PIXELPIPE_DIRTY + || port->pipe->status == DT_DEV_PIXELPIPE_INVALID + || port->pipe->changed != DT_DEV_PIPE_UNCHANGED) + { + dt_dev_process_preview2(pinned_dev); + } + } + else if(!pinned_dev) { + // Process main dev's preview2 pipeline if(_preview2_request(dev)) dt_dev_process_preview2(dev); } @@ -3884,108 +3907,19 @@ static gboolean _second_window_scrolled_callback(GtkWidget *widget, GdkEventScroll *event, dt_develop_t *dev) { + if(dev->gui_leaving) return TRUE; + int delta_y; if(dt_gui_get_scroll_unit_delta(event, &delta_y)) { - // If pinned, handle zooming/panning on the pinned surface directly - if(dev->preview2_pinned && dev->preview2_pinned_surface) - { - // get widget size in logical pixels - GtkAllocation allocation; - gtk_widget_get_allocation(widget, &allocation); - const float width = (float)allocation.width; // logical pixels - const float height = (float)allocation.height; // logical pixels - - // get surface dimensions - int img_width = cairo_image_surface_get_width(dev->preview2_pinned_surface); - int img_height = cairo_image_surface_get_height(dev->preview2_pinned_surface); - - // current total scale - const float base = dev->preview2_pinned_base_scale > 0.0f ? dev->preview2_pinned_base_scale : 1.0f; - const float total_scale = base * dev->preview2_pinned_scale; - - // simple step scale factor - const float step = 1.1f; - const float f = (delta_y < 0) ? step : (1.0f / step); - - // event coordinates in logical pixels (as delivered by GTK) - const float ex = event->x; - const float ey = event->y; - - // Calculate new scale, but don't allow zooming out smaller than fit-to-window - float new_scale = dev->preview2_pinned_scale * f; - if(new_scale < 1.0f) - { - new_scale = 1.0f; - } - - const float new_total_scale = base * new_scale; - - // Convert offsets to normalized coordinates [-0.5, 0.5] like unpinned images - float zoom_x = dev->preview2_pinned_off_x / img_width; - float zoom_y = dev->preview2_pinned_off_y / img_height; - - // Only adjust zoom position if scale actually changed - if(fabsf(new_scale - dev->preview2_pinned_scale) > 0.001f) - { - // Adjust zoom position to keep same point under cursor (like unpinned images) - const float mouse_off_x = (ex - 0.5f * width) / img_width; - const float mouse_off_y = (ey - 0.5f * height) / img_height; - zoom_x += mouse_off_x / total_scale - mouse_off_x / new_total_scale; - zoom_y += mouse_off_y / total_scale - mouse_off_y / new_total_scale; - } - - // Apply new scale first - dev->preview2_pinned_scale = new_scale; - - // Calculate scaled dimensions with new scale - const float scaled_img_width = img_width * new_total_scale; - const float scaled_img_height = img_height * new_total_scale; - - // Convert back to offset system - dev->preview2_pinned_off_x = zoom_x * img_width; - dev->preview2_pinned_off_y = zoom_y * img_height; - - // Clamp offsets to ensure image edges never leave the window - // The coordinate system: offsets are translations of the image center from window center - - // For X dimension: - if(scaled_img_width <= width) - { - // Image narrower than window - center it (no offset) - dev->preview2_pinned_off_x = 0.0f; - } - else - { - // Image wider than window - clamp to keep edges within window - // Offsets are in pixels. When offset=0, image is centered. - // Max positive offset: left edge touches left window edge - // Min negative offset: right edge touches right window edge - const float max_offset_x = (scaled_img_width - width) * 0.5f; - const float min_offset_x = -max_offset_x; - dev->preview2_pinned_off_x = CLAMP(dev->preview2_pinned_off_x, min_offset_x, max_offset_x); - } - - // For Y dimension: - if(scaled_img_height <= height) - { - // Image shorter than window - center it (no offset) - dev->preview2_pinned_off_y = 0.0f; - } - else - { - // Image taller than window - clamp to keep edges within window - const float max_offset_y = (scaled_img_height - height) * 0.5f; - const float min_offset_y = -max_offset_y; - dev->preview2_pinned_off_y = CLAMP(dev->preview2_pinned_off_y, min_offset_y, max_offset_y); - } - - if(dev->preview2.widget) gtk_widget_queue_draw(dev->preview2.widget); - return TRUE; - } + // 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(&dev->preview2, DT_ZOOM_SCROLL, 0.0f, delta_y < 0, + dt_dev_zoom_move(port, DT_ZOOM_SCROLL, 0.0f, delta_y < 0, event->x, event->y, constrained); } @@ -3996,19 +3930,20 @@ static gboolean _second_window_button_pressed_callback(GtkWidget *w, GdkEventButton *event, dt_develop_t *dev) { - // Handle double-click on pinned images to reset zoom and center + if(dev->gui_leaving) return FALSE; + + // 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; + + // Handle double-click to reset zoom and center if(event->type == GDK_2BUTTON_PRESS && event->button == GDK_BUTTON_PRIMARY) { - if(dev->preview2_pinned && dev->preview2_pinned_surface && dev->preview2.widget) - { - // Reset to fit-to-window scale and center - dev->preview2_pinned_scale = 1.0f; - dev->preview2_pinned_off_x = 0.0f; - dev->preview2_pinned_off_y = 0.0f; - if(dev->preview2.widget) gtk_widget_queue_draw(dev->preview2.widget); - return TRUE; - } - return FALSE; + dt_dev_zoom_move(port, DT_ZOOM_FIT, 0.0f, 0, + event->x, event->y, TRUE); + return TRUE; } if(event->button == GDK_BUTTON_PRIMARY) { @@ -4020,16 +3955,7 @@ static gboolean _second_window_button_pressed_callback(GtkWidget *w, } if(event->button == GDK_BUTTON_MIDDLE) { - if(dev->preview2_pinned && dev->preview2_pinned_surface && dev->preview2.widget) - { - // reset pinned zoom/position similar to zoom-to-1 behaviour - dev->preview2_pinned_scale = 1.0f; - dev->preview2_pinned_off_x = 0.0f; - dev->preview2_pinned_off_y = 0.0f; - if(dev->preview2.widget) gtk_widget_queue_draw(dev->preview2.widget); - return TRUE; - } - dt_dev_zoom_move(&dev->preview2, DT_ZOOM_1, 0.0f, -2, + dt_dev_zoom_move(port, DT_ZOOM_1, 0.0f, -2, event->x, event->y, !dt_modifier_is(event->state, GDK_CONTROL_MASK)); return TRUE; } @@ -4050,85 +3976,19 @@ static gboolean _second_window_mouse_moved_callback(GtkWidget *w, GdkEventMotion *event, dt_develop_t *dev) { + if(dev->gui_leaving) return FALSE; + if(event->state & GDK_BUTTON1_MASK) { dt_control_t *ctl = darktable.control; - if(dev->preview2_pinned && dev->preview2_pinned_surface && dev->preview2.widget) - { - // event coordinates are in logical pixels (as delivered by GTK) - const float ex = event->x; - const float ey = event->y; - - const float dx = ex - ctl->button_x; - const float dy = ey - ctl->button_y; - - // Get widget and image dimensions - GtkAllocation allocation; - gtk_widget_get_allocation(w, &allocation); - const float width = (float)allocation.width; - const float height = (float)allocation.height; - - int img_width = cairo_image_surface_get_width(dev->preview2_pinned_surface); - int img_height = cairo_image_surface_get_height(dev->preview2_pinned_surface); - - const float base = dev->preview2_pinned_base_scale > 0.0f ? dev->preview2_pinned_base_scale : 1.0f; - const float total_scale = base * dev->preview2_pinned_scale; - - // Calculate scaled image dimensions - const float scaled_img_width = img_width * total_scale; - const float scaled_img_height = img_height * total_scale; - - // Only allow panning if image is larger than window in at least one dimension - if(scaled_img_width >= width || scaled_img_height >= height) - { - // Only accumulate pan delta in dimensions where image is larger than window - if(scaled_img_width >= width) - dev->preview2_pinned_off_x += dx; - if(scaled_img_height >= height) - dev->preview2_pinned_off_y += dy; - - // Clamp offsets to ensure image edges never leave the window - // The coordinate system: offsets are translations of the image center from window center - // Positive offset moves image right/down, negative offset moves image left/up - - // For X dimension: - if(scaled_img_width <= width) - { - // Image narrower than window - center it (no offset) - dev->preview2_pinned_off_x = 0.0f; - } - else - { - // Image wider than window - clamp to keep edges within window - const float max_offset_x = (scaled_img_width - width) * 0.5f; - const float min_offset_x = -max_offset_x; - dev->preview2_pinned_off_x = CLAMP(dev->preview2_pinned_off_x, min_offset_x, max_offset_x); - } - - // For Y dimension: - if(scaled_img_height <= height) - { - // Image shorter than window - center it (no offset) - dev->preview2_pinned_off_y = 0.0f; - } - else - { - // Image taller than window - clamp to keep edges within window - const float max_offset_y = (scaled_img_height - height) * 0.5f; - const float min_offset_y = -max_offset_y; - dev->preview2_pinned_off_y = CLAMP(dev->preview2_pinned_off_y, min_offset_y, max_offset_y); - } - } - - // Always update button position to prevent delta accumulation - ctl->button_x = ex; - ctl->button_y = ey; - - if(dev->preview2.widget) gtk_widget_queue_draw(dev->preview2.widget); - return TRUE; - } + + // 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; - dt_dev_zoom_move(&dev->preview2, DT_ZOOM_MOVE, -1.f, 0, + dt_dev_zoom_move(port, DT_ZOOM_MOVE, -1.f, 0, event->x - ctl->button_x, event->y - ctl->button_y, TRUE); ctl->button_x = event->x; ctl->button_y = event->y; @@ -4149,6 +4009,8 @@ static gboolean _second_window_configure_callback(GtkWidget *da, GdkEventConfigure *event, dt_develop_t *dev) { + if(dev->gui_leaving) return TRUE; + gboolean size_changed = (dev->preview2.orig_width != event->width || dev->preview2.orig_height != event->height); @@ -4164,51 +4026,18 @@ static gboolean _second_window_configure_callback(GtkWidget *da, dev->preview2.pipe->changed |= DT_DEV_PIPE_REMOVE; dev->preview2.pipe->cache_obsolete = TRUE; - // If we have a pinned image, update the viewport dimensions - if(dev->preview2_pinned) + // If we have a pinned image, update its viewport dimensions too + dt_develop_t *pinned_dev = dev->preview2_pinned ? dev->preview2_pinned_dev : NULL; + if(pinned_dev && !pinned_dev->gui_leaving) { - // Recompute base fit scale for pinned image to follow window size - if(dev->preview2_pinned_surface) - { - const int img_width = cairo_image_surface_get_width(dev->preview2_pinned_surface); - const int img_height = cairo_image_surface_get_height(dev->preview2_pinned_surface); - if(img_width > 0 && img_height > 0) - { - const float scale_w = (float)event->width / (float)img_width; - const float scale_h = (float)event->height / (float)img_height; - dev->preview2_pinned_base_scale = MIN(scale_w, scale_h); - - // If completely zoomed out (user scale == 1), keep image fit to window - if(fabsf(dev->preview2_pinned_scale - 1.0f) < 1e-6f) - { - dev->preview2_pinned_off_x = 0.0f; - dev->preview2_pinned_off_y = 0.0f; - } - else - { - // Clamp offsets against new window size - const float total_scale = dev->preview2_pinned_base_scale * dev->preview2_pinned_scale; - const float scaled_img_w = img_width * total_scale; - const float scaled_img_h = img_height * total_scale; - if(scaled_img_w <= event->width) dev->preview2_pinned_off_x = 0.0f; - else - { - const float max_x = (scaled_img_w - event->width) * 0.5f; - dev->preview2_pinned_off_x = CLAMP(dev->preview2_pinned_off_x, -max_x, max_x); - } - if(scaled_img_h <= event->height) dev->preview2_pinned_off_y = 0.0f; - else - { - const float max_y = (scaled_img_h - event->height) * 0.5f; - dev->preview2_pinned_off_y = CLAMP(dev->preview2_pinned_off_y, -max_y, max_y); - } - } - } - } - - // Force a redraw - if(dev->preview2.widget) - gtk_widget_queue_draw(dev->preview2.widget); + dt_dev_viewport_t *pinned_port = &pinned_dev->preview2; + pinned_port->width = event->width; + pinned_port->height = event->height; + pinned_port->orig_width = event->width; + pinned_port->orig_height = event->height; + pinned_port->pipe->status = DT_DEV_PIXELPIPE_DIRTY; + pinned_port->pipe->changed |= DT_DEV_PIPE_REMOVE; + pinned_port->pipe->cache_obsolete = TRUE; } } @@ -4219,47 +4048,40 @@ static gboolean _second_window_configure_callback(GtkWidget *da, #endif dt_dev_configure(&dev->preview2); + + // Also configure pinned viewport if present + dt_develop_t *pinned_dev = dev->preview2_pinned ? dev->preview2_pinned_dev : NULL; + if(pinned_dev && !pinned_dev->gui_leaving) + { + dt_dev_viewport_t *pinned_port = &pinned_dev->preview2; + pinned_port->ppd = dev->preview2.ppd; + pinned_port->dpi = dev->preview2.dpi; + pinned_port->dpi_factor = dev->preview2.dpi_factor; + dt_dev_configure(pinned_port); + } return TRUE; } // Query tooltip handler for the pin button -static gboolean _preview2_pin_button_query_tooltip(GtkWidget *widget, - gint x, gint y, - gboolean keyboard_mode, - GtkTooltip *tooltip, - gpointer user_data) -{ - gboolean is_pinned = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(widget)); - const char *tooltip_text = is_pinned ? _("Unpin image") : _("Pin current image"); - gtk_tooltip_set_text(tooltip, tooltip_text); - return TRUE; -} - -static gboolean _preview2_on_top_button_query_tooltip(GtkWidget *widget, - gint x, gint y, - gboolean keyboard_mode, - GtkTooltip *tooltip, - gpointer user_data) -{ - gboolean is_on_top = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(widget)); - const char *tooltip_text = is_on_top ? _("Do not keep second window on top") : _("Keep second window on top"); - gtk_tooltip_set_text(tooltip, tooltip_text); - return TRUE; -} - // Callback for the pin button in the overlay static void _preview2_pin_button_clicked(GtkToggleButton *button, dt_develop_t *dev) { dt_dev_toggle_preview2_pinned(dev); + gboolean is_pinned = gtk_toggle_button_get_active(button); + gtk_widget_set_tooltip_text(GTK_WIDGET(button), + is_pinned ? _("unpin image") : _("pin current image")); } static void _preview2_on_top_button_clicked(GtkToggleButton *button, dt_develop_t *dev) { - gtk_window_set_keep_above(GTK_WINDOW(dev->second_wnd), - gtk_toggle_button_get_active(button)); + gboolean is_on_top = gtk_toggle_button_get_active(button); + gtk_window_set_keep_above(GTK_WINDOW(dev->second_wnd), is_on_top); + gtk_widget_set_tooltip_text(GTK_WIDGET(button), + is_on_top ? _("disable keep second window on top") + : _("keep second window on top")); } static void _darkroom_ui_second_window_init(GtkWidget *overlay, @@ -4277,42 +4099,37 @@ static void _darkroom_ui_second_window_init(GtkWidget *overlay, GtkWidget *pin_button = dtgtk_togglebutton_new(dtgtk_cairo_paint_pin, 0, NULL); gtk_widget_set_name(pin_button, "dt_window2_pin_button"); gtk_widget_set_size_request(pin_button, 24, 24); - gtk_widget_set_has_tooltip(pin_button, TRUE); - g_signal_connect(G_OBJECT(pin_button), "query-tooltip", - G_CALLBACK(_preview2_pin_button_query_tooltip), dev); + gtk_widget_set_tooltip_text(pin_button, _("pin current image")); + gtk_widget_add_events(pin_button, GDK_ENTER_NOTIFY_MASK | GDK_LEAVE_NOTIFY_MASK + | GDK_POINTER_MOTION_MASK); g_signal_connect(G_OBJECT(pin_button), "toggled", G_CALLBACK(_preview2_pin_button_clicked), dev); - gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(overlay), pin_button, TRUE); gtk_widget_set_halign(pin_button, GTK_ALIGN_END); gtk_widget_set_valign(pin_button, GTK_ALIGN_START); gtk_widget_set_margin_top(pin_button, 10); gtk_widget_set_margin_end(pin_button, 10); - gtk_widget_show(pin_button); gtk_overlay_add_overlay(GTK_OVERLAY(overlay), pin_button); + gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(overlay), pin_button, FALSE); // Create keep-on-top button for the overlay GtkWidget *on_top_button = dtgtk_togglebutton_new(dtgtk_cairo_paint_eye, 0, NULL); gtk_widget_set_name(on_top_button, "dt_window2_on_top_button"); gtk_widget_set_size_request(on_top_button, 24, 24); - gtk_widget_set_has_tooltip(on_top_button, TRUE); - g_signal_connect(G_OBJECT(on_top_button), "query-tooltip", - G_CALLBACK(_preview2_on_top_button_query_tooltip), dev); + gtk_widget_set_tooltip_text(on_top_button, _("keep second window on top")); + gtk_widget_add_events(on_top_button, GDK_ENTER_NOTIFY_MASK | GDK_LEAVE_NOTIFY_MASK + | GDK_POINTER_MOTION_MASK); g_signal_connect(G_OBJECT(on_top_button), "toggled", G_CALLBACK(_preview2_on_top_button_clicked), dev); - gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(overlay), on_top_button, TRUE); gtk_widget_set_halign(on_top_button, GTK_ALIGN_END); gtk_widget_set_valign(on_top_button, GTK_ALIGN_START); gtk_widget_set_margin_top(on_top_button, 10); gtk_widget_set_margin_end(on_top_button, 10 + 24 + 5); - gtk_widget_show(on_top_button); gtk_overlay_add_overlay(GTK_OVERLAY(overlay), on_top_button); + gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(overlay), on_top_button, FALSE); // Store the button in the dev structure for later access if needed dev->preview2.pin_button = pin_button; - // Make sure the button is above other widgets - gtk_widget_show(pin_button); - dev->preview2.border_size = 0; // Set window size and position @@ -4351,20 +4168,64 @@ static void _darkroom_ui_second_window_write_config(GtkWidget *widget) (gdk_window_get_state(gtk_widget_get_window(widget)) & GDK_WINDOW_STATE_FULLSCREEN)); } +// Helper to clean up second window state - called before destroying window +static void _darkroom_ui_second_window_cleanup(dt_develop_t *dev) +{ + // Signal main preview2 pipe to stop and wait for any pending jobs + if(dev->preview2.pipe) + { + dt_atomic_set_int(&dev->preview2.pipe->shutdown, DT_DEV_PIXELPIPE_STOP_NODES); + dt_pthread_mutex_lock(&dev->preview2.pipe->mutex); + dt_pthread_mutex_unlock(&dev->preview2.pipe->mutex); + dt_pthread_mutex_lock(&dev->preview2.pipe->busy_mutex); + dt_pthread_mutex_unlock(&dev->preview2.pipe->busy_mutex); + } + + // Clean up pinned develop + if(dev->preview2_pinned && dev->preview2_pinned_dev) + { + dt_develop_t *pinned_dev = dev->preview2_pinned_dev; + + pinned_dev->gui_leaving = TRUE; + pinned_dev->preview2.widget = NULL; + + if(pinned_dev->preview2.pipe) + dt_atomic_set_int(&pinned_dev->preview2.pipe->shutdown, DT_DEV_PIXELPIPE_STOP_NODES); + if(pinned_dev->preview_pipe) + dt_atomic_set_int(&pinned_dev->preview_pipe->shutdown, DT_DEV_PIXELPIPE_STOP_NODES); + if(pinned_dev->full.pipe) + dt_atomic_set_int(&pinned_dev->full.pipe->shutdown, DT_DEV_PIXELPIPE_STOP_NODES); + + if(pinned_dev->preview2.pipe) + { + dt_pthread_mutex_lock(&pinned_dev->preview2.pipe->mutex); + dt_pthread_mutex_unlock(&pinned_dev->preview2.pipe->mutex); + dt_pthread_mutex_lock(&pinned_dev->preview2.pipe->busy_mutex); + dt_pthread_mutex_unlock(&pinned_dev->preview2.pipe->busy_mutex); + } + + dt_dev_cleanup(pinned_dev); + free(pinned_dev); + dev->preview2_pinned_dev = NULL; + dev->preview2_pinned = FALSE; + } + + dev->second_wnd = NULL; + dev->preview2.widget = NULL; +} + static gboolean _second_window_delete_callback(GtkWidget *widget, GdkEvent *event, dt_develop_t *dev) { - // We need to be careful when using the second window reference from dev. It could be null at this point + // Called when user closes window via window manager (X button) _darkroom_ui_second_window_write_config(widget); + dt_conf_set_bool("second_window/last_visible", FALSE); // There's a bug in GTK+3 where fullscreen GTK window on macOS may cause EXC_BAD_ACCESS. - // We need to unfullscreen the window and consume all pending events first before - // destroying the window gtk_window_unfullscreen(GTK_WINDOW(widget)); - dev->second_wnd = NULL; - dev->preview2.widget = NULL; + _darkroom_ui_second_window_cleanup(dev); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(dev->second_wnd_button), FALSE); @@ -4373,6 +4234,16 @@ static gboolean _second_window_delete_callback(GtkWidget *widget, static void _darkroom_display_second_window(dt_develop_t *dev) { + // Wait for any pending jobs and reset shutdown flag + if(dev->preview2.pipe) + { + dt_pthread_mutex_lock(&dev->preview2.pipe->mutex); + dt_pthread_mutex_unlock(&dev->preview2.pipe->mutex); + dt_pthread_mutex_lock(&dev->preview2.pipe->busy_mutex); + dt_pthread_mutex_unlock(&dev->preview2.pipe->busy_mutex); + dt_atomic_set_int(&dev->preview2.pipe->shutdown, DT_DEV_PIXELPIPE_STOP_NO); + } + if(dev->second_wnd == NULL) { dev->preview2.width = -1; @@ -4388,6 +4259,7 @@ static void _darkroom_display_second_window(dt_develop_t *dev) // Create the overlay for the window GtkWidget *overlay = gtk_overlay_new(); + gtk_widget_add_events(overlay, GDK_ENTER_NOTIFY_MASK | GDK_LEAVE_NOTIFY_MASK); gtk_container_add(GTK_CONTAINER(dev->second_wnd), overlay); // Create the drawing area and add it to the overlay diff --git a/src/views/view.c b/src/views/view.c index bebc33493f90..2d0b6356f82a 100644 --- a/src/views/view.c +++ b/src/views/view.c @@ -1729,8 +1729,10 @@ void dt_view_paint_surface(cairo_t *cr, int buf_height, dt_dev_zoom_pos_t buf_zoom_pos) { - dt_develop_t *dev = darktable.develop; - dt_dev_pixelpipe_t *pp = dev->preview_pipe; + // Use the viewport's develop if available, otherwise fall back to global + dt_develop_t *dev = port->dev ? port->dev : darktable.develop; + // Preview pipe for fallback rendering - only available for main develop + dt_dev_pixelpipe_t *pp = darktable.develop->preview_pipe; int processed_width, processed_height; dt_dev_get_processed_size(port, &processed_width, &processed_height); @@ -1806,20 +1808,27 @@ void dt_view_paint_surface(cairo_t *cr, const double trans_x = (offset_x - zoom_x) * processed_width * buf_scale - 0.5 * buf_width; const double trans_y = (offset_y - zoom_y) * processed_height * buf_scale - 0.5 * buf_height; - if(pp->output_imgid == dev->image_storage.id + // Check if we should use the preview pipe for fallback rendering + // This is only valid for the main develop (not for pinned images which have dev != darktable.develop) + const gboolean use_preview_fallback = + (dev == darktable.develop) + && pp->output_imgid == dev->image_storage.id && (port->pipe->output_imgid != dev->image_storage.id || fabsf(backbuf_scale / buf_scale - 1.0f) > .09f || floor(maxw / 2 / back_scale) - 1 > MIN(- trans_x, trans_x + buf_width) || floor(maxh / 2 / back_scale) - 1 > MIN(- trans_y, trans_y + buf_height)) - && (port == &dev->full || port == &dev->preview2)) + && (port == &dev->full || port == &dev->preview2); + + if(use_preview_fallback) { port->pipe->changed |= DT_DEV_PIPE_ZOOMED; if(port->pipe->status == DT_DEV_PIXELPIPE_VALID) port->pipe->status = DT_DEV_PIXELPIPE_DIRTY; // draw preview - const float wd = processed_width * pp->processed_width / MAX(1, dev->full.pipe->processed_width); - const float ht = processed_height * pp->processed_width / MAX(1, dev->full.pipe->processed_width); + const int full_pipe_width = dev->full.pipe ? dev->full.pipe->processed_width : 1; + const float wd = processed_width * pp->processed_width / MAX(1, full_pipe_width); + const float ht = processed_height * pp->processed_width / MAX(1, full_pipe_width); cairo_save(cr); cairo_scale(cr, zoom_scale, zoom_scale);