diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 149d45ba42..3ab4f90929 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -130,6 +130,7 @@ FILE(GLOB SOURCE_FILES "develop/masks/masks.c" "develop/masks/path.c" "develop/pixelpipe.c" + "develop/preview_data.c" "develop/tiling.c" "dtgtk/button.c" "dtgtk/culling.c" diff --git a/src/develop/preview_data.c b/src/develop/preview_data.c new file mode 100644 index 0000000000..66ed7e76dd --- /dev/null +++ b/src/develop/preview_data.c @@ -0,0 +1,235 @@ +/* + This file is part of darktable, + Copyright (C) 2026 darktable developers. + + darktable is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + darktable is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with darktable. If not, see . +*/ + +#include "develop/preview_data.h" + +#include "common/darktable.h" +#include "develop/develop.h" +#include "develop/imageop.h" +#include "develop/pixelpipe_hb.h" + +void dt_preview_data_alloc(dt_preview_data_t *pd, + const dt_iop_module_t *module) +{ + pd->buf = NULL; + pd->width = 0; + pd->height = 0; + pd->components = 1; + pd->hash = DT_INVALID_HASH; + pd->module = module; +} + +void dt_preview_data_free(dt_preview_data_t *pd) +{ + if(pd->buf) + { + dt_free_align(pd->buf); + pd->buf = NULL; + } + pd->width = 0; + pd->height = 0; + pd->hash = DT_INVALID_HASH; + pd->module = NULL; +} + +void dt_preview_data_store(dt_preview_data_t *pd, + const size_t width, + const size_t height, + const dt_dev_pixelpipe_iop_t *piece, + dt_preview_data_fill_t fill, + void *const user_data) +{ + if(!pd || !pd->module || !fill || !piece) return; + + // resize, fill and hash commit all happen under the same GUI lock so + // that the GUI thread can never observe a resized but not-yet-filled + // buffer (a window the earlier ensure()/fill/set_hash split exposed). + dt_iop_gui_enter_critical_section((dt_iop_module_t *)pd->module); + + const size_t nelems = width * height * pd->components; + gboolean can_fill = TRUE; + if(pd->width != width || pd->height != height) + { + float *const new_buf = dt_alloc_align_float(nelems); + if(new_buf) + { + dt_free_align(pd->buf); + pd->buf = new_buf; + pd->width = width; + pd->height = height; + } + else + { + pd->hash = DT_INVALID_HASH; + can_fill = FALSE; + } + } + + if(can_fill && pd->buf) + { + fill(user_data, pd->buf, nelems); + pd->hash = dt_dev_pixelpipe_piece_hash((dt_dev_pixelpipe_iop_t *)piece, + &piece->processed_roi_out, TRUE); + } + + dt_iop_gui_leave_critical_section((dt_iop_module_t *)pd->module); +} + +float *dt_preview_data_resize(dt_preview_data_t *pd, + const size_t width, + const size_t height, + dt_preview_data_resize_cb_t resize_cb, + void *const user_data) +{ + if(!pd || !pd->module) return NULL; + + // The resize and the caller's invalidation callback run under the same + // GUI lock so that the module can atomically mark its dependent state + // stale together with the buffer reallocation. + dt_iop_gui_enter_critical_section((dt_iop_module_t *)pd->module); + + gboolean ok = TRUE; + if(pd->width != width || pd->height != height) + { + float *const new_buf = dt_alloc_align_float(width * height * pd->components); + if(new_buf) + { + dt_free_align(pd->buf); + pd->buf = new_buf; + pd->width = width; + pd->height = height; + } + else + { + pd->hash = DT_INVALID_HASH; + ok = FALSE; + } + if(resize_cb) resize_cb(user_data); + } + + float *const buf = ok ? pd->buf : NULL; + dt_iop_gui_leave_critical_section((dt_iop_module_t *)pd->module); + + return buf; +} + +void dt_preview_data_set_hash(dt_preview_data_t *pd, + const dt_dev_pixelpipe_iop_t *piece) +{ + if(!pd || !pd->module) return; + + dt_iop_gui_enter_critical_section((dt_iop_module_t *)pd->module); + pd->hash = dt_dev_pixelpipe_piece_hash((dt_dev_pixelpipe_iop_t *)piece, + &piece->processed_roi_out, TRUE); + dt_iop_gui_leave_critical_section((dt_iop_module_t *)pd->module); +} + +gboolean dt_preview_data_get(dt_preview_data_t *pd, + const size_t x, + const size_t y, + const size_t comp, + float *value) +{ + if(!pd || !pd->module || !value) return FALSE; + + dt_iop_gui_enter_critical_section((dt_iop_module_t *)pd->module); + + gboolean ok = FALSE; + float v = 0.f; + // The bounds check and the buffer read must both happen under the + // GUI lock: the pipe thread may resize pd->buf between the two. + if(pd->buf && x < pd->width && y < pd->height && comp < pd->components) + { + const size_t idx = (((size_t)y * pd->width + (size_t)x) * pd->components) + comp; + v = pd->buf[idx]; + ok = TRUE; + } + + dt_iop_gui_leave_critical_section((dt_iop_module_t *)pd->module); + + *value = v; + return ok; +} + +gboolean dt_preview_data_is_fresh(dt_preview_data_t *pd) +{ + if(!pd || !pd->module || !pd->buf) return FALSE; + + const dt_iop_module_t *const module = pd->module; + dt_iop_gui_enter_critical_section((dt_iop_module_t *)module); + + // No value stored yet, or the stored data has been invalidated. + const dt_hash_t stored_hash = pd->hash; + gboolean fresh = (stored_hash != DT_INVALID_HASH); + + if(fresh) + { + const dt_develop_t *const dev = module->dev; + if(!dev || !dev->preview_pipe) + fresh = FALSE; + else + { + dt_dev_pixelpipe_iop_t *piece = NULL; + for(GList *iter = dev->preview_pipe->nodes; iter; iter = g_list_next(iter)) + { + dt_dev_pixelpipe_iop_t *const p = (dt_dev_pixelpipe_iop_t *)iter->data; + if(p->module == module) + { + piece = p; + break; + } + } + if(!piece) + fresh = FALSE; + else + { + const dt_hash_t cur_hash = dt_dev_pixelpipe_piece_hash(piece, &piece->processed_roi_out, TRUE); + fresh = (cur_hash == stored_hash); + } + } + } + + dt_iop_gui_leave_critical_section((dt_iop_module_t *)module); + return fresh; +} + +dt_hash_t dt_preview_data_get_hash(dt_preview_data_t *pd) +{ + if(!pd || !pd->module) return DT_INVALID_HASH; + + dt_iop_gui_enter_critical_section((dt_iop_module_t *)pd->module); + const dt_hash_t hash = pd->hash; + dt_iop_gui_leave_critical_section((dt_iop_module_t *)pd->module); + + return hash; +} + +void dt_preview_data_invalidate(dt_preview_data_t *pd) +{ + if(!pd || !pd->module) return; + + dt_iop_gui_enter_critical_section((dt_iop_module_t *)pd->module); + pd->hash = DT_INVALID_HASH; + dt_iop_gui_leave_critical_section((dt_iop_module_t *)pd->module); +} + +// clang-format off +// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py +// vim: shiftwidth=2 expandtab tabstop=2 cindent +// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified; +// clang-format on \ No newline at end of file diff --git a/src/develop/preview_data.h b/src/develop/preview_data.h new file mode 100644 index 0000000000..65c12db9f8 --- /dev/null +++ b/src/develop/preview_data.h @@ -0,0 +1,178 @@ +/* + This file is part of darktable, + Copyright (C) 2026 darktable developers. + + darktable is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + darktable is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with darktable. If not, see . +*/ + +/* + Generic support for reading a per-pixel scalar value produced on the + preview pipe while flying over the image in the darkroom. + + iop modules (tone equalizer, color equalizer, ...) that want to + display a value under the mouse cursor (exposure, hue, ...) previously + had to duplicate buffer management, hashing and locking in each module. + This service factorizes that common plumbing: + + - allocation/resizing of the per-pixel scalar buffer, + - recording and checking the cumulative pipe hash, + - thread-safe write (pipe thread) and read (GUI thread), + - invalidation helpers. + + The service only deals with *data*. How the per-pixel value is + computed (the module's own process(), CPU or OpenCL) and how it is + rendered on screen (the module's own gui_post_expose() and cursor + handling) stay fully module specific. The mapping between normalized + cursor coordinates and buffer pixels is also left to the module: + it depends on the module position in the pipe (geometry transforms + located after the module, such as crop, must be inverted back to reach + the module buffer space) and is therefore not part of this service. +*/ + +#pragma once + +#ifdef __cplusplus +extern "C" +{ +#endif + +#include "common/darktable.h" +#include "develop/develop.h" +#include "develop/pixelpipe_hb.h" + +typedef struct dt_preview_data_t +{ + float *buf; // components floats per pixel of the preview pipe + size_t width; // buffer width in pixels + size_t height; // buffer height in pixels + size_t components; // floats per pixel (1 = scalar, default; e.g. 3 = HSB) + dt_hash_t hash; // cumulative pipe hash when the buffer was last filled + const dt_iop_module_t *module; // owning module +} dt_preview_data_t; + +/** create/allocate the structure. */ +void dt_preview_data_alloc(dt_preview_data_t *pd, + const dt_iop_module_t *module); + +/** free allocated data. */ +void dt_preview_data_free(dt_preview_data_t *pd); + +/** fill the buffer with the per-pixel values; called under the module GUI lock. */ +typedef void (*dt_preview_data_fill_t)(void *const user_data, + float *const buf, + const size_t npixels); + +/** called under the module GUI lock after the buffer has been resized. */ +typedef void (*dt_preview_data_resize_cb_t)(void *const user_data); + +/** + * Make sure the buffer has the given dimensions, call fill() while + * holding the module GUI lock, then commit the cumulative pipe hash of + * piece. resize, fill and hash commit all happen atomically under a + * single critical section, so that the GUI can never observe a resized + * but not-yet-filled buffer. Thread-safe. + * + * On allocation failure the previously stored data is invalidated and + * the old buffer is kept (no fill, no new hash). + * + * Intended for cheap per-pixel copies (e.g. a hue buffer filled from + * process() output). Expensive fills that must not hold the GUI lock + * should use dt_preview_data_resize() and commit afterwards with + * dt_preview_data_set_hash(). + */ +void dt_preview_data_store(dt_preview_data_t *pd, + const size_t width, + const size_t height, + const dt_dev_pixelpipe_iop_t *piece, + dt_preview_data_fill_t fill, + void *const user_data); + +/** + * Make sure the buffer has the given dimensions and return a pointer to + * it (owned by the service, valid until the next resize()/free() call). + * Returns NULL on allocation failure (the stored data is invalidated). + * + * If the buffer had to be resized, resize_cb() is called while still + * holding the module GUI lock so that the module can atomically + * invalidate its own dependent state (e.g. its "buffer valid" flag) + * together with the resize. Thread-safe. + */ +float *dt_preview_data_resize(dt_preview_data_t *pd, + const size_t width, + const size_t height, + dt_preview_data_resize_cb_t resize_cb, + void *const user_data); + +/** + * Record the cumulative hash of the module's piece in the preview + * pipe. Must be called after the buffer has been (re)filled in memory + * returned by dt_preview_data_resize() so that is_fresh() can later + * validate the data. Thread-safe. + */ +void dt_preview_data_set_hash(dt_preview_data_t *pd, + const dt_dev_pixelpipe_iop_t *piece); + +/** + * Read a component of the per-pixel value at buffer pixel (x, y). + * + * Thread-safe: takes the module GUI lock around the read. + * Returns TRUE and fills *value on success, FALSE if there is no data + * stored yet, if (x, y) is outside the buffer or if comp is out of the + * [0, components) range. + * + * @param x, y: coordinates in buffer pixels. The mapping from the + * cursor position to buffer pixels is module specific and + * must be done by the caller (see comment at top of file). + * @param comp: component index, in [0, components). + */ +gboolean dt_preview_data_get(dt_preview_data_t *pd, + const size_t x, + const size_t y, + const size_t comp, + float *value); + +/** + * Check whether the stored data is still fresh with respect to the + * current preview pipe: TRUE when a value is stored and the cumulative + * hash of the module's piece in the preview pipe still matches the hash + * recorded at store time. FALSE when nothing is stored yet or when the + * upstream pipe state has changed (the module needs a reprocess). + * + * Thread-safe. + */ +gboolean dt_preview_data_is_fresh(dt_preview_data_t *pd); + +/** + * Return the cumulative pipe hash recorded at the last store, so the + * module can decide whether its per-pixel value needs to be recomputed + * (compared against the hash of the current pipe state). Thread-safe. + */ +dt_hash_t dt_preview_data_get_hash(dt_preview_data_t *pd); + +/** + * Invalidate the stored data (mark the recorded hash as stale). + * The buffer itself is kept and may be read but is_fresh() will return + * FALSE until a new set_hash() happens. Thread-safe. + */ +void dt_preview_data_invalidate(dt_preview_data_t *pd); + +#ifdef __cplusplus +} +#endif + +// clang-format off +// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py +// vim: shiftwidth=2 expandtab tabstop=2 cindent +// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified; +// clang-format on \ No newline at end of file diff --git a/src/gui/draw.h b/src/gui/draw.h index 23a691288e..0d4a87dd3d 100644 --- a/src/gui/draw.h +++ b/src/gui/draw.h @@ -95,6 +95,174 @@ static inline void dt_draw_line(cairo_t *cr, cairo_line_to(cr, right, bottom); } +/** fills the current clip region with evenly spaced diagonal hatch lines. + * center is the (x, y) coordinates of the region to draw; span is the + * distance of the region's bounds to the center, over (x, y) axes. + */ +static inline void dt_draw_hatches(cairo_t *cr, + const double center[2], + const double span[2], + const int instances, + const double line_width, + const double shade) +{ + const double C0[2] = { center[0] - span[0], center[1] - span[1] }; + const double C2[2] = { center[0] + span[0], center[1] + span[1] }; + + const double delta[2] = { 2.0 * span[0] / (double)instances, + 2.0 * span[1] / (double)instances }; + + cairo_set_line_width(cr, line_width); + cairo_set_source_rgb(cr, shade, shade, shade); + + for(int i = -instances / 2 - 1; i <= instances / 2 + 1; i++) + { + cairo_move_to(cr, C0[0] + (double)i * delta[0], C0[1]); + cairo_line_to(cr, C2[0] + (double)i * delta[0], C2[1]); + cairo_stroke(cr); + } +} + +/** one filled, optionally hatched circle of dt_draw_correction_cursor(). */ +static inline void _dt_draw_cursor_circle(cairo_t *cr, + const double x, + const double y, + const double radius, + const float color[3], + const float alpha, + const float zoom_scale, + const gboolean hatch, + const float frame_color[3]) +{ + const double radius_z = radius / zoom_scale; + + cairo_set_source_rgba(cr, color[0], color[1], color[2], alpha); + cairo_arc(cr, x, y, radius_z, 0, 2 * M_PI); + cairo_fill_preserve(cr); + cairo_save(cr); + cairo_clip(cr); + + if(hatch) + { + const double pointer_coord[2] = { x, y }; + const double span[2] = { radius_z, radius_z }; + dt_draw_hatches(cr, pointer_coord, span, 6, DT_PIXEL_APPLY_DPI(1.0 / zoom_scale), 0.3); + } + cairo_restore(cr); + + // outline the circle in the same color as the crosshair/wedge, so it + // stays legible against any background + cairo_set_source_rgb(cr, frame_color[0], frame_color[1], frame_color[2]); + cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(1.0 / zoom_scale)); + cairo_arc(cr, x, y, radius_z, 0, 2 * M_PI); + cairo_stroke(cr); +} + +/** + * dt_draw_correction_cursor — the on-canvas cursor shown by iop modules + * that let the user adjust a per-pixel correction by hovering/scrolling + * over the image (tone equalizer, color equalizer, ...): a crosshair + * frame, a pie-wedge on the left showing the magnitude/direction of the + * correction, one or two concentric filled circles, and a text label to + * the right. Factored out so every such module shares the same design. + * + * pointerx, pointery: cursor position, in the same coordinate space as + * the rest of the module's gui_post_expose() drawing. + * correction_norm: signed magnitude of the correction, roughly in + * [-1 ; 1] (values are clamped to that range here — pre-scale if your + * natural range is larger); drives the wedge's angular span, up to ±90°. + * frame_color: color of the wedge outline, the crosshair/ground-level + * lines, and the outline stroked around both circles. + * outer_color, inner_color: fill colors of the outer (radius 16) and + * inner (radius 8) circles — pass the same color for both for a plain + * single-color dot, or two different colors to show e.g. a value + * before/after the correction. + * outer_hatch, inner_hatch: overlay diagonal hatching on that circle + * (e.g. to flag an out-of-range value); pass FALSE to disable. + * text: label drawn in a background pill to the right of the circle + * (e.g. "+1.2 EV", "+12%", "-4.2°"); pass NULL or "" to omit it. + */ +static inline void dt_draw_correction_cursor(cairo_t *cr, + const double pointerx, + const double pointery, + const float zoom_scale, + const float correction_norm, + const float frame_color[3], + const float outer_color[3], + const gboolean outer_hatch, + const float inner_color[3], + const gboolean inner_hatch, + const char *text) +{ + const double outer_radius = 16.0; + const double inner_radius = outer_radius / 2.0; + const double padding = 4.0; // matches the bauhaus quad padding (kept in sync manually) + const double setting_offset_x = (outer_radius + 4.0 * padding) / zoom_scale; + const double fill_width = DT_PIXEL_APPLY_DPI(4.0 / zoom_scale); + + // wedge showing the magnitude/direction of the correction. + // Opens up to ±90° (M_PI_2) at full magnitude (|correction_norm| = 1), + // whereas a neutral (0) correction leaves a bare horizontal bar. + const double wedge_end = M_PI + CLAMP(correction_norm, -1.0, 1.0) * M_PI_2; + cairo_set_source_rgb(cr, frame_color[0], frame_color[1], frame_color[2]); + cairo_set_line_width(cr, 2.0 * fill_width); + cairo_move_to(cr, pointerx - setting_offset_x, pointery); + if(correction_norm >= 0.0f) + cairo_arc(cr, pointerx, pointery, setting_offset_x, M_PI, wedge_end); + else + cairo_arc_negative(cr, pointerx, pointery, setting_offset_x, M_PI, wedge_end); + cairo_stroke(cr); + + // ground-level reference bars + cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(1.5 / zoom_scale)); + cairo_move_to(cr, pointerx + (outer_radius + 2.0 * padding) / zoom_scale, pointery); + cairo_line_to(cr, pointerx + outer_radius / zoom_scale, pointery); + cairo_move_to(cr, pointerx - outer_radius / zoom_scale, pointery); + cairo_line_to(cr, pointerx - setting_offset_x - 4.0 * padding / zoom_scale, pointery); + cairo_stroke(cr); + + // crosshair + cairo_move_to(cr, pointerx, pointery + setting_offset_x + fill_width); + cairo_line_to(cr, pointerx, pointery + outer_radius / zoom_scale); + cairo_move_to(cr, pointerx, pointery - outer_radius / zoom_scale); + cairo_line_to(cr, pointerx, pointery - setting_offset_x - fill_width); + cairo_stroke(cr); + + _dt_draw_cursor_circle(cr, pointerx, pointery, outer_radius, outer_color, 0.9f, zoom_scale, outer_hatch, frame_color); + _dt_draw_cursor_circle(cr, pointerx, pointery, inner_radius, inner_color, 0.9f, zoom_scale, inner_hatch, frame_color); + + if(!text || !*text) return; + + PangoFontDescription *desc = dt_gui_get_font(); + const int old_size = pango_font_description_get_size(desc); + pango_font_description_set_size(desc, (int)(old_size / zoom_scale)); + + PangoLayout *layout = pango_cairo_create_layout(cr); + pango_layout_set_font_description(layout, desc); + pango_cairo_context_set_resolution(pango_layout_get_context(layout), darktable.gui->dpi); + pango_layout_set_text(layout, text, -1); + + PangoRectangle ink; + pango_layout_get_pixel_extents(layout, &ink, NULL); + + const double pad = padding / zoom_scale; + const double tx = pointerx + (outer_radius + 2.0 * padding) / zoom_scale; + const double ty = pointery - ink.y - ink.height / 2.0 - pad; + + cairo_rectangle(cr, tx, ty, + ink.width + 2.0 * ink.x + 2.0 * pad, ink.height + 2.0 * ink.y + 2.0 * pad); + cairo_set_source_rgba(cr, 0.0, 0.0, 0.0, 0.8); + cairo_fill(cr); + + cairo_move_to(cr, tx + pad, pointery - ink.y - ink.height / 2.0); + cairo_set_source_rgba(cr, 1.0, 1.0, 1.0, 1.0); + pango_cairo_show_layout(cr, layout); + cairo_stroke(cr); + + pango_font_description_free(desc); + g_object_unref(layout); +} + static inline void dt_draw_grid(cairo_t *cr, const int num, const int left, diff --git a/src/iop/colorequal.c b/src/iop/colorequal.c old mode 100644 new mode 100755 index 0fb536cf32..47f36d61b2 --- a/src/iop/colorequal.c +++ b/src/iop/colorequal.c @@ -71,6 +71,7 @@ None;midi:CC24=iop/colorequal/brightness/magenta #include "develop/imageop.h" #include "develop/imageop_math.h" #include "develop/imageop_gui.h" +#include "develop/preview_data.h" #include "develop/tiling.h" #include "dtgtk/drawingarea.h" #include "dtgtk/expander.h" @@ -282,6 +283,35 @@ typedef struct dt_iop_colorequal_gui_data_t gboolean on_node; int selected; float points[NODES+1][2]; + + // Hue read under mouse cursor (degrees, GUI space 0..360) + float cursor_hue; + // TRUE if the last hue reading is usable (picker active, sufficient chroma) + gboolean cursor_valid; + + // Cursor position in preview image coordinates (for gui_post_expose) + float cursor_pos_x; + float cursor_pos_y; + + // TRUE once a preview reprocess has been requested to (re)fill pd and + // no fresh data has been observed since. Prevents flooding the pipeline + // with redundant reprocess requests while hovering with a stale/missing + // buffer (see mouse_moved()/gui_focus()). + gboolean reprocess_pending; + + // Last cursor position within the graph/histogram widget, in widget + // pixel coordinates, and whether it is currently valid. Independent of + // cursor_pos_x/y (which track the mouse over the main image) so that + // scrolling on the graph acts on the node actually under the cursor + // there, not on the last hue seen while hovering the image. + float graph_cursor_x; + gboolean graph_cursor_valid; + + // Shared preview pipe under-cursor data (buffer + freshness hash), + // filled by process() for the preview pipe (CPU and OpenCL paths). + // Enables direct reading of the UCS hue (radians) under the cursor + // without depending on the GTK color picker (asynchronous). + dt_preview_data_t pd; } dt_iop_colorequal_gui_data_t; void init_global(dt_iop_module_so_t *self) @@ -950,6 +980,23 @@ static void _prepare_process(const float roi_scale, _init_satweights(d->contrast); } +static void _copy_HSB_cb(void *const user_data, + float *const buf, + const size_t npixels) +{ + // pix_out[0..2] = HSB of the module input pixel: hue (radians UCS), + // saturation, brightness — computed in process() before any correction. + // npixels covers components floats per pixel (3·width·height). + const float *const src = (const float *)user_data; + DT_OMP_FOR() + for(size_t k = 0; k < npixels / 3; k++) + { + buf[k * 3 + 0] = src[k * 4 + 0]; + buf[k * 3 + 1] = src[k * 4 + 1]; + buf[k * 3 + 2] = src[k * 4 + 2]; + } +} + void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *const i, @@ -1088,6 +1135,17 @@ void process(dt_iop_module_t *self, } } + // Cache the HSB (hue in radians, saturation, brightness) of the module + // *input* pixel in the preview buffer for mouse_moved/scrolled and the + // cursor in/out colors. The service resizes, fills and commits the + // freshness hash under one GUI lock so the GUI thread can never observe + // a resized but not-yet-filled buffer. + if(g && (piece->pipe->type & DT_DEV_PIXELPIPE_PREVIEW)) + { + dt_iop_colorequal_gui_data_t *gui = self->gui_data; // non-const for writing + dt_preview_data_store(&gui->pd, width, height, piece, _copy_HSB_cb, (void *)out); + } + if(d->use_filter && !run_fast) { // blur the saturation gradients @@ -1586,6 +1644,25 @@ int process_cl(dt_iop_module_t *self, CLARG(width), CLARG(height)); if(err != CL_SUCCESS) goto error; + // On the preview pipe, read the original (uncorrected) HSB back from the + // GPU pixout buffer to populate the shared preview buffer for + // mouse_moved/scrolled. pixout[k].xyz contains the raw HSB of the + // module input (same as the CPU process() path). + if(self->gui_data && (piece->pipe->type & DT_DEV_PIXELPIPE_PREVIEW)) + { + dt_iop_colorequal_gui_data_t *gui = (dt_iop_colorequal_gui_data_t *)self->gui_data; + const size_t npixels = (size_t)width * height; + const size_t px_sz = 4 * npixels * sizeof(float); + float *host_pixout = dt_alloc_align_float(4 * npixels); + if(host_pixout) + { + err = dt_opencl_read_buffer_from_device(devid, host_pixout, pixout, 0, px_sz, TRUE); + if(err == CL_SUCCESS) + dt_preview_data_store(&gui->pd, width, height, piece, _copy_HSB_cb, (void *)host_pixout); + dt_free_align(host_pixout); + } + } + if(guiding && !run_fast) { err = dt_gaussian_mean_blur_cl(devid, Lscharr, width, height, 1, scharr_sigma); @@ -2232,10 +2309,67 @@ void init_presets(dt_iop_module_so_t *self) TRUE, DEVELOP_BLEND_CS_RGB_SCENE); } +/* _switch_cursors — mirrors the tone equalizer's on-canvas cursor + * handling: hide the native GTK cursor so only our own indicator + * (gui_post_expose) is visible while a valid reading is available, + * whether or not the preview pipe is still (re)computing. No busy/wait + * animation is shown while hovering. Falls back to the default cursor + * otherwise (mask editing, module not focused, no valid reading yet). + */ +static void _switch_cursors(dt_iop_module_t *self) +{ + dt_iop_colorequal_gui_data_t *g = self->gui_data; + if(!g || !self->dev->gui_attached) return; + + GtkWidget *widget = dt_ui_main_window(darktable.gui->ui); + + // Editing a mask (brush/path/etc.) or canvas otherwise not interactive: + // leave the default cursor alone. + if((self->dev->form_gui && self->dev->form_gui->creation) + || dt_iop_canvas_not_sensitive(self->dev)) + { + GdkCursor *const cursor = gdk_cursor_new_from_name(gdk_display_get_default(), "default"); + gdk_window_set_cursor(gtk_widget_get_window(widget), cursor); + g_object_unref(cursor); + return; + } + + if(!self->expanded) + return; // module not focused: let the app decide + + if(g->cursor_valid) + { + // valid reading: hide the native cursor and rely on the custom + // indicator drawn by gui_post_expose, whatever the pipe processing + // state (no busy animation while hovering) + dt_control_change_cursor("none"); + } + else + { + GdkCursor *const cursor = gdk_cursor_new_from_name(gdk_display_get_default(), "default"); + gdk_window_set_cursor(gtk_widget_get_window(widget), cursor); + g_object_unref(cursor); + } +} + void gui_focus(dt_iop_module_t *self, gboolean in) { dt_iop_colorequal_gui_data_t *g = self->gui_data; - if(!in) + if(in) + { + // Opening/focusing the module does not by itself dirty the pipe, so the + // shared under-cursor buffer can still be NULL/stale (gui_init() always + // resets it). Kick a preview-only reprocess so hovering works right + // away instead of only after some unrelated trigger happens to also + // reprocess the preview pipe. + if(!dt_preview_data_is_fresh(&g->pd) && !g->reprocess_pending) + { + g->reprocess_pending = TRUE; + dt_dev_reprocess_preview(self->dev, self->iop_order); + } + _switch_cursors(self); + } + else { dt_iop_color_picker_reset(self, FALSE); const gboolean buttons = g->mask_mode != 0; @@ -2243,7 +2377,12 @@ void gui_focus(dt_iop_module_t *self, gboolean in) dt_bauhaus_widget_set_quad_active(g->threshold, FALSE); dt_bauhaus_widget_set_quad_active(g->hue_shift, FALSE); g->mask_mode = 0; + g->cursor_valid = FALSE; // disables Gaussian mode when module loses focus + g->reprocess_pending = FALSE; + dt_preview_data_invalidate(&g->pd); if(buttons) dt_dev_reprocess_center(self->dev, self->iop_order); + _switch_cursors(self); + dt_control_queue_redraw_center(); } } @@ -2494,6 +2633,23 @@ static gboolean _iop_colorequalizer_draw(GtkWidget *widget, cairo_fill(cr); } + // Draw a white vertical line showing the hue currently under the mouse + // cursor on the main image (mirroring the tone equalizer's exposure + // cursor line). The graph x-axis is linear in conventional GUI degrees, + // shifted by hue_shift, so the hue is mapped directly to x. + if(self->enabled && g->cursor_valid) + { + float x_cursor = (g->cursor_hue / 360.0f + dx) * graph_width; + x_cursor = fmodf(x_cursor, graph_width); // hue is periodic + if(x_cursor < 0.0f) x_cursor += graph_width; + + cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(1.5)); + set_color(cr, darktable.bauhaus->graph_fg); + cairo_move_to(cr, x_cursor, 0.0); + cairo_line_to(cr, x_cursor, graph_height); + cairo_stroke(cr); + } + dt_free_align(g->LUT); if(self->enabled && self->request_color_pick == DT_REQUEST_COLORPICK_MODULE) @@ -2537,6 +2693,556 @@ static void _pipe_RGB_to_Ych(dt_iop_module_t *self, Ych[2] = DT_2PI_F + Ych[2]; } +/* mouse_moved — updates the hue read under the mouse cursor. + * + * Reads directly from the preview buffer filled by process() to get + * the UCS hue (radians) at the point under the cursor. No dependency + * on the GTK color picker (asynchronous and unpredictable). + * + * The hue stored in the preview buffer (g->pd.buf) is in UCS radians [-π ; π] + * (from atan2f(V, U) in dt_UCS_LUV_to_JCH). + * + * Conversion to GUI degrees: + * ucs_rad = deg2rad(gui_deg + ANGLE_SHIFT) + * → gui_deg = rad2deg(ucs_rad) − ANGLE_SHIFT + * + * Returns 0 to let darktable propagate the event normally. + */ +int mouse_moved(dt_iop_module_t *self, + const float pzx, + const float pzy, + const double pressure, + const int which, + const float zoom_scale) +{ + dt_iop_colorequal_gui_data_t *g = self->gui_data; + if(!g) return 0; + + // Disable cursor tracking when drawing a mask (brush/path/etc.) + if(self->dev->form_gui && self->dev->form_gui->creation) + { + g->cursor_valid = FALSE; + _switch_cursors(self); + return 0; + } + + // Read hue (component 0 of the stored HSB) from the preview buffer + float hue_rad = 0.f; + gboolean have_hue = FALSE; + dt_iop_gui_enter_critical_section(self); + const float *buf = g->pd.buf; + const int bwidth = g->pd.width; + const int bheight = g->pd.height; + if(buf != NULL && bwidth > 0 && bheight > 0) + { + const int cx = CLAMP((int)(pzx * bwidth), 0, bwidth - 1); + const int cy = CLAMP((int)(pzy * bheight), 0, bheight - 1); + hue_rad = buf[3 * ((size_t)cy * bwidth + cx)]; + have_hue = TRUE; + } + dt_iop_gui_leave_critical_section(self); + + if(!have_hue) + { + g->cursor_valid = FALSE; + // The buffer is missing entirely (e.g. gui_init() just reset it, or the + // module was never reprocessed on the preview pipe yet). Nothing else + // will refill it on its own — ask for a preview reprocess, debounced so + // we don't flood the pipeline while hovering with no data available. + if(!g->reprocess_pending) + { + g->reprocess_pending = TRUE; + dt_dev_reprocess_preview(self->dev, self->iop_order); + } + _switch_cursors(self); + return 0; + } + + // UCS hue in radians (may be in [-π ; π]) + if(hue_rad < 0.f) hue_rad += DT_2PI_F; + + // Convert to GUI degrees: inverse of _conventional_hue_deg_to_ucs_rad() + g->cursor_hue = hue_rad * (180.f / M_PI_F) - ANGLE_SHIFT; + + // Wrap into [0 ; 360[ + if(g->cursor_hue < 0.f) g->cursor_hue += 360.f; + if(g->cursor_hue >= 360.f) g->cursor_hue -= 360.f; + + // Store normalized cursor position [0..1] for gui_post_expose + g->cursor_pos_x = pzx; + g->cursor_pos_y = pzy; + + // Validate buffer freshness against the cumulative pipe hash. + // cursor_valid is set TRUE only when the hash matches, so the GUI + // indicator (gui_post_expose) and the graph Gaussian mode + // (_area_scrolled_callback) never see stale pipeline data. + g->cursor_valid = dt_preview_data_is_fresh(&g->pd); + if(g->cursor_valid) + { + g->reprocess_pending = FALSE; + dt_control_queue_redraw_center(); + } + else if(!g->reprocess_pending) + { + // Buffer exists but is stale (params changed since it was filled) — + // same debounced reprocess request as above so tracking self-heals + // instead of staying frozen until an unrelated trigger (e.g. scroll) + // happens to kick a reprocess. + g->reprocess_pending = TRUE; + dt_dev_reprocess_preview(self->dev, self->iop_order); + } + _switch_cursors(self); + return 0; +} + +/* mouse_leave — invalidates hue tracking when the mouse leaves the image. + * + * Without this, cursor_valid would remain TRUE with stale hue data, + * and the scroll wheel would continue affecting sliders even outside the image. + */ +int mouse_leave(dt_iop_module_t *self) +{ + dt_iop_colorequal_gui_data_t *g = self->gui_data; + if(!g) return 0; + + g->cursor_valid = FALSE; + _switch_cursors(self); + gtk_widget_queue_draw(GTK_WIDGET(g->area)); + dt_control_queue_redraw_center(); + + return 1; +} + +// Forward declarations: defined further down in the file, alongside the +// rest of the node/Gaussian-weighting helpers they belong with, but also +// needed here to draw the "% from neutral" readout in gui_post_expose(). +static float *_get_param_ptr(dt_iop_colorequal_params_t *p, + const dt_iop_colorequal_channel_t channel, + const int k, + float *out_min, + float *out_max); +static float _gaussian_interp_value(const dt_iop_colorequal_params_t *p, + const dt_iop_colorequal_gui_data_t *g, + const float ref_hue_deg); + +/* gui_post_expose — draws a color indicator circle over the image + * showing the color under the cursor, plus a "% from neutral" (or, for + * the hue channel, "° from neutral") readout of the active channel's + * current correction at that hue. + */ +void gui_post_expose(dt_iop_module_t *self, + cairo_t *cr, + const float width, + const float height, + const float pointerx, + const float pointery, + const float zoom_scale) +{ + dt_iop_colorequal_gui_data_t *g = self->gui_data; + if(!g || !g->cursor_valid) return; + + // Hide cursor indicator when drawing a mask (brush/path/etc.) + if(self->dev->form_gui && self->dev->form_gui->creation) return; + + // Read the color from the preview pipe backbuf + dt_develop_t *dev = self->dev; + dt_pthread_mutex_t *mutex = &dev->preview_pipe->backbuf_mutex; + uint8_t *backbuf = dev->preview_pipe->backbuf; + const int buf_w = dev->preview_pipe->backbuf_width; + const int buf_h = dev->preview_pipe->backbuf_height; + + float cr_f = 0.5f, cg_f = 0.5f, cb_f = 0.5f; // fallback grey + + if(backbuf && buf_w > 0 && buf_h > 0) + { + const int px = CLAMP((int)(g->cursor_pos_x * buf_w), 0, buf_w - 1); + const int py = CLAMP((int)(g->cursor_pos_y * buf_h), 0, buf_h - 1); + + dt_pthread_mutex_lock(mutex); + const size_t idx = (size_t)py * buf_w * 4 + px * 4; + // backbuf is CAIRO_FORMAT_ARGB32: B, G, R, A byte order on little-endian + cb_f = backbuf[idx + 0] / 255.0f; + cg_f = backbuf[idx + 1] / 255.0f; + cr_f = backbuf[idx + 2] / 255.0f; + dt_pthread_mutex_unlock(mutex); + } + + // Position in full image coordinates + const float cx = g->cursor_pos_x * width; + const float cy = g->cursor_pos_y * height; + + // "% from neutral" (hue: "° from neutral") readout for the active + // channel, using the same Gaussian blend scrolling would apply here. + const dt_iop_colorequal_params_t *p = self->params; + const float value = _gaussian_interp_value(p, g, g->cursor_hue); + + char text[64]; + // Wedge magnitude/direction, scaled to the shared cursor's ±45° range: + // hue is an offset in [-180° ; 180°], sat/bright a gain in [0 ; 2] with + // 1.0 = neutral (so value - 1.0 is already in [-1 ; 1]). + float correction_norm; + if(g->channel == HUE) + { + snprintf(text, sizeof(text), "%+.1f°", value); // value is already an offset from neutral (0°) + correction_norm = value / 180.0f; + } + else + { + snprintf(text, sizeof(text), "%+.1f%%", (value - 1.0f) * 100.0f); // 1.0 = neutral gain + correction_norm = value - 1.0f; + } + + // Module input/output colors at the cursor, read from the shared + // preview buffer (same HSB of the module *input* pixel that mouse_moved + // and scrolled use, stored by process() from the pre-correction values). + // The "out" color replays the exact process() correction math — the three + // RBF LUTs from the current params combined as in STEP 4/5 of process(). + float in_color[3] = { cr_f, cg_f, cb_f }; + float out_color[3] = { cr_f, cg_f, cb_f }; + + if(g->pd.buf && g->pd.width > 0 && g->pd.height > 0 && g->gamut_LUT) + { + const int p_cx = CLAMP((int)(g->cursor_pos_x * g->pd.width), 0, (int)g->pd.width - 1); + const int p_cy = CLAMP((int)(g->cursor_pos_y * g->pd.height), 0, (int)g->pd.height - 1); + + // Read the 3 HSB components under one lock, like mouse_moved does. + float hue_in = 0.f, sat_in = 0.f, bright_in = 0.f; + gboolean have_hsb = FALSE; + dt_iop_gui_enter_critical_section(self); + const float *buf = g->pd.buf; + if(buf) + { + const size_t idx = (size_t)p_cy * g->pd.width + p_cx; + hue_in = buf[3 * idx + 0]; + sat_in = buf[3 * idx + 1]; + bright_in = buf[3 * idx + 2]; + have_hsb = TRUE; + } + dt_iop_gui_leave_critical_section(self); + + if(have_hsb) + { + // Rebuild the three RBF LUTs from the current params, exactly as + // commit_params() does for the pipe data. + float DT_ALIGNED_ARRAY sat_values[NODES]; + float DT_ALIGNED_ARRAY hue_values[NODES]; + float DT_ALIGNED_ARRAY bright_values[NODES]; + float DT_ALIGNED_ARRAY LUT_hue[LUT_ELEM]; + float DT_ALIGNED_ARRAY LUT_sat[LUT_ELEM]; + float DT_ALIGNED_ARRAY LUT_bright[LUT_ELEM]; + + _pack_saturation(p, sat_values); + _periodic_RBF_interpolate(sat_values, M_PI_F, LUT_sat, p->hue_shift, TRUE); + _pack_hue(p, hue_values); + _periodic_RBF_interpolate(hue_values, 1.f / p->smoothing_hue * M_PI_F, + LUT_hue, p->hue_shift, FALSE); + _pack_brightness(p, bright_values); + _periodic_RBF_interpolate(bright_values, M_PI_F, LUT_bright, p->hue_shift, TRUE); + + // Corrections as in process() STEP 3/4 (hue is an offset, sat a gain, + // brightness a gain applied through b_corrections). + const float corr_hue = lookup_gamut(LUT_hue, hue_in); + const float corr_sat = lookup_gamut(LUT_sat, hue_in); + const float b_corr = sat_in * (lookup_gamut(LUT_bright, hue_in) - 1.0f); + + const float hue_out = hue_in + corr_hue; + const float sat_out = MAX(0.f, sat_in * (1.f + SAT_EFFECT * (corr_sat - 1.f))); + const float bright_out = MAX(0.f, bright_in * (1.f + BRIGHT_EFFECT * b_corr)); + + // gamut-map + convert to display RGB, same path as the module's + // sliders/graphs (g->white_adapted_profile may be NULL → sRGB fallback + // inside _build_dt_UCS_HSB_gradients). + dt_aligned_pixel_t RGB = { 1.f }; + _build_dt_UCS_HSB_gradients((dt_aligned_pixel_t){ hue_in, sat_in, bright_in, 1.0f }, + RGB, g->white_adapted_profile, g->gamut_LUT); + in_color[0] = RGB[0]; + in_color[1] = RGB[1]; + in_color[2] = RGB[2]; + + _build_dt_UCS_HSB_gradients((dt_aligned_pixel_t){ hue_out, sat_out, bright_out, 1.0f }, + RGB, g->white_adapted_profile, g->gamut_LUT); + out_color[0] = RGB[0]; + out_color[1] = RGB[1]; + out_color[2] = RGB[2]; + } + } + + // Crosshair/wedge/outline color adapts to the sampled background, same + // spirit as the tone equalizer's cursor: white over dark content, black + // over light content, so it stays legible everywhere. + const float bg_luma = 0.3f * cr_f + 0.59f * cg_f + 0.11f * cb_f; + const float frame_shade = (bg_luma > 0.5f) ? 0.0f : 1.0f; + const float frame_color[3] = { frame_shade, frame_shade, frame_shade }; + + dt_draw_correction_cursor(cr, cx, cy, zoom_scale, correction_norm, + frame_color, + in_color, FALSE, // outer: module input color + out_color, FALSE, // inner: module output color + text); + + // keep the graph's cursor indicator (white vertical line) in sync + gtk_widget_queue_draw(GTK_WIDGET(g->area)); +} + +/* _get_param_ptr — returns a direct pointer to the parameter value + * of node k for the active channel, along with its min/max bounds. + * + * Uses offsetof() for copy-free access to struct fields, + * consistent with _pack_saturation / _pack_hue / _pack_brightness. + * + * Bounds per channel: + * HUE : [-180° ; +180°] + * SATURATION : [ 0.0 ; 2.0] (multiplier, 1.0 = neutral) + * BRIGHTNESS : [ 0.0 ; 2.0] (multiplier, 1.0 = neutral) + */ +static float *_get_param_ptr(dt_iop_colorequal_params_t *p, + const dt_iop_colorequal_channel_t channel, + const int k, + float *out_min, + float *out_max) +{ + // Offsets in the struct — same order as the _pack_*() functions + static const size_t sat_off[NODES] = { + offsetof(dt_iop_colorequal_params_t, sat_red), + offsetof(dt_iop_colorequal_params_t, sat_orange), + offsetof(dt_iop_colorequal_params_t, sat_yellow), + offsetof(dt_iop_colorequal_params_t, sat_green), + offsetof(dt_iop_colorequal_params_t, sat_cyan), + offsetof(dt_iop_colorequal_params_t, sat_blue), + offsetof(dt_iop_colorequal_params_t, sat_lavender), + offsetof(dt_iop_colorequal_params_t, sat_magenta) }; + + static const size_t hue_off[NODES] = { + offsetof(dt_iop_colorequal_params_t, hue_red), + offsetof(dt_iop_colorequal_params_t, hue_orange), + offsetof(dt_iop_colorequal_params_t, hue_yellow), + offsetof(dt_iop_colorequal_params_t, hue_green), + offsetof(dt_iop_colorequal_params_t, hue_cyan), + offsetof(dt_iop_colorequal_params_t, hue_blue), + offsetof(dt_iop_colorequal_params_t, hue_lavender), + offsetof(dt_iop_colorequal_params_t, hue_magenta) }; + + static const size_t bright_off[NODES] = { + offsetof(dt_iop_colorequal_params_t, bright_red), + offsetof(dt_iop_colorequal_params_t, bright_orange), + offsetof(dt_iop_colorequal_params_t, bright_yellow), + offsetof(dt_iop_colorequal_params_t, bright_green), + offsetof(dt_iop_colorequal_params_t, bright_cyan), + offsetof(dt_iop_colorequal_params_t, bright_blue), + offsetof(dt_iop_colorequal_params_t, bright_lavender), + offsetof(dt_iop_colorequal_params_t, bright_magenta) }; + + char *base = (char *)p; + switch(channel) + { + case HUE: + *out_min = -180.f; *out_max = 180.f; + return (float *)(base + hue_off[k]); + case SATURATION: + *out_min = 0.f; *out_max = 2.f; + return (float *)(base + sat_off[k]); + case BRIGHTNESS: + default: + *out_min = 0.f; *out_max = 2.f; + return (float *)(base + bright_off[k]); + } +} + +static GtkWidget *_get_slider(const dt_iop_colorequal_gui_data_t *g, const int selected) +{ + GtkWidget *w = NULL; + + switch(g->channel) + { + case(SATURATION): + w = g->sat_sliders[selected]; + break; + case(HUE): + w = g->hue_sliders[selected]; + break; + case(BRIGHTNESS): + default: + w = g->bright_sliders[selected]; + break; + } + + return w; +} + +// Sigma (degrees) of the Gaussian weighting used both when adjusting +// sliders around the cursor hue and when reading back the interpolated +// value under the cursor for display. +#define GAUSSIAN_SIGMA_DEG 35.0f + +/* Angular position of node k in GUI degrees [0 ; 360[, accounting for hue_shift. */ +static inline float _node_hue_deg(const int k, const float hue_shift) +{ + const float node_ucs_rad = _get_hue_node(k, hue_shift); + float node_deg = node_ucs_rad * (180.f / M_PI_F) - ANGLE_SHIFT; + if(node_deg < 0.f) node_deg += 360.f; + if(node_deg >= 360.f) node_deg -= 360.f; + return node_deg; +} + +/* Minimum circular distance between two hues in degrees, in [0 ; 180°]. */ +static inline float _hue_circular_dist_deg(const float a, const float b) +{ + float dist = fabsf(a - b); + if(dist > 180.f) dist = 360.f - dist; + return dist; +} + +/* Gaussian weight for a given circular hue distance: 1.0 at center, + * decays to 0 at large distance. Shared by the slider-adjustment path + * and the under-cursor value readout so they can't drift apart. */ +static inline float _gaussian_weight(const float dist_deg) +{ + const float inv2s2 = 1.0f / (2.0f * GAUSSIAN_SIGMA_DEG * GAUSSIAN_SIGMA_DEG); + return expf(-(dist_deg * dist_deg) * inv2s2); +} + +/* Gaussian-weighted blend of the active channel's current per-node values + * around ref_hue_deg — the same weighting scrolling would apply, read + * back rather than written, for the on-canvas "value at cursor" readout. + */ +static float _gaussian_interp_value(const dt_iop_colorequal_params_t *p, + const dt_iop_colorequal_gui_data_t *g, + const float ref_hue_deg) +{ + float wsum = 0.f, vsum = 0.f; + + for(int k = 0; k < NODES; k++) + { + const float node_deg = _node_hue_deg(k, p->hue_shift); + const float dist = _hue_circular_dist_deg(ref_hue_deg, node_deg); + const float weight = _gaussian_weight(dist); + + float vmin, vmax; + const float *val = _get_param_ptr((dt_iop_colorequal_params_t *)p, g->channel, k, &vmin, &vmax); + wsum += weight; + vsum += weight * (*val); + } + + return (wsum > 1e-6f) ? (vsum / wsum) : 0.f; +} + +/* Apply a Gaussian-weighted adjustment to all sliders of the active + * channel, centered on ref_hue_deg. + * Nodes farther than sigma (35°) receive diminishing influence; + * contributions below 1% are skipped. + * Returns TRUE if any slider value changed. + */ +static gboolean _adjust_params_gaussian(dt_iop_module_t *self, + dt_iop_colorequal_params_t *p, + dt_iop_colorequal_gui_data_t *g, + const float move, + const float ref_hue_deg) +{ + gboolean changed = FALSE; + + for(int k = 0; k < NODES; k++) + { + const float node_deg = _node_hue_deg(k, p->hue_shift); + const float dist = _hue_circular_dist_deg(ref_hue_deg, node_deg); + + const float weight = _gaussian_weight(dist); + if(weight < 0.01f) continue; // negligible contribution + + float vmin, vmax; + float *val = _get_param_ptr(p, g->channel, k, &vmin, &vmax); + *val = CLAMP(*val + move * weight, vmin, vmax); + + // Update the slider — let the callback fire for redraw + GtkWidget *w = _get_slider(g, k); + if(w) dt_bauhaus_slider_set(w, *val); + + changed = TRUE; + } + + if(changed) + { + dt_dev_add_history_item(self->dev, self, TRUE); + gtk_widget_queue_draw(GTK_WIDGET(g->area)); + } + + return changed; +} + +/* scrolled — IOP hook called by darktable when the scroll wheel is used + * WHILE THE MOUSE IS OVER THE IMAGE in the darkroom (not over the GUI panel). + * + * This function — not _area_scrolled_callback — intercepts the event + * before darktable sends it to the zoom handler. + * Returning 1 consumes the event and BLOCKS image zoom. + * Returning 0 lets darktable zoom normally. + * + * Logic: + * - Reads the hue directly from the cached preview buffer + * (not via mouse_moved, to avoid gating on pipeline hash) + * - If a valid hue is available under the cursor + * → applies Gaussian weighting to the active channel's sliders + * → returns 1 to block zoom + * - Otherwise → returns 0, normal zoom + */ +int scrolled(dt_iop_module_t *self, + const float x, + const float y, + const int up, + const uint32_t state) +{ + dt_iop_colorequal_gui_data_t *g = self->gui_data; + + if(!g) return 0; + + // Alt+scroll: switch channel tab, matching the graph's Alt+scroll + // behavior (_area_scrolled_callback). Handled before the hue lookup + // below since switching tabs doesn't need a hue reading. + if(dt_modifier_is(state, GDK_MOD1_MASK)) + { + const int pages = gtk_notebook_get_n_pages(g->notebook); + const int current = gtk_notebook_get_current_page(g->notebook); + const int next = (current + (up ? 1 : -1) + pages) % pages; + gtk_notebook_set_current_page(g->notebook, next); + return 1; // consumes the event → blocks image zoom, same as the normal path + } + + // Read the hue directly from the cached preview buffer (race-safe). + // We do NOT call mouse_moved() here because that would gate on the + // pipeline hash — scroll-based adjustment should work even with + // slightly stale data rather than falling through to image zoom. + float hue_rad = 0.f; + gboolean have_hue = FALSE; + if(g->pd.buf && g->pd.width > 0 && g->pd.height > 0) + { + const int cx = CLAMP((int)(x * g->pd.width), 0, (int)g->pd.width - 1); + const int cy = CLAMP((int)(y * g->pd.height), 0, (int)g->pd.height - 1); + have_hue = dt_preview_data_get(&g->pd, cx, cy, 0, &hue_rad); + } + if(!have_hue) return 0; + + // Convert UCS hue → GUI degrees + if(hue_rad < 0.f) hue_rad += DT_2PI_F; + float hue_deg = hue_rad * (180.f / M_PI_F) - ANGLE_SHIFT; + if(hue_deg < 0.f) hue_deg += 360.f; + if(hue_deg >= 360.f) hue_deg -= 360.f; + g->cursor_hue = hue_deg; + g->cursor_pos_x = x; + g->cursor_pos_y = y; + g->cursor_valid = TRUE; + + // Step: 1.0 for hue (°), 0.01 for sat/bright (%) + // Ctrl for fine precision (÷10) + const float base_step = (g->channel == HUE) ? 1.0f : 0.01f; + const float step = dt_modifier_is(state, GDK_CONTROL_MASK) ? base_step * 0.1f : base_step; + // up=1 → scroll up → increase value + const float move = up ? +step : -step; + + _adjust_params_gaussian(self, self->params, g, move, g->cursor_hue); + _switch_cursors(self); + + return 1; // consumes the event → BLOCKS image zoom +} + void color_picker_apply(dt_iop_module_t *self, GtkWidget *picker, dt_dev_pixelpipe_t *pipe) @@ -2611,28 +3317,6 @@ static void _channel_tabs_switch_callback(GtkNotebook *notebook, gtk_widget_queue_draw(GTK_WIDGET(g->area)); } -static GtkWidget *_get_slider(const dt_iop_colorequal_gui_data_t *g, const int selected) -{ - GtkWidget *w = NULL; - - switch(g->channel) - { - case(SATURATION): - w = g->sat_sliders[selected]; - break; - case(HUE): - w = g->hue_sliders[selected]; - break; - case(BRIGHTNESS): - default: - w = g->bright_sliders[selected]; - break; - } - - gtk_widget_realize(w); - return w; -} - static void _area_set_value(const dt_iop_colorequal_gui_data_t *g, const float graph_height, const float pos) @@ -2695,18 +3379,130 @@ static void _area_reset_nodes(dt_iop_colorequal_gui_data_t *g) } } +/* _graph_x_to_hue_deg — continuous hue (GUI degrees) under a given x + * position within the graph widget. Nodes are laid out at evenly spaced x + * positions independent of hue_shift (see the drawing loop populating + * g->points[]), while their hue value is offset by hue_shift, so we + * interpolate the fractional node index from x and apply the same + * per-node hue mapping as _node_hue_deg() to it. Used so that scrolling + * on the graph can weight around the hue actually under the cursor there, + * rather than the last hue seen while hovering the main image. + */ +static float _graph_x_to_hue_deg(const dt_iop_colorequal_gui_data_t *g, + const dt_iop_colorequal_params_t *p, + const float x) +{ + const float span = g->points[1][0] - g->points[0][0]; + if(fabsf(span) < 1e-6f) return g->cursor_hue; // graph not laid out yet + + const float frac_k = (x - g->points[0][0]) / span; + float hue_deg = _node_hue_deg(0, p->hue_shift) + frac_k * (360.f / (float)NODES); + hue_deg = fmodf(hue_deg, 360.f); + if(hue_deg < 0.f) hue_deg += 360.f; + return hue_deg; +} + +/* _area_scrolled_callback — scroll wheel handling on the graph. + * + * Behavior depending on preview buffer state: + * + * A) Buffer exists (g->pd.buf != NULL) → Gaussian mode + * The scroll wheel modifies all sliders of the active channel based on + * their angular distance to the hue under the cursor on the graph + * itself. Weight follows a Gaussian with sigma=35°: the closest node + * receives maximum movement, neighbors receive a decreasing fraction. + * This ensures smooth transitions with no dead zones. + * + * B) No buffer yet → classic single-node behavior + * The scroll wheel is forwarded to the slider of the selected node in the graph. + * + * C) Alt+scroll: switch page (original behavior unchanged). + * + * Modifiers: + * Ctrl → fine step (0.001 instead of 0.01) + */ static void _area_scrolled_callback(GtkEventControllerScroll *controller, - gdouble dx, - gdouble dy, - dt_iop_module_t *self) + gdouble dx, + gdouble dy, + dt_iop_module_t *self) { - const dt_iop_colorequal_gui_data_t *g = self->gui_data; + GtkWidget *const widget = dt_gui_get_widget(controller); + dt_iop_colorequal_gui_data_t *g = self->gui_data; + dt_iop_colorequal_params_t *p = self->params; + + const GdkModifierType state = dt_key_modifier_state(); + + // Alt+scroll: switch page (original behavior unchanged) + if(dt_modifier_is(state, GDK_MOD1_MASK)) + { + // previously the scroll event was forwarded to the notebook; + // event controllers cannot forward, so switch the page directly + const int pages = gtk_notebook_get_n_pages(g->notebook); + const int current = gtk_notebook_get_current_page(g->notebook); + const int next = (current + (dy > 0.0 ? -1 : 1) + pages) % pages; + gtk_notebook_set_current_page(g->notebook, next); + return; + } + + // The Gaussian mode below weights around the hue under the cursor on the + // graph itself (g->graph_cursor_x, tracked by _area_motion_notify_callback), + // falling back to the last hue seen on the main image (g->cursor_hue) only + // if the graph has not seen a motion event yet. + + // If no preview buffer has been allocated yet, fall back to classic + // single-node adjustment. Otherwise use Gaussian weighting — we + // check buffer existence rather than cursor_valid (which gates on + // pipe hash) so that the graph remains usable with slightly stale data. + if(g->pd.buf == NULL) + { + const float base_step = (g->channel == HUE) ? 1.0f : 0.01f; + const float step = dt_modifier_is(state, GDK_CONTROL_MASK) + ? base_step * 0.1f : base_step; + // dy < 0 on scroll-up (darktable's canonical convention, see + // src/gui/gtk.c) so negate it to make scroll-up increase, consistent + // with scrolled() and with the tone equalizer. + const float move = (float)(-dy) * step; + + float vmin, vmax; + float *val = _get_param_ptr(p, g->channel, g->selected, &vmin, &vmax); + const float old_val = *val; + float new_val = *val + move; + if(g->channel == HUE) + { + if(new_val > 180.f) new_val -= 360.f; + else if(new_val < -180.f) new_val += 360.f; + } + else + new_val = CLAMP(new_val, vmin, vmax); + *val = new_val; + + GtkWidget *w = _get_slider(g, g->selected); + if(w) dt_bauhaus_slider_set(w, *val); + + if(*val != old_val) + dt_dev_add_history_item(self->dev, self, TRUE); + gtk_widget_queue_draw(widget); + return; + } + + // --- Gaussian mode ------------------------------------------------------- + + const float base_step = (g->channel == HUE) ? 1.0f : 0.01f; + const float step = dt_modifier_is(state, GDK_CONTROL_MASK) + ? base_step * 0.1f + : base_step; + // dy < 0 on scroll-up (darktable's canonical convention, see + // src/gui/gtk.c) so negate it to make scroll-up increase, consistent + // with scrolled() and with the tone equalizer. + const float move = (float)(-dy) * step; - const GdkModifierType state = dt_gui_get_current_event_state(GTK_EVENT_CONTROLLER(controller)); - dt_gui_forward_scroll(controller, - dt_modifier_is(state, GDK_MOD1_MASK) - ? GTK_WIDGET(g->notebook) - : _get_slider(g, g->selected)); + const float ref_hue_deg = g->graph_cursor_valid + ? _graph_x_to_hue_deg(g, p, g->graph_cursor_x) + : g->cursor_hue; + + _adjust_params_gaussian(self, p, g, move, ref_hue_deg); + + gtk_widget_queue_draw(widget); } static void _area_motion_notify_callback(GtkEventControllerMotion *controller, @@ -2716,6 +3512,9 @@ static void _area_motion_notify_callback(GtkEventControllerMotion *controller, { dt_iop_colorequal_gui_data_t *g = self->gui_data; + g->graph_cursor_x = (float)x; + g->graph_cursor_valid = TRUE; + if(g->dragging && g->on_node) _area_set_pos(g, y); else @@ -2724,8 +3523,8 @@ static void _area_motion_notify_callback(GtkEventControllerMotion *controller, const float epsilon = DT_PIXEL_APPLY_DPI(10.0); const int oldsel = g->selected; const int oldon = g->on_node; - g->selected = (int)(((float)x - g->points[0][0]) - / (g->points[1][0] - g->points[0][0]) + 0.5f) % NODES; + g->selected = (((int)(((float)x - g->points[0][0]) + / (g->points[1][0] - g->points[0][0]) + 0.5f) % NODES) + NODES) % NODES; g->on_node = fabsf(g->points[g->selected][1] - (float)y) < epsilon; darktable.control->element = g->selected; if(oldsel != g->selected || oldon != g->on_node) @@ -2733,6 +3532,13 @@ static void _area_motion_notify_callback(GtkEventControllerMotion *controller, } } +static void _area_leave_callback(GtkEventControllerMotion *controller, + dt_iop_module_t *self) +{ + dt_iop_colorequal_gui_data_t *g = self->gui_data; + g->graph_cursor_valid = FALSE; +} + static void _area_button_press_callback(GtkGestureSingle *gesture, gint n_press, gdouble x, @@ -2854,6 +3660,7 @@ void gui_cleanup(dt_iop_module_t *self) } dt_free_align(g->gamut_LUT); + dt_preview_data_free(&g->pd); // Destroy the background cache for(dt_iop_colorequal_channel_t chan = 0; chan < NUM_CHANNELS; chan++) @@ -2951,6 +3758,15 @@ void gui_init(dt_iop_module_t *self) g->work_profile = work_profile; g->gradients_cached = FALSE; g->on_node = FALSE; + g->cursor_hue = 0.f; + g->cursor_valid = FALSE; + g->cursor_pos_x = 0.f; + g->cursor_pos_y = 0.f; + g->reprocess_pending = FALSE; + g->graph_cursor_x = 0.f; + g->graph_cursor_valid = FALSE; + dt_preview_data_alloc(&g->pd, self); + g->pd.components = 3; // store the HSB of the module input pixel per sample for(dt_iop_colorequal_channel_t chan = 0; chan < NUM_CHANNELS; chan++) { g->b_data[chan] = NULL; @@ -2990,7 +3806,7 @@ void gui_init(dt_iop_module_t *self) | GDK_BUTTON_RELEASE_MASK | darktable.gui->scroll_mask); dt_gui_connect_click_all(g->area, _area_button_press_callback, _area_button_release_callback, self); - dt_gui_connect_motion(g->area, _area_motion_notify_callback, NULL, NULL, self); + dt_gui_connect_motion(g->area, _area_motion_notify_callback, NULL, _area_leave_callback, self); dt_gui_connect_scroll(g->area, GTK_EVENT_CONTROLLER_SCROLL_BOTH_AXES | GTK_EVENT_CONTROLLER_SCROLL_DISCRETE, _area_scrolled_callback, self); diff --git a/src/iop/toneequal.c b/src/iop/toneequal.c index 788435cdf5..19bce1be57 100644 --- a/src/iop/toneequal.c +++ b/src/iop/toneequal.c @@ -108,6 +108,7 @@ #include "develop/imageop.h" #include "develop/imageop_math.h" #include "develop/imageop_gui.h" +#include "develop/preview_data.h" #include "dtgtk/drawingarea.h" #include "dtgtk/expander.h" #include "gui/accelerators.h" @@ -231,9 +232,10 @@ typedef struct dt_iop_toneequalizer_gui_data_t // 6 uint64 to pack - contiguous-ish memory dt_hash_t ui_preview_hash; - dt_hash_t thumb_preview_hash; size_t full_preview_buf_width, full_preview_buf_height; - size_t thumb_preview_buf_width, thumb_preview_buf_height; + + // shared preview pipe under-cursor data (buffer + freshness hash) + dt_preview_data_t pd; // Misc stuff, contiguity, length and alignment unknown float scale; @@ -243,7 +245,6 @@ typedef struct dt_iop_toneequalizer_gui_data_t float histogram_last_decile; // Heap arrays, 64 bits-aligned, unknown length - float *thumb_preview_buf; float *full_preview_buf; // GTK garbage, nobody cares, no SIMD here @@ -630,12 +631,21 @@ static void invalidate_luminance_cache(dt_iop_module_t *const self) g->max_histogram = 1; g->luminance_valid = FALSE; g->histogram_valid = FALSE; - g->thumb_preview_hash = DT_INVALID_HASH; g->ui_preview_hash = DT_INVALID_HASH; dt_iop_gui_leave_critical_section(self); + dt_preview_data_invalidate(&g->pd); dt_iop_refresh_all(self); } +static void _toneeq_preview_resized(void *const user_data) +{ + // Called under the module GUI lock when the preview buffer has been + // reallocated: don't let the GUI read it before it has been recomputed. + dt_iop_module_t *const self = (dt_iop_module_t *)user_data; + dt_iop_toneequalizer_gui_data_t *const g = self->gui_data; + if(g) g->luminance_valid = FALSE; +} + // gaussian-ish kernel - sum is == 1.0f so we don't care much about actual coeffs static const dt_colormatrix_t gauss_kernel = { { 0.076555024f, 0.124401914f, 0.076555024f }, @@ -730,9 +740,9 @@ static float _luminance_from_module_buffer(const dt_iop_module_t *self) _get_point(self, c_x, c_y, &b_x, &b_y); - return get_luminance_from_buffer(g->thumb_preview_buf, - g->thumb_preview_buf_width, - g->thumb_preview_buf_height, + return get_luminance_from_buffer(g->pd.buf, + g->pd.width, + g->pd.height, b_x, b_y); } @@ -1027,11 +1037,11 @@ void toneeq_process(dt_iop_module_t *self, { dt_iop_gui_enter_critical_section(self); g->ui_preview_hash = DT_INVALID_HASH; - g->thumb_preview_hash = DT_INVALID_HASH; g->pipe_order = piece->module->iop_order; g->luminance_valid = FALSE; g->histogram_valid = FALSE; dt_iop_gui_leave_critical_section(self); + dt_preview_data_invalidate(&g->pd); } if(dt_pipe_is_full(piece->pipe)) @@ -1056,23 +1066,11 @@ void toneeq_process(dt_iop_module_t *self, { // For preview pipe we need to cache it too because we have to // compute the full image stats upon user request in GUI threads. - // Locks are required since GUI reads and writes on that buffer. - - // Re-allocate a new buffer if the thumb preview size has changed - dt_iop_gui_enter_critical_section(self); - if(g->thumb_preview_buf_width != width || g->thumb_preview_buf_height != height) - { - dt_free_align(g->thumb_preview_buf); - g->thumb_preview_buf = dt_alloc_align_float(num_elem); - g->thumb_preview_buf_width = width; - g->thumb_preview_buf_height = height; - g->luminance_valid = FALSE; - } - - luminance = g->thumb_preview_buf; + // The shared under-cursor service owns the buffer and its locks. + // The resize and the luminance_valid invalidation happen under one + // GUI lock so the GUI never reads a resized, not-yet-recomputed buffer. + luminance = dt_preview_data_resize(&g->pd, width, height, _toneeq_preview_resized, self); cached = TRUE; - - dt_iop_gui_leave_critical_section(self); } else // just to please GCC { @@ -1116,8 +1114,7 @@ void toneeq_process(dt_iop_module_t *self, } else if(dt_pipe_is_preview(piece->pipe)) { - dt_hash_t saved_hash; - hash_set_get(&g->thumb_preview_hash, &saved_hash, &self->gui_lock); + const dt_hash_t saved_hash = dt_preview_data_get_hash(&g->pd); dt_iop_gui_enter_critical_section(self); const gboolean luminance_valid = g->luminance_valid; @@ -1126,10 +1123,18 @@ void toneeq_process(dt_iop_module_t *self, if(saved_hash != hash || !luminance_valid) { /* compute only if upstream pipe state has changed */ + // Flag the cache as being recomputed so the GUI threads never + // read a partially filled buffer, then commit hash + validity + // once the data is ready. dt_iop_gui_enter_critical_section(self); - g->thumb_preview_hash = hash; g->histogram_valid = FALSE; + g->luminance_valid = FALSE; + dt_iop_gui_leave_critical_section(self); + compute_luminance_mask(in, luminance, width, height, d); + dt_preview_data_set_hash(&g->pd, piece); + + dt_iop_gui_enter_critical_section(self); g->luminance_valid = TRUE; dt_iop_gui_leave_critical_section(self); dt_dev_pixelpipe_cache_invalidate_later(piece->pipe, self->iop_order, "toneequal: "); @@ -1337,7 +1342,7 @@ static void gui_cache_init(dt_iop_module_t *self) dt_iop_gui_enter_critical_section(self); g->ui_preview_hash = DT_INVALID_HASH; - g->thumb_preview_hash = DT_INVALID_HASH; + dt_preview_data_alloc(&g->pd, self); g->max_histogram = 1; g->scale = 1.0f; g->sigma = M_SQRT2_F; @@ -1349,7 +1354,7 @@ static void gui_cache_init(dt_iop_module_t *self) g->lut_valid = FALSE; // TRUE if the gui_lut is ready g->graph_valid = FALSE; // TRUE if the UI graph view is ready g->user_param_valid = FALSE; // TRUE if users params set in interactive view are in bounds - g->factors_valid = FALSE; // TRUE once radial-basis coeffs have been successfully solved + g->factors_valid = TRUE; // TRUE if radial-basis coeffs are ready g->valid_nodes_x = FALSE; // TRUE if x coordinates of graph nodes have been inited g->valid_nodes_y = FALSE; // TRUE if y coordinates of graph nodes have been inited @@ -1362,10 +1367,6 @@ static void gui_cache_init(dt_iop_module_t *self) g->full_preview_buf_width = 0; g->full_preview_buf_height = 0; - g->thumb_preview_buf = NULL; - g->thumb_preview_buf_width = 0; - g->thumb_preview_buf_height = 0; - g->desc = NULL; g->layout = NULL; g->cr = NULL; @@ -1477,8 +1478,8 @@ static inline void update_histogram(dt_iop_module_t *const self) dt_iop_gui_enter_critical_section(self); if(!g->histogram_valid && g->luminance_valid) { - const size_t num_elem = g->thumb_preview_buf_height * g->thumb_preview_buf_width; - compute_log_histogram_and_stats(g->thumb_preview_buf, g->histogram, num_elem, + const size_t num_elem = g->pd.height * g->pd.width; + compute_log_histogram_and_stats(g->pd.buf, g->histogram, num_elem, &g->max_histogram, &g->histogram_first_decile, &g->histogram_last_decile); g->histogram_average = (g->histogram_first_decile + g->histogram_last_decile) / 2.0f; @@ -1499,13 +1500,6 @@ static inline void compute_lut_correction(dt_iop_toneequalizer_gui_data_t *g, if(g == NULL) return; float *const restrict LUT = g->gui_lut; - - if(!g->factors_valid) - { - for(size_t i = 0; i < UI_SAMPLES; i++) LUT[i] = offset; - return; - } - const float *const restrict factors = g->factors; const float sigma = g->sigma; @@ -1519,15 +1513,7 @@ static inline void compute_lut_correction(dt_iop_toneequalizer_gui_data_t *g, } } -// Mark g->interpolation_matrix as invalid to force a recompute, and update g->sigma -// which the matrix computation in update_curve_lut uses. -// Important: the caller must hold the GUI critical section. -static inline void _invalidate_interpolation_matrix_on_sigma_change(dt_iop_toneequalizer_gui_data_t *g, - const float smoothing) -{ - if(g->sigma != smoothing) g->interpolation_valid = FALSE; - g->sigma = smoothing; -} + static inline gboolean update_curve_lut(dt_iop_module_t *self) { @@ -1624,17 +1610,16 @@ void commit_params(dt_iop_module_t *self, /* * Perform a radial-based interpolation using a series gaussian functions */ - - gboolean curve_valid; - if(self->dev->gui_attached && g) { dt_iop_gui_enter_critical_section(self); - _invalidate_interpolation_matrix_on_sigma_change(g, p->smoothing); + if(g->sigma != p->smoothing) + g->interpolation_valid = FALSE; + g->sigma = p->smoothing; g->user_param_valid = FALSE; // force updating channels factors dt_iop_gui_leave_critical_section(self); - curve_valid = update_curve_lut(self); + update_curve_lut(self); dt_iop_gui_enter_critical_section(self); dt_simd_memcpy(g->factors, d->factors, PIXEL_CHAN); @@ -1648,23 +1633,14 @@ void commit_params(dt_iop_module_t *self, float A[CHANNELS * PIXEL_CHAN] DT_ALIGNED_ARRAY; build_interpolation_matrix(A, p->smoothing); - curve_valid = pseudo_solve(A, factors, CHANNELS, PIXEL_CHAN, TRUE); + pseudo_solve(A, factors, CHANNELS, PIXEL_CHAN, TRUE); dt_simd_memcpy(factors, d->factors, PIXEL_CHAN); } // compute the correction LUT here to spare some time in process // when computing several times toneequalizer with same parameters - if(curve_valid) - { - compute_correction_lut(d->correction_lut, d->smoothing, d->factors); - } - else - { - // solver failed; make sure the operation is a no-op with/without darkroom GUI - for(size_t i = 0; i < LUT_RESOLUTION * PIXEL_CHAN + 1; i++) - d->correction_lut[i] = 1.0f; - } + compute_correction_lut(d->correction_lut, d->smoothing, d->factors); } @@ -1786,14 +1762,12 @@ static void smoothing_callback(GtkWidget *slider, dt_iop_module_t *self) { DT_GUARD_GUI_UPDATE(); dt_iop_toneequalizer_params_t *p = self->params; - dt_iop_toneequalizer_gui_data_t *g = self->gui_data; + const dt_iop_toneequalizer_gui_data_t *g = self->gui_data; p->smoothing= powf(M_SQRT2_F, 1.0f + dt_bauhaus_slider_get(slider)); - // avoid stale matrix; commit_params(), which also performs the invalidation, has not run yet - dt_iop_gui_enter_critical_section(self); - _invalidate_interpolation_matrix_on_sigma_change(g, p->smoothing); - dt_iop_gui_leave_critical_section(self); + float factors[CHANNELS] DT_ALIGNED_ARRAY; + get_channels_factors(factors, p); // Solve the interpolation by least-squares to check the validity of the smoothing param if(!update_curve_lut(self)) @@ -1997,12 +1971,17 @@ static void switch_cursors(dt_iop_module_t *self) if(!g || !self->dev->gui_attached) return; + GtkWidget *widget = dt_ui_main_window(darktable.gui->ui); + // if we are editing masks or using colour-pickers, do not display controls if(in_mask_editing(self) || dt_iop_canvas_not_sensitive(self->dev)) { // display default cursor - dt_control_change_cursor("default"); + GdkCursor *const cursor = + gdk_cursor_new_from_name(gdk_display_get_default(), "default"); + gdk_window_set_cursor(gtk_widget_get_window(widget), cursor); + g_object_unref(cursor); return; } @@ -2018,21 +1997,11 @@ static void switch_cursors(dt_iop_module_t *self) // do nothing and let the app decide return; } - else if((dt_pipe_processing(self->dev->full.pipe) - || self->dev->full.pipe->status == DT_DEV_PIXELPIPE_DIRTY - || self->dev->preview_pipe->status == DT_DEV_PIXELPIPE_DIRTY) - && g->cursor_valid) - { - // if pipe is busy or dirty but cursor is on preview, - // display waiting cursor while pipe reprocesses - dt_control_change_cursor("wait"); - - dt_control_queue_redraw_center(); - } - else if(g->cursor_valid && !dt_pipe_processing(self->dev->full.pipe)) + else if(g->cursor_valid) { - // if pipe is clean and idle and cursor is on preview, - // hide GTK cursor because we display our custom one + // if cursor is on the preview, hide GTK cursor because we display + // our custom one. We do this whether or not the pipe is still + // (re)computing, so no busy animation appears while hovering. dt_control_change_cursor("none"); dt_control_hinter_message(_("scroll over image to change tone exposure\n" "shift+scroll for large steps; " @@ -2044,7 +2013,10 @@ static void switch_cursors(dt_iop_module_t *self) { // if module is active and opened but cursor is out of the preview, // display default cursor - dt_control_change_cursor("default"); + GdkCursor *const cursor = + gdk_cursor_new_from_name(gdk_display_get_default(), "default"); + gdk_window_set_cursor(gtk_widget_get_window(widget), cursor); + g_object_unref(cursor); dt_control_queue_redraw_center(); } @@ -2052,7 +2024,10 @@ static void switch_cursors(dt_iop_module_t *self) { // in any other situation where module has focus, // reset the cursor but don't launch a redraw - dt_control_change_cursor("default"); + GdkCursor *const cursor = + gdk_cursor_new_from_name(gdk_display_get_default(), "default"); + gdk_window_set_cursor(gtk_widget_get_window(widget), cursor); + g_object_unref(cursor); } } @@ -2116,7 +2091,10 @@ int mouse_leave(dt_iop_module_t *self) dt_iop_gui_leave_critical_section(self); // display default cursor - dt_control_change_cursor("default"); + GtkWidget *widget = dt_ui_main_window(darktable.gui->ui); + GdkCursor *cursor = gdk_cursor_new_from_name(gdk_display_get_default(), "default"); + gdk_window_set_cursor(gtk_widget_get_window(widget), cursor); + g_object_unref(cursor); dt_control_queue_redraw_center(); gtk_widget_queue_draw(GTK_WIDGET(g->area)); @@ -2263,91 +2241,15 @@ static inline gboolean _init_drawing(dt_iop_module_t *const restrict self, dt_iop_toneequalizer_gui_data_t *const restrict g); -void cairo_draw_hatches(cairo_t *cr, - double center[2], - double span[2], - const int instances, - const double line_width, - const double shade) -{ - // center is the (x, y) coordinates of the region to draw - // span is the distance of the region's bounds to the center, over (x, y) axes - - // Get the coordinates of the corners of the bounding box of the region - const double C0[2] = { center[0] - span[0], center[1] - span[1] }; - const double C2[2] = { center[0] + span[0], center[1] + span[1] }; - - const double delta[2] = { 2.0 * span[0] / (double)instances, - 2.0 * span[1] / (double)instances }; - - cairo_set_line_width(cr, line_width); - cairo_set_source_rgb(cr, shade, shade, shade); +// The on-canvas correction cursor itself (crosshair, wedge, circles, text +// label) is shared with other modules via dt_draw_correction_cursor() in +// gui/draw.h; only the exposure-specific grey shades fed into it stay here. - for(int i = -instances / 2 - 1; i <= instances / 2 + 1; i++) - { - cairo_move_to(cr, C0[0] + (double)i * delta[0], C0[1]); - cairo_line_to(cr, C2[0] + (double)i * delta[0], C2[1]); - cairo_stroke(cr); - } -} - -static void get_shade_from_luminance(cairo_t *cr, - const float luminance, - const float alpha) +static float _shade_from_luminance(const float luminance) { // TODO: fetch screen gamma from ICC display profile const float gamma = 1.0f / 2.2f; - const float shade = powf(luminance, gamma); - cairo_set_source_rgba(cr, shade, shade, shade, alpha); -} - - -static void draw_exposure_cursor(cairo_t *cr, - const double pointerx, - const double pointery, - const double radius, - const float luminance, - const float zoom_scale, - const int instances, - const float alpha) -{ - // Draw a circle cursor filled with a grey shade corresponding to a luminance value - // or hatches if the value is above the overexposed threshold - - const double radius_z = radius / zoom_scale; - - get_shade_from_luminance(cr, luminance, alpha); - cairo_arc(cr, pointerx, pointery, radius_z, 0, 2 * M_PI); - cairo_fill_preserve(cr); - cairo_save(cr); - cairo_clip(cr); - - if(log2f(luminance) > 0.0f) - { - // if overexposed, draw hatches - double pointer_coord[2] = { pointerx, pointery }; - double span[2] = { radius_z, radius_z }; - cairo_draw_hatches(cr, pointer_coord, span, instances, - DT_PIXEL_APPLY_DPI(1. / zoom_scale), 0.3); - } - cairo_restore(cr); -} - - -static void match_color_to_background(cairo_t *cr, - const float exposure, - const float alpha) -{ - float shade = 0.0f; - // TODO: put that as a preference in darktablerc - const float contrast = 1.0f; - - if(exposure > -2.5f) - shade = (fminf(exposure * contrast, 0.0f) - 2.5f); - else - shade = (fmaxf(exposure / contrast, -5.0f) + 2.5f); - - get_shade_from_luminance(cr, exp2f(shade), alpha); + return powf(luminance, gamma); } @@ -2371,7 +2273,6 @@ void gui_post_expose(dt_iop_module_t *self, const gboolean fail = !g->cursor_valid || !g->interpolation_valid - || dt_pipe_processing(dev->full.pipe) || !g->has_focus; dt_iop_gui_leave_critical_section(self); @@ -2382,8 +2283,10 @@ void gui_post_expose(dt_iop_module_t *self, if(!_init_drawing(self, self->widget, g)) return; - // re-read the exposure in case it has changed - if(g->luminance_valid && self->enabled) + // Re-read the exposure in case it has changed. While the pipe is busy + // the module buffer may be mid-recompute, so keep the last value and + // stay drawing the indicator (no blinking cursor during reprocess). + if(g->luminance_valid && self->enabled && !dt_pipe_processing(dev->full.pipe)) g->cursor_exposure = log2f(_luminance_from_module_buffer(self)); dt_iop_gui_enter_critical_section(self); @@ -2403,15 +2306,8 @@ void gui_post_expose(dt_iop_module_t *self, exposure_in = g->cursor_exposure; luminance_in = exp2f(exposure_in); - // avoid stale g->factors: only set correction if factors were successfully solved; - // otherwise, leave correction = 0 EV, which is what the pixels get — commit_params() fills - // correction_lut with 1.0 when there is no valid solution. - if(g->factors_valid) - { - // Get the corresponding correction and compute resulting exposure - correction = log2f(pixel_correction(exposure_in, g->factors, g->sigma)); - } - + // Get the corresponding correction and compute resulting exposure + correction = log2f(pixel_correction(exposure_in, g->factors, g->sigma)); exposure_out = exposure_in + correction; luminance_out = exp2f(exposure_out); } @@ -2420,90 +2316,48 @@ void gui_post_expose(dt_iop_module_t *self, if(dt_isnan(exposure_in)) return; // something went wrong - // set custom cursor dimensions - const double outer_radius = 16.; - const double inner_radius = outer_radius / 2.0; - const double setting_offset_x = (outer_radius + 4. * g->inner_padding) / zoom_scale; - const double fill_width = DT_PIXEL_APPLY_DPI(4. / zoom_scale); - - // setting fill bars - match_color_to_background(cr, exposure_out, 1.0); - cairo_set_line_width(cr, 2.0 * fill_width); - cairo_move_to(cr, x_pointer - setting_offset_x, y_pointer); - - if(correction > 0.0f) - cairo_arc(cr, x_pointer, y_pointer, setting_offset_x, - M_PI, M_PI + correction * M_PI_4); - else - cairo_arc_negative(cr, x_pointer, y_pointer, setting_offset_x, - M_PI, M_PI + correction * M_PI_4); - - cairo_stroke(cr); - - // setting ground level - cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(1.5 / zoom_scale)); - cairo_move_to(cr, x_pointer + (outer_radius + 2. * g->inner_padding) / zoom_scale, - y_pointer); - cairo_line_to(cr, x_pointer + outer_radius / zoom_scale, y_pointer); - cairo_move_to(cr, x_pointer - outer_radius / zoom_scale, y_pointer); - cairo_line_to(cr, x_pointer - setting_offset_x - 4.0 * g->inner_padding / zoom_scale, - y_pointer); - cairo_stroke(cr); - - // setting cursor cross hair - cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(1.5 / zoom_scale)); - cairo_move_to(cr, x_pointer, y_pointer + setting_offset_x + fill_width); - cairo_line_to(cr, x_pointer, y_pointer + outer_radius / zoom_scale); - cairo_move_to(cr, x_pointer, y_pointer - outer_radius / zoom_scale); - cairo_line_to(cr, x_pointer, y_pointer - setting_offset_x - fill_width); - cairo_stroke(cr); - - // draw exposure cursor - draw_exposure_cursor(cr, x_pointer, y_pointer, outer_radius, - luminance_in, zoom_scale, 6, .9); - draw_exposure_cursor(cr, x_pointer, y_pointer, inner_radius, - luminance_out, zoom_scale, 3, .9); - - // Create Pango objects : texts char text[256]; - PangoLayout *layout; - PangoRectangle ink; - PangoFontDescription *desc = dt_gui_get_font(); - - // Avoid text resizing based on zoom level - const int old_size = pango_font_description_get_size(desc); - pango_font_description_set_size (desc, (int)(old_size / zoom_scale)); - layout = pango_cairo_create_layout(cr); - pango_layout_set_font_description(layout, desc); - pango_cairo_context_set_resolution(pango_layout_get_context(layout), darktable.gui->dpi); - - // Build text object if(g->luminance_valid && self->enabled) snprintf(text, sizeof(text), _("%+.1f EV"), exposure_in); else snprintf(text, sizeof(text), "? EV"); - pango_layout_set_text(layout, text, -1); - pango_layout_get_pixel_extents(layout, &ink, NULL); - - // Draw the text plain blackground - get_shade_from_luminance(cr, luminance_out, 0.75); - cairo_rectangle(cr, - x_pointer + (outer_radius + 2. * g->inner_padding) / zoom_scale, - y_pointer - ink.y - ink.height / 2.0 - g->inner_padding / zoom_scale, - ink.width + 2.0 * ink.x + 4. * g->inner_padding / zoom_scale, - ink.height + 2.0 * ink.y + 2. * g->inner_padding / zoom_scale); - cairo_fill(cr); - - // Display the EV reading - match_color_to_background(cr, exposure_out, 1.0); - cairo_move_to(cr, x_pointer + (outer_radius + 4. * g->inner_padding) / zoom_scale, - y_pointer - ink.y - ink.height / 2.); - pango_cairo_show_layout(cr, layout); - - cairo_stroke(cr); - pango_font_description_free(desc); - g_object_unref(layout); + // Sample the pixel under the cursor from the preview pipe backbuf: + // white frame lines over dark content, black over bright content, + // like the color equalizer's cursor. The circles keep the + // exposure-specific shades below to convey the before/after luminance. + uint8_t *backbuf = dev->preview_pipe->backbuf; + const int buf_w = dev->preview_pipe->backbuf_width; + const int buf_h = dev->preview_pipe->backbuf_height; + float cr_f = 0.5f, cg_f = 0.5f, cb_f = 0.5f; // fallback mid-grey + if(backbuf && buf_w > 0 && buf_h > 0) + { + const int px = CLAMP((int)x_pointer, 0, buf_w - 1); + const int py = CLAMP((int)y_pointer, 0, buf_h - 1); + dt_pthread_mutex_lock(&dev->preview_pipe->backbuf_mutex); + const size_t idx = (size_t)py * buf_w * 4 + px * 4; + // backbuf is CAIRO_FORMAT_ARGB32: B, G, R, A byte order on little-endian + cb_f = backbuf[idx + 0] / 255.0f; + cg_f = backbuf[idx + 1] / 255.0f; + cr_f = backbuf[idx + 2] / 255.0f; + dt_pthread_mutex_unlock(&dev->preview_pipe->backbuf_mutex); + } + const float bg_luma = 0.3f * cr_f + 0.59f * cg_f + 0.11f * cb_f; + const float frame_shade = (bg_luma > 0.5f) ? 0.0f : 1.0f; + const float frame_color[3] = { frame_shade, frame_shade, frame_shade }; + const float outer_shade = _shade_from_luminance(luminance_in); + const float inner_shade = _shade_from_luminance(luminance_out); + const float outer_color[3] = { outer_shade, outer_shade, outer_shade }; + const float inner_color[3] = { inner_shade, inner_shade, inner_shade }; + + // The wedge normalizes the correction to ±1 (full ±90°); tone equalizer + // corrections are expressed in EV and regularly exceed ±1 EV, so halve + // the value here: the wedge then reaches its full ±90° at ±2 EV. + dt_draw_correction_cursor(cr, x_pointer, y_pointer, zoom_scale, 0.5f * correction, + frame_color, + outer_color, log2f(luminance_in) > 0.0f, + inner_color, log2f(luminance_out) > 0.0f, + text); if(g->luminance_valid && self->enabled) { @@ -3435,7 +3289,7 @@ void gui_cleanup(dt_iop_module_t *self) dt_conf_set_int("plugins/darkroom/toneequal/gui_page", gtk_notebook_get_current_page (g->notebook)); - dt_free_align(g->thumb_preview_buf); + dt_preview_data_free((dt_preview_data_t *)&g->pd); dt_free_align(g->full_preview_buf); if(g->desc) pango_font_description_free(g->desc);