GTK4 quirks
Siril's GUI (in src/gui-gtk4/) was ported from GTK3 to GTK4 over a
long series of numbered phases, and the port surfaced a set of behaviour
changes that are easy to trip over. Several of the items below cost real
debugging time. This chapter records them in a symptom → cause → required
pattern form so you can recognise the failure quickly and reach for the
already-established fix instead of rediscovering it.
Read this page alongside OS differences, which holds the
platform-specific detail (macOS NSEvent monitors, dark-mode, and so on),
and Threading / Locking, which the dialog and
quit paths interact with.
Added in version 1.5: The whole GTK4 GUI layer is new in the 1.5 development line.
Modal windows need a transient parent
Symptom. A window created with gtk_window_set_modal(win, TRUE)
behaves as if it were not modal — clicks reach widgets behind it — and on
some window managers (notably KDE/kwin) a code-created modal can wedge the
whole desktop session so that no clicks land anywhere.
Cause. GTK4 grants modality relative to a transient parent. Without
gtk_window_set_transient_for() the window has no parent to be modal over,
so GTK either ignores the modality or the compositor takes a full-session
grab that nothing owns.
Required pattern. Every modal you create in code gets a transient
parent, set before it is presented. The parent is normally the main window,
looked up from the builder as control_window, or the root of the widget
that triggered the dialog:
gtk_window_set_transient_for(win, GTK_WINDOW(gtk_builder_get_object(gui.builder, "control_window")))— the pattern insiril_open_dialog()andrun_custom_message_window()insrc/gui-gtk4/dialogs.candsrc/gui-gtk4/message_dialog.c.gtk_window_set_transient_for(dialog, GTK_WINDOW(gtk_widget_get_root(widget)))when you only have the originating widget — as incompositing.candkeywords_tree.c.
Setting the transient parent also gives you free centring: GTK4 removed
GTK_WIN_POS_CENTER and the dialog window-type hint, and the compositor
now centres transient windows for you (see the comment in
siril_open_dialog()).
Focus is not returned to the parent on dismissal
Symptom. After a transient dialog or a native file dialog closes, the
parent window silently stops responding to its accelerators (Ctrl+O,
Ctrl+P, …). Clicking back into the window fixes it. On the GTK console
you may also see "Broken accounting of active state" if you re-present too
eagerly.
Cause. On Wayland and macOS the compositor does not always hand keyboard focus/activation back to the parent toplevel when the child is dismissed, so the parent is no longer the active window and its shortcut controller never sees the keys. X11 and most Wayland cases do return focus, so a blind re-present is both pointless and harmful there.
Required pattern. Re-present the parent on an idle, guarded so it only
acts when the parent is genuinely inactive. Use reactivate_parent() (which
reads the dialog's transient parent and falls back to control_window)
after hiding but before destroying a dialog; siril_fc_run() and
run_custom_message_window() already do this. The machinery is
present_toplevel() / present_toplevel_idle() in
src/gui-gtk4/dialogs.c: the re-present is deferred to the next
main-loop iteration (the triggering click is still in flight) and only fires
when gtk_window_is_active() is false.
Event controllers replace signals
Symptom. Legacy "button-press-event" / "scroll-event" /
"key-press-event" handlers no longer compile or never fire; event structs
like GdkEventButton and their field accesses (event->x,
event->button) are gone.
Cause. GTK4 folds the per-type events into one opaque GdkEvent and
routes input through GtkEventController objects rather than widget
signals. The transition shim src/gui-gtk4/gtk3_event_compat.h keeps
legacy type names (GdkEventButton, GdkEventScroll,
GdkEventMotion, GdkEventKey, GdkEventCrossing) compiling by
aliasing them all onto GdkEvent — but it deliberately does not
provide field access. Every field read must be rewritten through a controller
API.
Required pattern. Attach a controller with
gtk_widget_add_controller() and connect its signals:
GtkGestureClickfor button presses. Its"pressed"handler receivesint n_press— GTK4's replacement for the removedGDK_DOUBLE_BUTTON_PRESS/GDK_TRIPLE_BUTTON_PRESSevent types — son_press >= 2is a double-click. Claim the sequence withgtk_gesture_set_state(..., GTK_EVENT_SEQUENCE_CLAIMED)to consume it. Seeon_mask_button_clicked()andon_headerbar_pressed()insrc/gui-gtk4/callbacks.c.GtkEventControllerMotionfor hover/enter/leave,GtkGestureDragfor drags,GtkEventControllerScrollfor the wheel,GtkEventControllerKeyfor keys,GtkShortcutControllerfor accelerators. All of these are wired inattach_drawingarea_event_controllers()insrc/gui-gtk4/image_interactions.c.
Mind the propagation phase. Several stock widgets consume events in the
bubble phase before they reach your controller. When you need to see the
event first, set gtk_event_controller_set_propagation_phase(ctrl,
GTK_PHASE_CAPTURE). This is why the Escape-to-close controller
(ensure_escape_controller() in src/gui-gtk4/dialogs.c) and the
capture-phase accelerator controller (rebuild_capture_accel_controller()
in src/gui-gtk4/callbacks.c) both run in the capture phase — GTK4
plain GtkWindow also lost the built-in Escape-closes-dialog binding that
GtkDialog gave for free, so Escape is now wired explicitly and routed
through gtk_window_close().
The compat sentinels are not real events
gtk3_event_compat.h also re-defines GDK_DOUBLE_BUTTON_PRESS and
GDK_TRIPLE_BUTTON_PRESS as out-of-range values (0x1000 / 0x1001)
that cannot collide with real GdkEventType members. These exist only as
in-memory sentinels for storing and comparing mouse-button configuration
(single vs double-click), not as event types you can receive. Siril's own
mouse-config enum (for example SIRIL_EVENT_2BUTTON_PRESS used in
image_interactions.c) is the value you actually act on; the live
double-click test at input time is always n_press on the gesture.
Scroll events carry no pointer coordinates
Symptom. A wheel-zoom handler needs the cursor position to zoom about the
point under the pointer, but the GtkEventControllerScroll "scroll"
signal only delivers dx/dy, no x/y.
Cause. GTK4's scroll signal does not carry pointer coordinates, and
gdk_event_get_position() on the underlying event returns
surface-relative coordinates that are not what the zoom code expects.
Required pattern. Cache the last cursor position from the motion
controller and read it back in the scroll handler as the zoom anchor. In
src/gui-gtk4/image_interactions.c the motion callbacks write
last_motion_widget_x / last_motion_widget_y and
on_drawingarea_scroll_cb() reads them. This is always current: for a
scroll to fire over the drawing area the pointer must have crossed it (firing
motion) first. The discrete direction, when you need it, comes from
gdk_scroll_event_get_direction() on
gtk_event_controller_get_current_event().
File dialogs are async-only
Symptom. There is no synchronous "run the dialog and return the chosen
path" call anymore; blocking on a file dialog freezes the app, and on macOS
it can deadlock inside the CFRunLoop when the native panel is torn down
(the same freeze class as the historic open-dialog freeze).
Cause. GTK 4.10 deprecated GtkFileChooserDialog in favour of
GtkFileDialog, whose open / save / select_folder /
open_multiple methods are asynchronous — they return immediately and
deliver the result to a GAsyncResult finish callback. They also speak
GFile, not path strings.
Required pattern. Use the SirilFileChooser wrapper in
src/gui-gtk4/dialogs.c, which preserves the familiar
create → configure → run → read → destroy shape by spinning a local
GMainLoop in siril_fc_run() until the finish callback fires:
create with
siril_fc_open()/siril_fc_add()/siril_fc_save();configure with
siril_fc_set_current_folder_path(),siril_fc_set_filename(),siril_fc_set_current_name(),siril_fc_add_filter_pattern(),siril_fc_set_select_multiple();run with
siril_fc_run();read with
siril_fc_get_filename()/siril_fc_get_filenames()(these convert the returnedGFileto a path for you);free with
siril_fc_destroy().
The wrapper builds GFile objects internally via g_file_new_for_path(),
and siril_fc_run() re-presents the parent on return for the focus reason
above. The one exception left on the legacy path is open_dialog.c, because
GtkFileDialog has no slot for the custom image-preview widget. The
string-based folder helper siril_file_chooser_set_current_folder_path()
lives in src/gui-gtk4/utils.c and wraps the GTK4 signature change
(GFile* instead of const char *path).
Never block the main loop waiting on a file dialog by any other means — the
GMainLoop spin above is deliberate and returns cleanly; a naive busy-wait
is what re-introduces the macOS modal-teardown freeze.
Clipboard is asynchronous too
Symptom. The old synchronous gtk_clipboard_wait_for_text() is gone;
reading the clipboard inline (e.g. inside an owner-change handler) is no
longer possible.
Cause. GTK4 uses GdkClipboard obtained from
gtk_widget_get_clipboard() / gdk_display_get_clipboard(), and text
reads are asynchronous.
Required pattern. Split the read: the signal handler kicks off
gdk_clipboard_read_text_async() and a continuation callback does the work
once the text is available. See handle_owner_change() and
clipboard_text_ready_cb() in src/gui-gtk4/callbacks.c. Guard the
async read behind gdk_clipboard_get_formats() first: firing a text read on
non-text clipboard contents (for example an image/png from a screenshot
tool) has crashed reliably inside GLib/GTK's async dispatch.
Popover autohide is unreliable
Symptom. An autohide GtkPopover stays open when you click elsewhere in
the window; only Escape closes it. Clicking the popover's own border is enough
to break its dismissal.
Cause. Autohide relies on an outside-click grab. On Wayland/mutter and on
macOS that grab can be lost, and once it is, GTK never sees the outside click
that should pop the popover down. On macOS an autohide popover maps as a
separate focused NSWindow, so AppKit does not even deliver the outside
mouseDown to the main window's GTK view.
Required pattern. A capture-phase click controller on the main window
pops down any mapped autohide popover on press —
install_global_popover_autodismiss() /
on_window_press_dismiss_popovers() in src/gui-gtk4/callbacks.c.
The shared, unconditional dismissal is close_open_autohide_popovers() (it
skips the clicked menu button's own subtree so the button can still toggle
itself). On macOS the GTK controller never fires, so an NSEvent monitor
(siril_macos_fix_popover_autohide() in src/core/OS_utils.c) calls
back into GTK through the gui_iface.dismiss_autohide_popovers function
pointer, which is wired to close_open_autohide_popovers() in
src/gui-gtk4/gui_iface_impl.c — keeping the Cocoa code free of GTK
headers.
The nested-popover get_root trap
When you create a popover programmatically, do not blindly parent it on
gtk_widget_get_root() of an arbitrary widget: for a GtkMenuButton that
crashes in realize (the button's surface is not a valid popup parent), and for
a popover-inside-a-popover gtk_widget_get_root() can return a non-window
GtkRoot. Look up control_window from the builder as the parent
instead — it is always realized and always provides a valid native surface.
See popover_new_with_image() in src/gui-gtk4/utils.c.
APIs that simply went away
Window positioning. gtk_window_get_position() / gtk_window_move()
and GTK_WIN_POS_CENTER are gone; placement is compositor-driven. Siril no
longer records or restores dialog positions across reopens (see the note in
siril_open_dialog() in src/gui-gtk4/dialogs.c). Rely on the
transient-parent centring described above.
Quitting. There is no gtk_main_quit() in GTK4. Quit the application
through the GApplication — the "quit" action registered in
src/main.c and g_application_quit() in
src/gui-gtk4/gui_iface_impl.c. (There is a Siril function literally
named gtk_main_quit() in src/gui-gtk4/callbacks.c, but it is a
Siril shim that pumps the loop to drain in-flight work before cleanup, not the
old GTK symbol — don't confuse the two.)
Widget lifecycle: hide, don't destroy
Symptom. Destroying a reusable dialog and rebuilding it on next use loses its state and, worse, invalidates any cached widget pointers.
Cause. Siril's dialogs are built once from the builder and reused. Builder
objects live for the lifetime of the application, so a widget looked up with
lookup_widget() / gtk_builder_get_object() stays valid.
Required pattern. Toggle visibility rather than destroy: siril_open_dialog()
presents and siril_close_dialog() calls gtk_widget_set_visible(w, FALSE)
(both in src/gui-gtk4/dialogs.c). Per-window setup that must run only
once (the Escape controller, for instance) is guarded by an object-data flag
so reopening is idempotent — see ensure_escape_controller(). Reserve
gtk_window_destroy() for windows genuinely built ad hoc in code, such as
the custom message window in src/gui-gtk4/message_dialog.c.
macOS specials
These are summarised here and covered in full in OS differences.
Cmd-key accelerators. GTK4's
GdkMacosViewdoes not overrideperformKeyEquivalent:(GTK3'sGdkQuartzViewdid), soCommand+keyshortcuts never reach the shortcut controller. The workaround is a localNSEventkey-down monitor,siril_macos_fix_keyboard_shortcuts()insrc/core/OS_utils.c, which forwards Cmd+key events into GDK. Accelerators are registered with<Meta>on macOS —set_accel_map()insrc/gui-gtk4/callbacks.cruns each entry throughsiril_remap_accel()(insrc/gui-gtk4/utils.c) to swap<Primary>for<Meta>so Command matches.Popover mouseDown monitor. The autohide-popover
NSEventmonitorsiril_macos_fix_popover_autohide()described above. Both monitors ignore native panels by checking that the window's content view carries a"Gdk"class-name prefix.
Grab and click-freeze hazards on X11
Symptom. On some X11 desktops (KDE has been the reproducer) Siril can appear to freeze the desktop — clicks land nowhere — typically when a dialog or popover comes up while the app is backgrounded during startup.
Cause. This is a leaked/held grab: a modal anchored to a background window
can hold input focus behind an unrelated window, which reads as a frozen
desktop. The KDE setting AllowDeactivateGrabs is the discriminator between
desktops that recover and those that lock.
Required pattern (hardening, kept deliberately light).
Don't take popdown grabs during startup — keep autohide popovers out of the startup path.
When a modal must appear over a possibly-backgrounded parent, raise that parent first:
raise_parent_if_backgrounded()insrc/gui-gtk4/message_dialog.craises the transient parent when it is mapped but not active, so the modal surfaces in front of the user instead of trapping focus behind another window. It is a no-op for the normal foreground case, so it never steals focus.