dev-doc/GUI.md
This document covers building module UIs, handling events and callbacks, updating the GUI from worker threads, and widget reparenting patterns.
See also:
_from_params, buttons, sections)GtkNotebookgui_init() Overviewgui_init() is called once per module instance when entering the darkroom. Its job is to create and configure all widgets — but not to set their values (that happens in gui_update()).
void gui_init(dt_iop_module_t *self)
{
dt_iop_mymodule_gui_data_t *g = IOP_GUI_ALLOC(mymodule);
// ... create widgets ...
}
IOP_GUI_ALLOC(modulename) allocates gui_data_t via calloc and assigns it to self->gui_data.
self->widget — Dual RoleThe self->widget pointer has two purposes:
gui_init(): Acts as the "current packing target" for dt_bauhaus_*_from_params() functions, which implicitly pack into self->widget.gui_init(): Tells the framework which widget represents the entire module UI for display in the side panel.For a simple module, set it once at the top:
self->widget = dt_gui_vbox();
g->slider1 = dt_bauhaus_slider_from_params(self, "param1"); // auto-packs into self->widget
g->slider2 = dt_bauhaus_slider_from_params(self, "param2");
For tabbed or collapsible UIs, you temporarily redirect self->widget to sub-containers during construction, then restore it at the end. See Section 4: Widget Reparenting below.
darktable provides wrapper functions for GTK4 compatibility. Always use these instead of raw GTK packing functions:
| Function | Replaces | Purpose |
|---|---|---|
dt_gui_vbox() / dt_gui_hbox() | gtk_box_new() | Create a box with standard spacing |
dt_gui_box_add(box, child) | gtk_box_pack_start() | Add a widget to a container |
dt_ui_label_new(text) | gtk_label_new() | Label with automatic ellipsization |
dt_ui_section_label_new(text) | — | Visual section header/divider |
Controlling layout — set properties on the widget before adding:
// Make a widget expand to fill available space
gtk_widget_set_hexpand(widget, TRUE);
gtk_widget_set_halign(widget, GTK_ALIGN_FILL);
hexpand/vexpand): Whether the widget claims extra space. Default is FALSE.halign/valign): How the widget fits within its allocation. Use GTK_ALIGN_FILL for inputs, GTK_ALIGN_CENTER for checkboxes, GTK_ALIGN_START/GTK_ALIGN_END for labels._from_params() functions auto-pack into self->widget in the order you call them. For non-introspection widgets (labels, manual buttons), use dt_gui_box_add() explicitly:
self->widget = dt_gui_vbox();
g->slider1 = dt_bauhaus_slider_from_params(self, "param1"); // auto-packed
dt_gui_box_add(self->widget, dt_ui_section_label_new(_("advanced"))); // manual
g->slider2 = dt_bauhaus_slider_from_params(self, "param2"); // auto-packed
There are three distinct paths through the callback system:
Path A — _from_params widget changed by user:
User drags slider
↓
Framework sets value in self->params
↓
Framework calls gui_changed(self, widget, previous)
↓
Framework calls dt_dev_add_history_item() internally
↓
commit_params() → process()
Path B — Custom widget changed by user:
User clicks custom button
↓
your_callback() fires
↓
Check: if(darktable.gui->reset) return;
↓
Modify self->params directly
↓
Call dt_dev_add_history_item(darktable.develop, self, TRUE)
↓
commit_params() → process()
Path C — External change (image switch, undo, preset):
Framework loads new params into self->params
↓
Framework sets darktable.gui->reset
↓
Framework calls your gui_update()
↓
You sync widgets, call gui_changed(self, NULL, NULL)
↓
Framework clears darktable.gui->reset
gui_update() — Sync Widgets from ParamsCalled by the framework when params change externally (image switch, history navigation, preset load, copy/paste). The framework sets darktable.gui->reset before calling it, so widget callbacks won't fire.
Sliders and comboboxes created with _from_params auto-sync. You only need to manually sync toggle buttons and custom widgets. Always end with gui_changed(self, NULL, NULL):
void gui_update(dt_iop_module_t *self)
{
dt_iop_mymodule_gui_data_t *g = self->gui_data;
dt_iop_mymodule_params_t *p = self->params;
// Toggle buttons need manual sync
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->my_toggle), p->my_bool);
// Apply all UI state adjustments
gui_changed(self, NULL, NULL);
}
gui_changed() — UI State AdjustmentsThe single place for all conditional visibility, sensitivity, and dynamic label logic. Called:
_from_params auto-callback (with widget = the changed widget, previous = old value)gui_update() (with widget = NULL)void gui_changed(dt_iop_module_t *self, GtkWidget *w, void *previous)
{
dt_iop_mymodule_gui_data_t *g = self->gui_data;
dt_iop_mymodule_params_t *p = self->params;
// Show/hide based on mode
if(!w || w == g->method)
gtk_widget_set_visible(g->advanced_slider, p->mode == MODE_ADVANCED);
// Disable when irrelevant
gtk_widget_set_sensitive(g->saturation, p->mode != MODE_MONOCHROME);
}
darktable.gui->reset)A counter (not a boolean) that suppresses callback processing when non-zero. The framework uses it during gui_update().
Pattern 1: Check at the start of every manual callback:
static void my_callback(GtkWidget *w, dt_iop_module_t *self)
{
if(darktable.gui->reset) return; // Always do this
dt_iop_mymodule_params_t *p = self->params;
p->value = calculate_new_value();
dt_dev_add_history_item(darktable.develop, self, TRUE);
}
Pattern 2: Suppress callbacks when programmatically updating widgets:
// Setting slider2 in response to slider1 changing
++darktable.gui->reset;
dt_bauhaus_slider_set(g->slider2, compute_from(p->value1));
--darktable.gui->reset;
dt_dev_add_history_item()Records the current state of self->params to the history stack, triggering a pixelpipe reprocess.
void dt_dev_add_history_item(dt_develop_t *dev, dt_iop_module_t *module, gboolean enable);
The enable parameter:
TRUE: Also sets module->enabled = TRUE. Use when the user's action should turn on the module.FALSE: Only records params. Use during continuous adjustments (drag) where the module is already enabled.When to call it:
| Situation | Call? | Notes |
|---|---|---|
| Manual callback (custom slider, button) | Yes | After modifying self->params |
color_picker_apply() | Yes | After setting params from picked color |
| Mouse drag on graph/area | Yes | FALSE during drag, TRUE on release |
gui_changed() | No | Framework handles this for _from_params widgets |
gui_update() | No | Syncing GUI from params, not changing params |
Attach a color picker to a slider with dt_color_picker_new():
GtkWidget *slider = dt_bauhaus_slider_from_params(self, "white_point");
g->white_point_picker = dt_color_picker_new(self, DT_COLOR_PICKER_AREA, slider);
Picker lifecycle: button click → sets request_color_pick on module → pipeline processes → pixel data sampled → framework calls color_picker_apply().
Available data:
self->picked_color[0..2] — mean RGB from picked areaself->picked_color[3] — luminance (if available)self->picked_color_min[0..2], self->picked_color_max[0..2] — rangevoid color_picker_apply(dt_iop_module_t *self, GtkWidget *picker,
dt_dev_pixelpipe_t *pipe)
{
dt_iop_mymodule_params_t *p = self->params;
dt_iop_mymodule_gui_data_t *g = self->gui_data;
if(picker == g->white_point_picker)
p->white_point = log2f(self->picked_color[3]) + some_offset;
dt_dev_add_history_item(darktable.develop, self, TRUE);
}
For picker flags and dt_color_picker_new_with_cst(), see imageop_gui.md.
For modules with canvas interaction (crop, masks):
mouse_moved(), button_pressed(), button_released(), scrolled() — return 1 if event handledgui_post_expose() — draw overlays on the center view with CairoUse dt_control_change_cursor() to set the mouse cursor shape during interaction. This function uses CSS cursor names.
// Example: set to crosshair during interaction
dt_control_change_cursor("crosshair");
Commonly used CSS cursor names in darktable:
"default": standard arrow"pointer": clickable or draggable element"move": dragging an object"crosshair": precise selection/cropping"wait": busy state (replaces legacy GDK_WATCH)"not-allowed": invalid target or help-mode deselect"help": help mode"none": hidden cursor"w-resize", "e-resize", "n-resize", "s-resize": cardinal resizing"nw-resize", "ne-resize", "se-resize", "sw-resize": corner resizing"ew-resize", "ns-resize": bidirectional resizingRefer to src/control/control.c for the implementation of fallbacks for backends with incomplete CSS support.
process()process() runs on worker threads. GTK+ is not thread-safe. You cannot call GTK functions directly from process().
Always check these conditions before scheduling a GUI update from process():
dt_iop_mymodule_gui_data_t *g = self->gui_data;
if(g != NULL // GUI exists (not export)
&& self->dev->gui_attached // darkroom active
&& (piece->pipe->type & DT_DEV_PIXELPIPE_FULL)) // not preview/thumbnail
{
// Schedule GUI update...
}
g_idle_addStore computed values in gui_data under mutex, then schedule a GTK-thread callback.
// In process():
dt_iop_gui_enter_critical_section(self);
g->computed_exposure = exposure;
dt_iop_gui_leave_critical_section(self);
g_idle_add(_show_computed, self);
// Callback (GTK main thread):
static gboolean _show_computed(gpointer user_data)
{
dt_iop_module_t *self = user_data;
dt_iop_mymodule_gui_data_t *g = self->gui_data;
dt_iop_gui_enter_critical_section(self);
float val = g->computed_exposure;
dt_iop_gui_leave_critical_section(self);
gchar *str = g_strdup_printf(_("%.2f EV"), val);
gtk_label_set_text(g->label, str);
g_free(str);
return G_SOURCE_REMOVE; // Run once, then remove
}
Allocate a message struct that the callback owns and frees. No critical sections needed.
typedef struct
{
dt_iop_module_t *self;
float values[3];
} mymodule_gui_msg_t;
// Callback (GTK main thread):
static gboolean _update_gui(gpointer data)
{
mymodule_gui_msg_t *msg = data;
dt_iop_mymodule_gui_data_t *g = msg->self->gui_data;
if(g) // GUI might have been destroyed
{
memcpy(g->display_values, msg->values, sizeof(g->display_values));
gtk_widget_queue_draw(msg->self->widget);
}
g_free(msg); // Callback owns the message
return G_SOURCE_REMOVE;
}
// At end of process():
if(g != NULL && self->dev->gui_attached
&& (piece->pipe->type & DT_DEV_PIXELPIPE_FULL))
{
mymodule_gui_msg_t *msg = g_malloc(sizeof(*msg));
msg->self = self;
memcpy(msg->values, local_values, sizeof(msg->values));
g_idle_add(_update_gui, msg);
}
These use g_idle_add internally and are safe to call from any thread:
dt_control_queue_redraw_widget(widget) — redraw a specific widgetdt_control_queue_redraw_center() — redraw the center view// WRONG — GTK+ call directly in process()
void process(...) {
gtk_label_set_text(g->label, "value"); // Crash or undefined behavior
}
// WRONG — No mutex when writing gui_data
void process(...) {
g->computed_value = result; // Race condition
g_idle_add(update_gui, self);
}
// WRONG — Forgetting to free message
static gboolean callback(gpointer data) {
// ... use data ...
return G_SOURCE_REMOVE; // Memory leak — must g_free(data)
}
// WRONG — Sending updates for preview/thumbnail pipes
if(g != NULL) { // Missing pipe type check — floods with updates
g_idle_add(...);
}
GTK widgets have a single parent. Reparenting means removing a widget from one container and adding it to another. In darktable, this is used during gui_init() to build tabbed and collapsible UIs, and by the framework for the Quick Access Panel.
The most common reparenting pattern during gui_init(): temporarily point self->widget at each notebook page so _from_params() helpers pack widgets into the correct page.
WRONG — All widgets end up in main_vbox, not in notebook pages:
void gui_init(dt_iop_module_t *self)
{
dt_iop_mymodule_gui_data_t *g = IOP_GUI_ALLOC(mymodule);
GtkWidget *main_vbox = dt_gui_vbox();
self->widget = main_vbox; // Set too early!
static dt_action_def_t notebook_def = { };
g->notebook = dt_ui_notebook_new(¬ebook_def);
dt_gui_box_add(main_vbox, GTK_WIDGET(g->notebook));
GtkWidget *page1 = dt_ui_notebook_page(g->notebook, N_("basic"), NULL);
g->brightness = dt_bauhaus_slider_from_params(self, "brightness"); // Goes into main_vbox!
GtkWidget *page2 = dt_ui_notebook_page(g->notebook, N_("advanced"), NULL);
g->gamma = dt_bauhaus_slider_from_params(self, "gamma"); // Also goes into main_vbox!
}
CORRECT — Temporarily redirect self->widget for each page:
void gui_init(dt_iop_module_t *self)
{
dt_iop_mymodule_gui_data_t *g = IOP_GUI_ALLOC(mymodule);
GtkWidget *main_vbox = dt_gui_vbox();
static dt_action_def_t notebook_def = { };
g->notebook = dt_ui_notebook_new(¬ebook_def);
dt_gui_box_add(main_vbox, GTK_WIDGET(g->notebook));
// --- Page 1 ---
GtkWidget *page1 = dt_ui_notebook_page(g->notebook, N_("basic"), NULL);
self->widget = page1; // Redirect packing to page1
g->brightness = dt_bauhaus_slider_from_params(self, "brightness"); // → page1
g->contrast = dt_bauhaus_slider_from_params(self, "contrast"); // → page1
// --- Page 2 ---
GtkWidget *page2 = dt_ui_notebook_page(g->notebook, N_("advanced"), NULL);
self->widget = page2; // Redirect packing to page2
g->gamma = dt_bauhaus_slider_from_params(self, "gamma"); // → page2
// --- Final ---
self->widget = main_vbox; // Set to top-level container at the end
}
See Notebook_UI.md for the complete pattern with shortcut registration.
Same technique — temporarily redirect self->widget to the collapsible container:
GtkWidget *main_box = self->widget = dt_gui_vbox();
g->amount = dt_bauhaus_slider_from_params(self, "amount"); // main level
dt_gui_new_collapsible_section(&g->cs, "plugins/darkroom/mymodule/expand_advanced",
_("advanced"), GTK_BOX(main_box), DT_ACTION(self));
self->widget = GTK_WIDGET(g->cs.container); // redirect
g->detail = dt_bauhaus_slider_from_params(self, "detail"); // → collapsible
g->quality = dt_bauhaus_combobox_from_params(self, "quality"); // → collapsible
self->widget = main_box; // restore
When a user adds a module's widget to the Quick Access Panel, the framework (libs/modulegroups.c) automatically:
g_object_ref() the widget to prevent destruction on removalnotify::visible signals to keep visibility in syncConstraints for QAP-compatible widgets:
GtkBox or GtkGrid parent