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.

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:

  • GtkGestureClick for button presses. Its "pressed" handler receives int n_press — GTK4's replacement for the removed GDK_DOUBLE_BUTTON_PRESS / GDK_TRIPLE_BUTTON_PRESS event types — so n_press >= 2 is a double-click. Claim the sequence with gtk_gesture_set_state(..., GTK_EVENT_SEQUENCE_CLAIMED) to consume it. See on_mask_button_clicked() and on_headerbar_pressed() in src/gui-gtk4/callbacks.c.

  • GtkEventControllerMotion for hover/enter/leave, GtkGestureDrag for drags, GtkEventControllerScroll for the wheel, GtkEventControllerKey for keys, GtkShortcutController for accelerators. All of these are wired in attach_drawingarea_event_controllers() in src/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 returned GFile to 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 GdkMacosView does not override performKeyEquivalent: (GTK3's GdkQuartzView did), so Command+key shortcuts never reach the shortcut controller. The workaround is a local NSEvent key-down monitor, siril_macos_fix_keyboard_shortcuts() in src/core/OS_utils.c, which forwards Cmd+key events into GDK. Accelerators are registered with <Meta> on macOS — set_accel_map() in src/gui-gtk4/callbacks.c runs each entry through siril_remap_accel() (in src/gui-gtk4/utils.c) to swap <Primary> for <Meta> so Command matches.

  • Popover mouseDown monitor. The autohide-popover NSEvent monitor siril_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() in src/gui-gtk4/message_dialog.c raises 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.