Ctrl K
ComunidadGuías…

src/libs/history.c

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/*
    This file is part of darktable,
    Copyright (C) 2011-2026 darktable developers.

    darktable is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    darktable is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with darktable.  If not, see <http://www.gnu.org/licenses/>.
*/

#include "common/darktable.h"
#include "common/debug.h"
#include "common/math.h"
#include "common/styles.h"
#include "common/undo.h"
#include "control/conf.h"
#include "control/control.h"
#include "develop/develop.h"
#include "develop/masks.h"
#include "gui/accelerators.h"
#include "gui/gtk.h"
#include "gui/styles.h"
#include "libs/lib.h"
#include "libs/lib_api.h"
#include "common/history.h"
#include "bauhaus/bauhaus.h"
#include <complex.h>
#include <locale.h>

#ifdef GDK_WINDOWING_QUARTZ
#include "osx/osx.h"
#endif

DT_MODULE(1)

typedef struct dt_undo_history_t
{
  GList *history;
  int history_end;
  GList *iop_order_list;
  dt_masks_edit_mode_t mask_edit_mode;
  dt_dev_pixelpipe_display_mask_t request_mask_display;
} dt_undo_history_t;

typedef struct dt_lib_history_t
{
  /* vbox with managed history items */
  GtkWidget *history_box;
  GtkWidget *create_button;
  GtkWidget *compress_button;
  gboolean record_undo;
  int record_history_level; // set to +1 in signal DT_SIGNAL_DEVELOP_HISTORY_WILL_CHANGE
                            // and back to -1 in DT_SIGNAL_DEVELOP_HISTORY_CHANGE. We want
                            // to avoid multiple will-change before a change cb.
} dt_lib_history_t;

/* 3 widgets in each history line */
#define HIST_WIDGET_NUMBER 0
#define HIST_WIDGET_MODULE 1
#define HIST_WIDGET_STATUS 2

/* compress history stack */
static void _lib_history_compress_clicked_callback(GtkButton *widget,
                                                   gpointer user_data);

static void _lib_history_compress_pressed_callback(GtkGestureSingle *gesture,
                                                   gint n_press,
                                                   gdouble x,
                                                   gdouble y,
                                                   dt_lib_module_t *self);

static void _lib_history_button_clicked_callback(GtkGestureSingle *gesture,
                                                 gint n_press,
                                                 gdouble x,
                                                 gdouble y,
                                                 dt_lib_module_t *self);

static void _lib_history_create_style_button_clicked_callback(GtkWidget *widget,
                                                              gpointer user_data);
/* signal callback for history change */

static void _lib_history_will_change_callback(gpointer instance,
                                              dt_lib_module_t *self);

static void _lib_history_change_callback(gpointer instance,
                                         dt_lib_module_t *self);

static void _lib_history_module_remove_callback(gpointer instance,
                                                dt_iop_module_t *module,
                                                gpointer user_data);

const char *name(dt_lib_module_t *self)
{
  return _("history");
}

const char *description(dt_lib_module_t *self)
{
  return _("display the sequence of edit actions\n"
           "- click on an entry to temporarily return to\n"
           "  that earlier state of the edit\n"
           "  (warning: any edits will be made to that\n"
           "  earlier state, losing later changes)\n"
           "- shift-click to focus that module without\n"
           "  changing the edit state");
}

dt_view_type_flags_t views(dt_lib_module_t *self)
{
  return DT_VIEW_DARKROOM;
}

uint32_t container(dt_lib_module_t *self)
{
  return DT_UI_CONTAINER_PANEL_LEFT_CENTER;
}

int position(const dt_lib_module_t *self)
{
  return 900;
}

void gui_init(dt_lib_module_t *self)
{
  /* initialize ui widgets */
  dt_lib_history_t *d = g_malloc0(sizeof(dt_lib_history_t));
  self->data = (void *)d;

  d->record_undo = TRUE;
  d->record_history_level = 0;

  d->history_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
  gtk_widget_set_has_tooltip(d->history_box, FALSE);

  d->compress_button = dt_action_button_new
    (self, N_("compress history stack"), _lib_history_compress_clicked_callback, self,
     _("create a minimal history stack which produces the same image\n"
       "ctrl+click to truncate history to the selected item"), 0, 0);
  // capture phase, so that the claim in the callback also keeps the button's
  // own gesture from emitting "clicked" (see the callback)
  gtk_event_controller_set_propagation_phase
    (GTK_EVENT_CONTROLLER(dt_gui_connect_click(d->compress_button,
                                               _lib_history_compress_pressed_callback, NULL, self)),
     GTK_PHASE_CAPTURE);

  /* add toolbar button for creating style */
  d->create_button = dtgtk_button_new_full(dtgtk_cairo_paint_styles, CPF_NONE, NULL,
      &(dtgtk_button_config_t){
        .tooltip = _("create a style from the current history stack"),
        .action = DT_ACTION(self),
        .action_label = N_("create style from history"),
        .action_def = &dt_action_def_button,
        .clicked_cb = G_CALLBACK(_lib_history_create_style_button_clicked_callback),
      });
  gtk_widget_set_name(d->create_button, "non-flat");

  self->widget = dt_gui_vbox
    (dt_ui_resize_wrap(d->history_box, 1, "plugins/darkroom/history/windowheight"),
     dt_gui_hbox(dt_gui_expand(d->compress_button), d->create_button));
  gtk_widget_set_name(self->widget, "history-ui");
  gtk_widget_show_all(self->widget);

  /* connect to history change signal for updating the history view */
  DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_DEVELOP_HISTORY_WILL_CHANGE, _lib_history_will_change_callback);
  DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_DEVELOP_HISTORY_CHANGE, _lib_history_change_callback);
  DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_DEVELOP_MODULE_REMOVE, _lib_history_module_remove_callback);
}

void gui_cleanup(dt_lib_module_t *self)
{
  g_free(self->data);
  self->data = NULL;
}

static GtkWidget *_lib_history_create_button(dt_lib_module_t *self,
                                             const int num,
                                             const char *label,
                                             const gboolean enabled,
                                             const gboolean default_enabled,
                                             const gboolean always_on,
                                             const gboolean selected,
                                             const gboolean deprecated)
{
  /* create label */
  GtkWidget *hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
  gchar numlab[10];

  g_snprintf(numlab, sizeof(numlab), "%2d", num + 1);
  GtkWidget *numwidget = gtk_label_new(numlab);
  gtk_widget_set_name(numwidget, "history-number");
  dt_gui_add_class(numwidget, "dt_history_items");
  dt_gui_add_class(numwidget, "dt_monospace");

  GtkWidget *onoff = NULL;

  /* create toggle button */
  GtkWidget *widget = gtk_toggle_button_new_with_label("");
  dt_gui_add_class(widget, "dt_transparent_background");
  GtkWidget *lab = gtk_bin_get_child(GTK_BIN(widget));
  gtk_widget_set_halign(lab, GTK_ALIGN_START);
  gtk_label_set_xalign(GTK_LABEL(lab), 0);
  gtk_label_set_ellipsize(GTK_LABEL(lab), PANGO_ELLIPSIZE_END);
  gtk_label_set_markup (GTK_LABEL (lab), label);
  if(always_on)
  {
    onoff = dtgtk_button_new_full(dtgtk_cairo_paint_switch_on, 0, NULL,
      &(dtgtk_button_config_t){
        .tooltip = _("always-on module"),
      });
    dtgtk_button_set_active(DTGTK_BUTTON(onoff), TRUE);
  }
  else if(default_enabled)
  {
    onoff = dtgtk_button_new_full(dtgtk_cairo_paint_switch, 0, NULL,
      &(dtgtk_button_config_t){
        .tooltip = _("default enabled module"),
      });
    dtgtk_button_set_active(DTGTK_BUTTON(onoff), enabled);
  }
  else
  {
    if(deprecated)
    {
      onoff = dtgtk_button_new_full(dtgtk_cairo_paint_switch_deprecated, 0, NULL,
        &(dtgtk_button_config_t){
          .tooltip = _("deprecated module"),
        });
    }
    else
    {
      onoff = dtgtk_button_new(dtgtk_cairo_paint_switch, 0, NULL);
      dt_gui_add_class(onoff, enabled ? "" : "dt_history_switch_off");
    }
    dt_gui_add_class(lab, enabled ? "" : "dt_history_switch_off");
    dtgtk_button_set_active(DTGTK_BUTTON(onoff), enabled);
  }
  dt_gui_add_class(widget, "dt_history_items");
  dt_gui_add_class(onoff, "dt_history_switch");

  gtk_widget_set_sensitive(onoff, FALSE);

  if(selected) gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(widget), TRUE);

  /* Handle the press before GtkToggleButton's TARGET-phase default gesture.
   * This lets an active-row re-click claim the sequence before the widget can
   * toggle itself off, while unclaimed presses still reach normal handling. */
  GtkGestureSingle *history_gesture =
    dt_gui_connect_click(widget, _lib_history_button_clicked_callback, NULL, self);
  gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(history_gesture),
                                             GTK_PHASE_CAPTURE);

  /* associate the history number */
  g_object_set_data(G_OBJECT(widget), "history-number", GINT_TO_POINTER(num + 1));
  g_object_set_data(G_OBJECT(widget), "label", (gpointer)label);

  gtk_box_pack_start(GTK_BOX(hbox), numwidget, FALSE, FALSE, 0);
  gtk_box_pack_start(GTK_BOX(hbox), widget, TRUE, TRUE, 0);
  gtk_box_pack_end(GTK_BOX(hbox), onoff, FALSE, FALSE, 0);

  return hbox;
}

static void _reset_module_instance(GList *hist,
                                   dt_iop_module_t *module,
                                   const int multi_priority)
{
  for(; hist; hist = g_list_next(hist))
  {
    dt_dev_history_item_t *hit = hist->data;

    if(!hit->module
       && strcmp(hit->op_name, module->op) == 0
       && hit->multi_priority == multi_priority)
    {
      hit->module = module;
    }
  }
}

struct _cb_data
{
  dt_iop_module_t *module;
  int multi_priority;
};

static void _undo_items_cb(gpointer user_data,
                           const dt_undo_type_t type,
                           dt_undo_data_t data)
{
  struct _cb_data *udata = (struct _cb_data *)user_data;
  dt_undo_history_t *hdata = (dt_undo_history_t *)data;
  _reset_module_instance(hdata->history, udata->module, udata->multi_priority);
}

static void _history_invalidate_cb(gpointer user_data,
                                   const dt_undo_type_t type,
                                   dt_undo_data_t data)
{
  dt_iop_module_t *module = (dt_iop_module_t *)user_data;
  dt_undo_history_t *hist = (dt_undo_history_t *)data;
  dt_dev_invalidate_history_module(hist->history, module);
}

static void _add_module_expander(GList *iop_list, dt_iop_module_t *module)
{
  // dt_dev_reload_history_items won't do this for base instances
  // and it will call gui_init() for the rest
  // so we do it here
  if(!dt_iop_is_hidden(module) && !module->expander)
  {
      /* add module to right panel */
      dt_iop_gui_set_expander(module);
      dt_iop_gui_set_expanded(module, TRUE, FALSE);
      dt_iop_gui_update_blending(module);
  }
}

// return the 1st history entry that matches module
static dt_dev_history_item_t *_search_history_by_module(GList *history_list,
                                                        dt_iop_module_t *module)
{
  dt_dev_history_item_t *hist_ret = NULL;

  for(GList *history = history_list; history; history = g_list_next(history))
  {
    dt_dev_history_item_t *hist_item = history->data;

    if(hist_item->module == module)
    {
      hist_ret = hist_item;
      break;
    }
  }
  return hist_ret;
}

static gboolean _check_deleted_instances(dt_develop_t *dev,
                                         GList **_iop_list,
                                         GList *history_list)
{
  GList *iop_list = *_iop_list;
  gboolean deleted_module_found = FALSE;

  // we will check on dev->iop if there's a module that is not in history
  GList *modules = iop_list;
  while(modules)
  {
    dt_iop_module_t *mod = modules->data;

    gboolean delete_module = FALSE;

    // base modules are a special case
    // most base modules won't be in history and must not be deleted
    // but the user may have deleted a base instance of a multi-instance module
    // and then undo and redo, so we will end up with two entries in dev->iop
    // with multi_priority == 0, this can't happen and the extra one must be deleted
    // dev->iop is sorted by (priority, multi_priority DESC), so if the next one is
    // a base instance too, one must be deleted
    if(mod->multi_priority == 0)
    {
      GList *modules_next = g_list_next(modules);
      if(modules_next)
      {
        dt_iop_module_t *mod_next = modules_next->data;
        if(strcmp(mod_next->op, mod->op) == 0 && mod_next->multi_priority == 0)
        {
          // is the same one, check which one must be deleted
          const gboolean mod_in_history = _search_history_by_module(history_list, mod) != NULL;
          const gboolean mod_next_in_history =
            _search_history_by_module(history_list, mod_next) != NULL;

          // current is in history and next is not, delete next
          if(mod_in_history && !mod_next_in_history)
          {
            mod = mod_next;
            modules = modules_next;
            delete_module = TRUE;
          }
          // current is not in history and next is, delete current
          else if(!mod_in_history && mod_next_in_history)
          {
            delete_module = TRUE;
          }
          else
          {
            if(mod_in_history && mod_next_in_history)
              dt_print(DT_DEBUG_ALWAYS,
                  "[_check_deleted_instances] found duplicate module"
                  " %s %s (%i) and %s %s (%i) both in history",
                  mod->op, mod->multi_name, mod->multi_priority,
                  mod_next->op, mod_next->multi_name,
                  mod_next->multi_priority);
            else
              dt_print(DT_DEBUG_ALWAYS,
                  "[_check_deleted_instances] found duplicate module"
                  " %s %s (%i) and %s %s (%i) none in history",
                  mod->op, mod->multi_name, mod->multi_priority,
                  mod_next->op, mod_next->multi_name,
                  mod_next->multi_priority);
          }
        }
      }
    }
    // this is a regular multi-instance and must be in history
    else
    {
      delete_module = (_search_history_by_module(history_list, mod) == NULL);
    }

    // if module is not in history we delete it
    if(delete_module)
    {
      deleted_module_found = TRUE;

      if(darktable.develop->gui_module == mod)
        dt_iop_request_focus(NULL);

      DT_ENTER_GUI_UPDATE();

      // we remove the plugin effectively
      if(!dt_iop_is_hidden(mod))
      {
        // we just hide the module to avoid lots of gtk critical warnings
        gtk_widget_hide(mod->expander);

        // this is copied from dt_iop_gui_delete_callback(), not sure
        // why the above sentence...
        dt_iop_gui_cleanup_module(mod);
      }

      iop_list = g_list_remove_link(iop_list, modules);

      // remove it from all snapshots
      dt_undo_iterate(darktable.undo, DT_UNDO_HISTORY,
                      mod, &_history_invalidate_cb);

      // we cleanup the module
      dt_action_cleanup_instance_iop(mod);

      // don't delete the module, a pipe may still need it
      dev->alliop = g_list_append(dev->alliop, mod);

      DT_LEAVE_GUI_UPDATE();

      // and reset the list
      modules = iop_list;
      continue;
    }

    modules = g_list_next(modules);
  }
  if(deleted_module_found)
    iop_list = g_list_sort(iop_list, dt_sort_iop_by_order);

  *_iop_list = iop_list;

  return deleted_module_found;
}

static gboolean _rebuild_multi_priority(GList *history_list)
{
  gboolean changed = FALSE;
  for(const GList *history = history_list; history; history = g_list_next(history))
  {
    dt_dev_history_item_t *hitem = history->data;

    // if multi_priority is different in history and dev->iop
    // we keep the history version
    if(hitem->module && hitem->module->multi_priority != hitem->multi_priority)
    {
      dt_iop_update_multi_priority(hitem->module, hitem->multi_priority);
      changed = TRUE;
    }
  }
  return changed;
}

static gboolean _create_deleted_modules(GList **_iop_list, GList *history_list)
{
  GList *iop_list = *_iop_list;
  gboolean changed = FALSE;
  gboolean done = FALSE;

  GList *l = history_list;
  while(l)
  {
    GList *next = g_list_next(l);
    dt_dev_history_item_t *hitem = l->data;

    // this fixes the duplicate module when undo: hitem->multi_priority = 0;
    if(hitem->module == NULL)
    {
      changed = TRUE;

      const dt_iop_module_t *base_module =
        dt_iop_get_module_from_list(iop_list, hitem->op_name);
      if(base_module == NULL)
      {
        dt_print(DT_DEBUG_ALWAYS,
                 "[_create_deleted_modules] can't find base module for %s",
                 hitem->op_name);
        return changed;
      }

      // from there we create a new module for this base instance. The
      // goal is to do a very minimal setup of the new module to be
      // able to write the history items. From there we reload the
      // whole history back and this will recreate the proper module
      // instances.
      dt_iop_module_t *module = calloc(1, sizeof(dt_iop_module_t));
      if(dt_iop_load_module(module, base_module->so, base_module->dev))
      {
        return changed;
      }
      module->instance = base_module->instance;

      if(!dt_iop_is_hidden(module))
      {
        /** this works inside correct DT_ENTER/LEAVE_GUI_UPDATE()
            and ensures the module->gui_lock nutex is correctly intialized.
        */
        dt_iop_gui_init(module);
      }

      // adjust the multi_name of the new module
      g_strlcpy(module->multi_name, hitem->multi_name, sizeof(module->multi_name));
      dt_iop_update_multi_priority(module, hitem->multi_priority);
      module->iop_order = hitem->iop_order;

      // we insert this module into dev->iop
      iop_list = g_list_insert_sorted(iop_list, module, dt_sort_iop_by_order);

      // add the expander, dt_dev_reload_history_items() don't work well without one
      _add_module_expander(iop_list, module);

      // if not already done, set the module to all others same instance
      if(!done)
      {
        _reset_module_instance(history_list, module, hitem->multi_priority);

        // and do that also in the undo/redo lists
        struct _cb_data udata = { module, hitem->multi_priority };
        dt_undo_iterate(darktable.undo, DT_UNDO_HISTORY,
                                 &udata, &_undo_items_cb);
        done = TRUE;
      }

      hitem->module = module;
    }
    l = next;
  }

  *_iop_list = iop_list;

  return changed;
}

static void _pop_undo(gpointer user_data,
                      dt_undo_type_t type,
                      dt_undo_data_t data,
                      dt_undo_action_t action,
                      GList **imgs)
{
  dt_lib_module_t *self = (dt_lib_module_t *)user_data;

  if(type == DT_UNDO_HISTORY)
  {
    dt_lib_history_t *d = self->data;
    dt_undo_history_t *hist = (dt_undo_history_t *)data;
    dt_develop_t *dev = darktable.develop;

    // we swap current and undo history;
    // it will move between undo and redo queue
    GList *history_temp = hist->history;
    int history_end_temp = hist->history_end;
    GList *iop_order_list_temp = hist->iop_order_list;

    GList *iop_temp = g_list_copy(dev->iop);

    // Undo/redo can destroy module GUI data and alter the module topology.
    // Keep all screen pipes quiescent until the replacement develop state is
    // complete, so no old node can resume with a cleaned-up module GUI.
    dt_dev_pixelpipe_stop_and_lock_all(dev);

    // topology has changed?
    gboolean pipe_remove = FALSE;

    // we have to check if multi_priority has changed since history was saved
    // we will adjust it here
    if(_rebuild_multi_priority(history_temp))
    {
      pipe_remove = TRUE;
      iop_temp = g_list_sort(iop_temp, dt_sort_iop_by_order);
    }

    // check if this undo a delete module and re-create it
    if(_create_deleted_modules(&iop_temp, history_temp))
    {
      pipe_remove = TRUE;
    }

    // check if this is a redo of a delete module or an undo of an add module
    if(_check_deleted_instances(dev, &iop_temp, history_temp))
    {
      pipe_remove = TRUE;
    }

    // disable recording undo as the _lib_history_change_callback will
    // be triggered by the calls below
    d->record_undo = FALSE;

    dt_pthread_mutex_lock(&dev->history_mutex);

    hist->history = dev->history;
    hist->history_end = dev->history_end;
    hist->iop_order_list = dev->iop_order_list;

    dev->history = history_temp;
    dev->history_end = history_end_temp;
    dev->iop_order_list = iop_order_list_temp;

    g_list_free(dev->iop);
    dev->iop = iop_temp;

    // topology has changed
    dt_dev_pixelpipe_rebuild(dev);

    // if dev->iop has changed reflect that on module list
    if(pipe_remove) dt_dev_reorder_gui_module_list(dev);

    // write new history and reload
    dt_dev_write_history(dev);
    dt_dev_reload_history_items(dev);

    dt_pthread_mutex_unlock(&dev->history_mutex);

    dt_ioppr_resync_modules_order(dev);

    dt_dev_pixelpipe_unlock_all(dev);

    dt_dev_modulegroups_set(darktable.develop,
                            dt_dev_modulegroups_get(darktable.develop));

    if(dev->gui_module)
    {
      dt_masks_set_edit_mode(dev->gui_module, hist->mask_edit_mode);
      darktable.develop->gui_module->request_mask_display =
        hist->request_mask_display;
      dt_iop_gui_blend_data_t *bd = dev->gui_module->blend_data;
      if(bd)
        gtk_toggle_button_set_active
          (GTK_TOGGLE_BUTTON(bd->showmask),
           hist->request_mask_display == DT_DEV_PIXELPIPE_DISPLAY_MASK);
    }
  }
}

static void _history_undo_data_free(gpointer data)
{
  dt_undo_history_t *hist = (dt_undo_history_t *)data;
  g_list_free_full(hist->history, dt_dev_free_history_item);
  g_list_free_full(hist->iop_order_list, free);
  free(data);
}

static void _lib_history_module_remove_callback(gpointer instance,
                                                dt_iop_module_t *module,
                                                gpointer user_data)
{
  dt_undo_iterate(darktable.undo, DT_UNDO_HISTORY, module, &_history_invalidate_cb);
}

static void _lib_history_will_change_callback(gpointer instance,
                                              dt_lib_module_t *self)
{
  dt_lib_history_t *lib = self->data;

  gtk_container_foreach(GTK_CONTAINER(lib->history_box),
                        (GtkCallback)gtk_widget_set_has_tooltip, NULL);

  if(lib->record_history_level++ == 0 && lib->record_undo)
  {
    /* record undo/redo history snapshot */
    dt_undo_history_t *hist = malloc(sizeof(dt_undo_history_t));
    hist->history = dt_history_duplicate(darktable.develop->history);
    hist->history_end = darktable.develop->history_end;
    hist->iop_order_list =
      dt_ioppr_iop_order_copy_deep(darktable.develop->iop_order_list);

    if(darktable.develop->gui_module)
    {
      hist->mask_edit_mode = dt_masks_get_edit_mode();
      hist->request_mask_display = darktable.develop->gui_module->request_mask_display;
    }
    else
    {
      hist->mask_edit_mode = DT_MASKS_EDIT_OFF;
      hist->request_mask_display = DT_DEV_PIXELPIPE_DISPLAY_NONE;
    }

    dt_undo_record(darktable.undo, self, DT_UNDO_HISTORY, (dt_undo_data_t)hist,
                   _pop_undo, _history_undo_data_free);
  }
}

#define CHG_STR(fmt, ...) g_strdup_printf("%s\t\t" #fmt "\t\u2192\t\t" #fmt " ", __VA_ARGS__)
static gchar *_lib_history_bauhaus_text(gpointer bhfield,
                                        const char *d,
                                        dt_iop_module_t *module,
                                        float oval,
                                        float pval)
{
  for(GSList *w = module->widget_list_bh; w; w = g_slist_next(w))
  {
    dt_action_target_t *at = w->data;
    GtkWidget *widget = at->target;
    if(dt_bauhaus_widget_get_field(widget) == bhfield)
    {
      dt_action_t *as = at->action->owner;
      gchar *s = as && as->type == DT_ACTION_TYPE_SECTION
                 ? g_strdup_printf("%s/%s", as->label, d) : NULL;
      gchar *old_text = dt_bauhaus_slider_get_text(widget, oval);
      gchar *new_text = dt_bauhaus_slider_get_text(widget, pval);
      gchar *ret_text = CHG_STR(%s, s ? s : d, old_text, new_text);
      g_free(old_text);
      g_free(new_text);
      g_free(s);
      return ret_text;
    }
  }
  return NULL;
}

static gchar *_lib_history_change_text(dt_introspection_field_t *field,
                                       const char *d,
                                       dt_iop_module_t *module,
                                       gpointer params,
                                       gpointer oldpar)
{
  void *p = (void*)params + field->header.offset;
  void *o = (void*)oldpar + field->header.offset;

#define CHANGE_TEXT_BAUHAUS(format, fieldtype)                            \
  _lib_history_bauhaus_text((void*)module->params + field->header.offset, \
                            d, module, *(fieldtype*)o, *(fieldtype*)p)    \
  ?: CHG_STR(format, d, *(fieldtype*)o, *(fieldtype*)p)

  switch(field->header.type)
  {
  case DT_INTROSPECTION_TYPE_STRUCT:
  case DT_INTROSPECTION_TYPE_UNION:
    {
      gchar **change_parts = g_malloc0_n(field->Struct.entries + 1, sizeof(char*));
      int num_parts = 0;

      for(int i = 0; i < field->Struct.entries; i++)
      {
        dt_introspection_field_t *entry = field->Struct.fields[i];

        gchar *description = _(*entry->header.description ?
                                entry->header.description :
                                entry->header.field_name);

        if(d) description = g_strdup_printf("%s.%s", d, description);

        gchar *part = _lib_history_change_text(entry, description, module, params, oldpar);
        if(part) change_parts[num_parts++] = part;

        if(d) g_free(description);
      }

      gchar *struct_text = num_parts ? g_strjoinv("\n", change_parts) : NULL;
      g_strfreev(change_parts);

      return struct_text;
    }
    break;
  case DT_INTROSPECTION_TYPE_ARRAY:
    if(field->Array.type == DT_INTROSPECTION_TYPE_CHAR)
    {
      // stored params need not terminate the array; the checks below stop only at NUL
      const gboolean is_valid =
        memchr(o, '\0', field->Array.count)
        && memchr(p, '\0', field->Array.count)
        && g_utf8_validate((char *)o, -1, NULL)
        && g_utf8_validate((char *)p, -1, NULL);

      if(is_valid && strncmp((char*)o, (char*)p, field->Array.count))
        return CHG_STR("%s", d, (char*)o, (char*)p);
    }
    else
    {
      const int max_elements = 4;
      gchar **change_parts = g_malloc0_n(max_elements + 1, sizeof(char*));
      int num_parts = 0;

      for(int i = 0, item_offset = 0;
          i < field->Array.count;
          i++, item_offset += field->Array.field->header.size)
      {
        char *description = g_strdup_printf("%s[%d]", d, i);
        char *element_text =
          _lib_history_change_text(field->Array.field, description, module,
                                   (uint8_t *)params + item_offset,
                                   (uint8_t *)oldpar + item_offset);
        g_free(description);

        if(element_text && ++num_parts <= max_elements)
          change_parts[num_parts - 1] = element_text;
        else
          g_free(element_text);
      }

      gchar *array_text = NULL;
      if(num_parts > max_elements)
        array_text = g_strdup_printf("%s\t%d changes", d, num_parts);
      else if(num_parts > 0)
        array_text = g_strjoinv("\n", change_parts);

      g_strfreev(change_parts);

      return array_text;
    }
    break;
  case DT_INTROSPECTION_TYPE_FLOAT:
    if(!feqf(*(float*)o, *(float*)p, 1e-6) && (dt_isfinite(*(float*)o) || dt_isfinite(*(float*)p)))
      return CHANGE_TEXT_BAUHAUS(%.4f, float);
    break;
  case DT_INTROSPECTION_TYPE_INT:
    if(*(int*)o != *(int*)p)
      return CHANGE_TEXT_BAUHAUS(%d, int);
    break;
  case DT_INTROSPECTION_TYPE_UINT:
    if(*(unsigned int*)o != *(unsigned int*)p)
      return CHANGE_TEXT_BAUHAUS(%u, unsigned int);
    break;
  case DT_INTROSPECTION_TYPE_USHORT:
    if(*(unsigned short int*)o != *(unsigned short int*)p)
      return CHANGE_TEXT_BAUHAUS(%hu, unsigned short int);
    break;
  case DT_INTROSPECTION_TYPE_INT8:
    if(*(uint8_t*)o != *(uint8_t*)p)
      return CHANGE_TEXT_BAUHAUS(%d, uint8_t);
    break;
  case DT_INTROSPECTION_TYPE_CHAR:
    if(*(char*)o != *(char*)p)
      return CHANGE_TEXT_BAUHAUS('%c', char);
    break;
  case DT_INTROSPECTION_TYPE_FLOATCOMPLEX:
    if(*(float complex*)o != *(float complex*)p)
      return CHG_STR(%.4f + %.4fi, d,
                     creal(*(float complex*)o), cimag(*(float complex*)o),
                     creal(*(float complex*)p), cimag(*(float complex*)p));
    break;
  case DT_INTROSPECTION_TYPE_ENUM:
    if(*(int*)o != *(int*)p)
    {
      const char *old_str = N_("unknown"), *new_str = N_("unknown");
      for(dt_introspection_type_enum_tuple_t *i = field->Enum.values; i && i->name; i++)
      {
        if(i->value == *(int*)o)
        {
          old_str = i->description;
          if(!*old_str) old_str = i->name;
        }
        if(i->value == *(int*)p)
        {
          new_str = i->description;
          if(!*new_str) new_str = i->name;
        }
      }

      return CHG_STR(%s, d, _(old_str), _(new_str));
    }
    break;
  case DT_INTROSPECTION_TYPE_BOOL:
    if(*(gboolean*)o != *(gboolean*)p)
    {
      char *old_str = *(gboolean*)o ? "on" : "off";
      char *new_str = *(gboolean*)p ? "on" : "off";
      return CHG_STR(%s, d, _(old_str), _(new_str));
    }
    break;
  case DT_INTROSPECTION_TYPE_OPAQUE:
    {
      // TODO: special case float2
    }
    break;
  default:
    dt_print(DT_DEBUG_ALWAYS, "unsupported introspection type \"%s\" encountered"
             " in _lib_history_change_text (field %s)\n",
             field->header.type_name, field->header.field_name);
    break;
  }

  return NULL;
}

static gboolean _changes_tooltip_callback(GtkWidget *widget,
                                          const gint x,
                                          const gint y,
                                          const gboolean keyboard_mode,
                                          GtkTooltip *tooltip,
                                          const dt_dev_history_item_t *hitem)
{
  dt_iop_params_t *old_params = hitem->module->default_params;
  dt_develop_blend_params_t *old_blend = hitem->module->default_blendop_params;

  for(const GList *find_old = darktable.develop->history;
      find_old && find_old->data != hitem;
      find_old = g_list_next(find_old))
  {
    const dt_dev_history_item_t *hiprev = find_old->data;

    if(hiprev->module == hitem->module)
    {
      old_params = hiprev->params;
      old_blend = hiprev->blend_params;
    }
  }

  gchar **change_parts = g_malloc0_n(sizeof(dt_develop_blend_params_t)
                                     / (sizeof(float)) + 10, sizeof(char*));

  if(hitem->module->have_introspection)
    change_parts[0] = _lib_history_change_text(hitem->module->get_introspection()->field,
                                               NULL, hitem->module,
                                               hitem->params, old_params);
  int num_parts = change_parts[0] ? 1 : 0;

  if(hitem->module->flags() & IOP_FLAGS_SUPPORTS_BLENDING)
  {
    #define add_blend_history_change(field, fmt, label)                         \
      if((hitem->blend_params->field) != (old_blend->field))                    \
      {                                                                         \
        gchar *chg_fmt = g_strconcat("%s\t\t", fmt, "\t\u2192\t\t", fmt, NULL); \
        change_parts[num_parts++] =                                             \
          _lib_history_bauhaus_text(&hitem->module->blend_params->field,        \
                                    label, hitem->module, old_blend->field,     \
                                    hitem->blend_params->field)                 \
          ?: g_strdup_printf(chg_fmt, label,                                    \
                             old_blend->field, hitem->blend_params->field);     \
        g_free(chg_fmt);                                                        \
      }
    #define add_blend_history_change_enum(field, label, list)                   \
      if((hitem->blend_params->field) != (old_blend->field))                    \
      {                                                                         \
        const char *old_str = NULL, *new_str = NULL;                            \
        for(const dt_introspection_type_enum_tuple_t *i = list; i->name; i++)   \
        {                                                                       \
          if(i->value == (old_blend->field)) old_str = i->name;                 \
          if(i->value == (hitem->blend_params->field)) new_str = i->name;       \
        }                                                                       \
                                                                                \
        change_parts[num_parts++] = (!old_str || !new_str)                      \
                                ? CHG_STR(%d, label, old_blend->field,          \
                                                     hitem->blend_params->field)\
                                : CHG_STR(%s, label, Q_(old_str), Q_(new_str)); \
      }

    add_blend_history_change_enum(blend_cst, _("colorspace"),
                                  dt_develop_blend_colorspace_names);
    add_blend_history_change_enum(mask_mode, _("mask mode"),
                                  dt_develop_mask_mode_names);
    add_blend_history_change_enum(blend_mode & DEVELOP_BLEND_MODE_MASK, _("blend mode"),
                                  dt_develop_blend_mode_names);
    add_blend_history_change_enum(blend_mode & DEVELOP_BLEND_REVERSE, _("blend operation"),
                                  dt_develop_blend_mode_flag_names);
    add_blend_history_change(blend_parameter, _("%.2f EV"), _("blend fulcrum"));
    add_blend_history_change(opacity, "%.4f", _("mask opacity"));
    add_blend_history_change_enum
      (mask_combine & (DEVELOP_COMBINE_INV | DEVELOP_COMBINE_INCL), _("combine masks"),
       dt_develop_combine_masks_names);
    add_blend_history_change(feathering_radius, "%.4f", _("feathering radius"));
    add_blend_history_change_enum(feathering_guide, _("feathering guide"),
                                  dt_develop_feathering_guide_names);
    add_blend_history_change(blur_radius, "%.4f", _("mask blur"));
    add_blend_history_change(contrast, "%.4f", _("mask contrast"));
    add_blend_history_change(brightness, "%.4f", _("brightness"));
    add_blend_history_change(raster_mask_instance, "%d", _("raster mask instance"));
    add_blend_history_change(raster_mask_id, "%d", _("raster mask id"));
    add_blend_history_change_enum(raster_mask_invert, _("invert mask"),
                                  dt_develop_invert_mask_names);

    const char old_combine = old_blend->mask_combine           & DEVELOP_COMBINE_MASKS_POS ? '+' : '-';
    const char new_combine = hitem->blend_params->mask_combine & DEVELOP_COMBINE_MASKS_POS ? '+' : '-';
    if(new_combine != old_combine)
      change_parts[num_parts++] = CHG_STR(%c, _("drawn mask polarity"), old_combine, new_combine);

    const dt_mask_id_t old_grid = dt_develop_blend_mask_group_id(hitem->module, old_blend);
    const dt_mask_id_t new_grid =
      dt_develop_blend_mask_group_id(hitem->module, hitem->blend_params);
    if(new_grid != old_grid)
      change_parts[num_parts++] = old_grid == 0
                                ? g_strdup_printf(_("a drawn mask was added"))
                                : new_grid == 0
                                ? g_strdup_printf(_("the drawn mask was removed"))
                                : g_strdup_printf(_("the drawn mask was changed"));

    dt_iop_gui_blend_data_t *bd = hitem->module->blend_data;

    for(int in_out = 1; in_out >= 0; in_out--)
    {
      gboolean first = TRUE;

      for(const dt_iop_gui_blendif_channel_t *b =
            bd ? dt_develop_blendif_channels_for_csp(bd->csp) : NULL;
          b && b->label != NULL;
          b++)
      {
        const dt_develop_blendif_channels_t ch = b->param_channels[in_out];

        const int oactive = old_blend->blendif & (1 << ch);
        const int nactive = hitem->blend_params->blendif & (1 << ch);

        const int opolarity = old_blend->blendif & (1 << (ch + 16));
        const int npolarity = hitem->blend_params->blendif & (1 << (ch + 16));

        float *of = &old_blend->blendif_parameters[4 * ch];
        float *nf = &hitem->blend_params->blendif_parameters[4 * ch];

        const float oboost = exp2f(old_blend->blendif_boost_factors[ch]);
        const float nboost = exp2f(hitem->blend_params->blendif_boost_factors[ch]);

        if((oactive || nactive)
           && (memcmp(of, nf, sizeof(float) * 4) || opolarity != npolarity))
        {
          if(first)
          {
            change_parts[num_parts++] = g_strdup(in_out
                                                 ? _("parametric output mask:")
                                                 : _("parametric input mask:"));
            first = FALSE;
          }
          char s[4][2][25];
          for(int k = 0; k < 4; k++)
          {
            b->scale_print(of[k], oboost, s[k][0], sizeof(s[k][0]));
            b->scale_print(nf[k], nboost, s[k][1], sizeof(s[k][1]));
          }

          char *opol = !oactive ? "" : (opolarity ? "(-)" : "(+)");
          char *npol = !nactive ? "" : (npolarity ? "(-)" : "(+)");

          change_parts[num_parts++] = CHG_STR(%s| %s- %s| %s%s, _(b->name),
                                              s[0][0], s[1][0], s[2][0], s[3][0], opol,
                                              s[0][1], s[1][1], s[2][1], s[3][1], npol);
        }
      }
    }
  }

  gchar *tooltip_text = g_strjoinv("\n", change_parts);
  g_strfreev(change_parts);

  gboolean show_tooltip = *tooltip_text;

  if(show_tooltip)
  {
    static GtkWidget *view = NULL;
    if(!view)
    {
      view = gtk_text_view_new();
      dt_gui_add_class(view, "dt_transparent_background");
      dt_gui_add_class(view, "dt_monospace");
      g_signal_connect(G_OBJECT(view), "destroy", G_CALLBACK(gtk_widget_destroyed), &view);
    }

    // find tabs to align columns and decimals
    int t[5] = { 0 };
    struct lconv *locale = localeconv();
    for(char *line = tooltip_text; *line; )
    {
      gchar *endline = g_strstr_len(line, -1, "\n");
      if(!endline) endline = line + strlen(line);

      gchar *tab1 = g_strstr_len(line, endline - line, "\t");
      if(tab1)
      {
        if(t[0] <= tab1 - line) t[0] = tab1 - line + 1;

        tab1 += 2;
        gchar *tab2 = g_strstr_len(tab1, endline - tab1, "\t");
        if(tab2)
        {
          if(t[3] < tab2 - tab1) t[3] = tab2 - tab1;
          gchar *decimal1 = g_strstr_len(tab1, tab2 - tab1, locale->decimal_point);
          if(decimal1 && t[1] < decimal1 - tab1) t[1] = decimal1 - tab1;
          if(decimal1 && t[2] < tab2 - decimal1) t[2] = tab2 - decimal1;

          gchar *tab3 = g_strrstr_len(tab2, endline - tab2, "\t") + 1;
          gchar *decimal2 = g_strstr_len(tab3, endline - tab3, locale->decimal_point);
          if(decimal2 && t[4] < decimal2 - tab3) t[4] = decimal2 - tab3;
        }
      }

      line = endline;
      if(*line) line++;
    }
    if(t[3] < t[1] + t[2]) t[3] = t[1] + t[2];

    PangoLayout *layout = gtk_widget_create_pango_layout(view, " ");
    int char_width;
    pango_layout_get_size(layout, &char_width, NULL);
    g_object_unref(layout);
    PangoTabArray *tabs = pango_tab_array_new_with_positions
      (5, FALSE,
       PANGO_TAB_LEFT   , char_width * (t[0]),
       PANGO_TAB_DECIMAL, char_width * (t[0] + t[1] + 1),
       PANGO_TAB_LEFT   , char_width * (t[0] + t[3] + 1),
       PANGO_TAB_LEFT   , char_width * (t[0] + t[3] + 3),
       PANGO_TAB_DECIMAL, char_width * (t[0] + t[3] + 4 + t[4]));
    gtk_text_view_set_tabs(GTK_TEXT_VIEW(view), tabs);
    pango_tab_array_free(tabs);

    GtkTextBuffer *buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(view));
    gtk_text_buffer_set_text(buffer, tooltip_text, -1);
    gtk_tooltip_set_custom(tooltip, view);
    gtk_widget_map(view); // FIXME: workaround added in order to fix
                          // #9908, probably a Gtk issue, remove when
                          // fixed upstream
  }

  g_free(tooltip_text);

  return show_tooltip;
}

static gchar *_lib_history_button_label(const dt_dev_history_item_t *item)
{
  gchar *label = NULL;
  if(!item)
    label = g_strdup("");
  else
    label = dt_history_get_name_label(item->module->name(),
                                      item->multi_name,
                                      TRUE,
                                      item->multi_name_hand_edited);
  return label;
}

static void _lib_history_change_callback(gpointer instance,
                                         dt_lib_module_t *self)
{
  dt_lib_history_t *d = self->data;

  d->record_history_level--;
  d->record_undo = TRUE;

  dt_lib_gui_queue_update(self);
}

void gui_update(dt_lib_module_t *self)
{
  dt_lib_history_t *d = self->data;

  /* lock history mutex */
  dt_pthread_mutex_lock(&darktable.develop->history_mutex);

  /* first destroy all buttons in list */
  dt_gui_container_destroy_children(GTK_CONTAINER(d->history_box));

  /* add default which always should be */
  GtkWidget *widget =
    _lib_history_create_button(self, -1, _("original"),
                               FALSE, FALSE, TRUE,
                               darktable.develop->history_end == 0, FALSE);
  gtk_box_pack_end(GTK_BOX(d->history_box), widget, FALSE, FALSE, 0);

  int num = 0;

  /* iterate over history items and add them to list*/
  for(const GList *history = darktable.develop->history;
      history;
      history = g_list_next(history))
  {
    const dt_dev_history_item_t *hitem = history->data;
    gchar *label = _lib_history_button_label(hitem);

    const gboolean selected = (num == darktable.develop->history_end - 1);
    widget =
      _lib_history_create_button
      (self, num, label,
       (hitem->enabled || (strcmp(hitem->op_name, "mask_manager") == 0)),
       hitem->module->default_enabled, hitem->module->hide_enable_button, selected,
       hitem->module->flags() & IOP_FLAGS_DEPRECATED);

    g_free(label);

    gtk_widget_set_has_tooltip(widget, TRUE);
    g_signal_connect(G_OBJECT(widget), "query-tooltip",
                     G_CALLBACK(_changes_tooltip_callback), (void *)hitem);

    gtk_box_pack_end(GTK_BOX(d->history_box), widget, FALSE, FALSE, 0);
    num++;
  }

  /* show all widgets */
  gtk_widget_show_all(d->history_box);
  dt_gui_widget_reallocate_now(d->history_box);

  if(num == darktable.develop->history_end)
  {
    // history has changed, scroll to the top
    GtkWidget *sw = gtk_widget_get_ancestor(d->history_box, GTK_TYPE_SCROLLED_WINDOW);
    GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(sw));
    gtk_adjustment_set_value(adj, 0);
  }

  dt_pthread_mutex_unlock(&darktable.develop->history_mutex);
}

static void _lib_history_truncate(const gboolean compress)
{
  dt_dev_history_truncate(darktable.develop, compress);
}


static void _lib_history_compress_clicked_callback(GtkButton *widget, gpointer user_data)
{
  _lib_history_truncate(TRUE);
}

static void _lib_history_compress_pressed_callback(GtkGestureSingle *gesture,
                                                   gint n_press,
                                                   gdouble x,
                                                   gdouble y,
                                                   dt_lib_module_t *self)
{
  /* Claim the sequence, replacing the `return TRUE` of the button-press-event
   * handler this gesture was converted from.  Without it GtkButton's own
   * gesture still completes the click and emits "clicked", so
   * _lib_history_compress_clicked_callback would run _lib_history_truncate()
   * a second time -- on release, while the pixelpipe job started by this call
   * is still running -- and ctrl+click would truncate and then compress. */
  dt_gui_claim(gesture);

  const gboolean compress = !(dt_key_modifier_state() & GDK_CONTROL_MASK);
  _lib_history_truncate(compress);
}

static void _lib_history_button_clicked_callback(GtkGestureSingle *gesture,
                                                 gint n_press,
                                                 gdouble x,
                                                 gdouble y,
                                                 dt_lib_module_t *self)
{
  GtkWidget *widget = dt_gui_get_widget(gesture);
  const dt_imgid_t imgid = darktable.develop->image_storage.id;
  if(!dt_is_valid_imgid(imgid)) return;

  static gboolean reset = FALSE;

  if(reset) return;

  // shift-click just show the corresponding module in modulegroups.
  // tested before the active-row guard below: revealing a module never changes
  // the selection, so it must work on the selected row too
  if(dt_key_modifier_state() & GDK_SHIFT_MASK)
  {
    /* Claim the sequence, replacing the `return TRUE` of the button-press-event
     * handler this gesture was converted from: the history entry must only be
     * revealed in modulegroups, not selected, so GtkToggleButton must not get
     * to complete the click and flip its state. */
    dt_gui_claim(gesture);

    const int num = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(widget), "history-number"));
    dt_dev_history_item_t *hist = g_list_nth_data(darktable.develop->history, num - 1);
    if(hist)
    {
      dt_dev_modulegroups_switch(darktable.develop, hist->module);
      dt_iop_gui_set_expanded(hist->module, TRUE, TRUE);
    }
    return;
  }

  if(gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(widget)))
  {
    // Exactly one history row must stay selected. Claiming the press prevents
    // GtkToggleButton's default gesture from toggling the active row off.
    dt_gui_claim(gesture);
    return;
  }

  dt_lib_history_t *d = self->data;
  reset = TRUE;

  /* deactivate all toggle buttons */
  GList *children = gtk_container_get_children(GTK_CONTAINER(d->history_box));
  for(GList *l = children; l != NULL; l = g_list_next(l))
  {
    GtkToggleButton *b = GTK_TOGGLE_BUTTON
      (dt_gui_container_nth_child(GTK_CONTAINER(l->data), HIST_WIDGET_MODULE));
    if(b != GTK_TOGGLE_BUTTON(widget))
      g_object_set(G_OBJECT(b), "active", FALSE, (gchar *)0);
  }
  g_list_free(children);

  reset = FALSE;
  if(dt_atomic_get_int(&darktable.gui->reset) != 0) return;

  dt_dev_undo_start_record(darktable.develop);

  /* revert to given history item. */
  const int num = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(widget), "history-number"));
  dt_dev_pop_history_items(darktable.develop, num);
  // set the module list order
  dt_dev_reorder_gui_module_list(darktable.develop);
  dt_image_update_final_size(imgid);

  dt_dev_pixelpipe_cache_invalidate_later(darktable.develop->preview_pipe, 0, "history button: ");

  /* signal history changed */
  dt_dev_undo_end_record(darktable.develop);

  dt_iop_connect_accels_all();
  dt_dev_modulegroups_set(darktable.develop, dt_dev_modulegroups_get(darktable.develop));
}

static void _lib_history_create_style_button_clicked_callback(GtkWidget *widget,
                                                              gpointer user_data)
{
  if(darktable.develop->image_storage.id)
  {
    dt_dev_write_history(darktable.develop);
    dt_gui_styles_dialog_new(darktable.develop->image_storage.id);
  }
}

void gui_reset(dt_lib_module_t *self)
{
  const dt_imgid_t imgid = darktable.develop->image_storage.id;
  if(!dt_is_valid_imgid(imgid)) return;

  if(!dt_conf_get_bool("ask_before_discard")
     || dt_gui_show_yes_no_dialog
          (_("delete image's history?"), "",
           _("do you really want to clear history of current image?")))
  {
    dt_dev_undo_start_record(darktable.develop);

    dt_history_delete_on_image_ext(imgid, FALSE, TRUE);

    dt_dev_undo_end_record(darktable.develop);

    dt_dev_modulegroups_set(darktable.develop, dt_dev_modulegroups_get(darktable.develop));

    // the reset removes the focused module's mask; left open, the separate masks
    // panel would go on offering to edit it. A module still on screen keeps the
    // focus, so that an undo brings its panel back with the mask. One the group
    // just hid (active modules) loses it: the group's own focus drop is ignored
    // inside its gui update (modulegroups.c:1153, imageop.c:2854)
    dt_iop_module_t *focused = dt_dev_gui_module();
    if(focused && !(focused->expander && gtk_widget_get_visible(focused->expander)))
      dt_iop_request_focus(NULL);
    else
      dt_iop_gui_blend_masks_panel_fold_unmasked();

    dt_control_queue_redraw_center();
  }
}

// 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