diff --git a/data/themes/darktable.css b/data/themes/darktable.css index 2acfb1764e97..e14e83f73b34 100644 --- a/data/themes/darktable.css +++ b/data/themes/darktable.css @@ -517,14 +517,21 @@ 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 +#second_window button +{ + background-color: @darkroom_bg_color; +} + +#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..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) { @@ -152,6 +155,41 @@ 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; + + // 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_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) @@ -201,8 +239,18 @@ 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 develop + // Clean up pinned develop + if(dev->preview2_pinned_dev) + { + _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); @@ -263,6 +311,213 @@ 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; + + // If we're trying to pin, validate the image first + if(!dev->preview2_pinned) + { + if(!dt_is_valid_imgid(dev->image_storage.id)) + { + dt_toast_log(_("no valid image to pin")); + return; + } + + if(dev->full.pipe && dev->full.pipe->loading) + { + 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); +} + void dt_dev_invalidate_preview(dt_develop_t *dev) { dev->preview_pipe->status = DT_DEV_PIXELPIPE_DIRTY; @@ -302,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; @@ -365,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 @@ -510,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; } @@ -2667,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]; @@ -2833,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) @@ -2903,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; @@ -3509,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 ab2bfb45a59b..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 @@ -126,6 +127,12 @@ 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; + + // 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 @@ -352,6 +359,11 @@ 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: 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); @@ -413,8 +425,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..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) { @@ -3805,19 +3841,64 @@ 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); + + // Early exit if we're in an inconsistent state + if(!dev->preview2.widget || dev->gui_leaving) + return TRUE; - if(dev->preview2.pipe->backbuf) // do we have an image? + // 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) { - // draw image - dt_gui_gtk_set_source_rgb(cri, DT_GUI_COLOR_DARKROOM_BG); - cairo_paint(cri); + render_dev = dev; + port = &dev->preview2; + pinned_dev = NULL; + } + + // For pinned images, sync viewport dimensions from main dev + if(pinned_dev) + { + 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); } - if(_preview2_request(dev)) dt_dev_process_preview2(dev); + // 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); + } return TRUE; } @@ -3826,11 +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)) { + // 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); } @@ -3841,9 +3930,24 @@ static gboolean _second_window_button_pressed_callback(GtkWidget *w, GdkEventButton *event, dt_develop_t *dev) { - if(event->type == GDK_2BUTTON_PRESS) return 0; + 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) + { + dt_dev_zoom_move(port, DT_ZOOM_FIT, 0.0f, 0, + event->x, event->y, TRUE); + return TRUE; + } 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,7 +3955,7 @@ static gboolean _second_window_button_pressed_callback(GtkWidget *w, } if(event->button == GDK_BUTTON_MIDDLE) { - 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; } @@ -3872,10 +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; - dt_dev_zoom_move(&dev->preview2, DT_ZOOM_MOVE, -1.f, 0, + + // 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(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; @@ -3896,8 +4009,12 @@ 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) + if(dev->gui_leaving) return TRUE; + + 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 +4025,20 @@ 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 its viewport dimensions too + 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->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; + } } dt_colorspaces_set_display_profile(DT_COLORSPACE_DISPLAY2); @@ -3917,35 +4048,107 @@ 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; } -static void _darkroom_ui_second_window_init(GtkWidget *widget, +// Query tooltip handler for the pin button +// 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) +{ + 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, 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_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_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_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_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_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_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; + + 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)); } } @@ -3965,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); @@ -3987,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; @@ -4000,8 +4257,14 @@ 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_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 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 +4299,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); } 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);