[client/ui] Center windows on show under minimal WMs (XEmbed tray)

On minimal window managers (fluxbox et al, the in-process XEmbed-tray
path) the WM neither centers small windows nor restores their position
across a hide -> show round-trip, so the main, Settings, and dialog
windows opened in the top-left corner instead of centered.

These windows are created Hidden, so Wails' Linux/GTK4 backend skips its
post-Show centering pass (gated on !Hidden) and InitialPosition has no
effect on an unrealized window. Re-center from Go after Show, gated on
the minimal-WM environment via a recenterOnShow predicate (set to
xembedTrayAvailable on Linux, nil on macOS/Windows where the WM handles
placement). centerWhenReady polls from a background goroutine until the
move actually lands -- Center() moves via raw X11, which no-ops while the
GdkSurface is still nil and GTK4 realizes it asynchronously after Show().

Also reorder xembed_host_linux.go so the static helpers (xembedTrayAvailable,
goMenuItemClicked) sit at the end, after the constructor and methods.
This commit is contained in:
Zoltán Papp
2026-06-05 11:42:03 +02:00
parent 296d3f124b
commit 3967864172
6 changed files with 172 additions and 42 deletions
+36 -36
View File
@@ -70,43 +70,7 @@ type xembedHost struct {
stopCh chan struct{}
}
// goMenuItemClicked is the C callback invoked from the GTK main thread
// when the user activates a popup-menu entry. C callbacks cannot carry
// Go pointers, so the active xembedHost is looked up through the
// activeMenuHost global instead. //export makes this symbol visible to
// the C side; the function must therefore live in package main.
//
//export goMenuItemClicked
func goMenuItemClicked(id C.int) {
activeMenuHostMu.Lock()
h := activeMenuHost
activeMenuHostMu.Unlock()
if h != nil {
go h.sendMenuEvent(int32(id))
}
}
// newXembedHost creates an XEmbed tray icon for the given SNI item.
// xembedTrayAvailable reports whether an XEmbed system tray manager
// (_NET_SYSTEM_TRAY_S0) currently owns its selection on the default screen.
// It is a cheap, side-effect-free probe — it only queries the selection
// owner, creating no windows. Used to decide whether the in-process
// StatusNotifierWatcher is needed at all: the watcher exists solely to
// bridge SNI items into an XEmbed tray on minimal WMs, so when no XEmbed
// tray is present (e.g. Wayland compositors with a real SNI host like
// Waybar) we must not claim org.kde.StatusNotifierWatcher and shadow the
// real one. Returns false when there is no X display (pure Wayland).
func xembedTrayAvailable() bool {
dpy := C.XOpenDisplay(nil)
if dpy == nil {
return false
}
defer C.XCloseDisplay(dpy)
screen := C.xembed_default_screen(dpy)
return C.xembed_find_tray(dpy, screen) != 0
}
// Returns an error if no XEmbed tray manager is available (graceful fallback).
func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) {
dpy := C.XOpenDisplay(nil)
@@ -436,6 +400,42 @@ func buildCItems(items []menuItemInfo, allocs *[]unsafe.Pointer) *C.xembed_menu_
return (*C.xembed_menu_item)(arr)
}
// xembedTrayAvailable reports whether an XEmbed system tray manager
// (_NET_SYSTEM_TRAY_S0) currently owns its selection on the default screen.
// It is a cheap, side-effect-free probe — it only queries the selection
// owner, creating no windows. Used to decide whether the in-process
// StatusNotifierWatcher is needed at all: the watcher exists solely to
// bridge SNI items into an XEmbed tray on minimal WMs, so when no XEmbed
// tray is present (e.g. Wayland compositors with a real SNI host like
// Waybar) we must not claim org.kde.StatusNotifierWatcher and shadow the
// real one. Returns false when there is no X display (pure Wayland).
func xembedTrayAvailable() bool {
dpy := C.XOpenDisplay(nil)
if dpy == nil {
return false
}
defer C.XCloseDisplay(dpy)
screen := C.xembed_default_screen(dpy)
return C.xembed_find_tray(dpy, screen) != 0
}
// goMenuItemClicked is the C callback invoked from the GTK main thread
// when the user activates a popup-menu entry. C callbacks cannot carry
// Go pointers, so the active xembedHost is looked up through the
// activeMenuHost global instead. //export makes this symbol visible to
// the C side; the function must therefore live in package main.
//
//export goMenuItemClicked
func goMenuItemClicked(id C.int) {
activeMenuHostMu.Lock()
h := activeMenuHost
activeMenuHostMu.Unlock()
if h != nil {
go h.sendMenuEvent(int32(id))
}
}
// boolToInt converts a Go bool to the C int the dbusmenu C API uses
// for boolean flags.
func boolToInt(b bool) C.int {