[client/ui-wails] Add submenu support to the XEmbed tray popup

Recursively walk dbusmenu children-display="submenu" entries when
flattening the SNI menu so the GTK popup can render nested items.
The C side renders submenu folders as labeled buttons that open a
child popup window aligned to the anchor row, kept on-screen with
horizontal flipping; the top-level popup no longer self-destructs
when focus transfers to one of its own submenus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zoltán Papp
2026-05-06 17:17:54 +02:00
parent 8b8f38de1b
commit 68c38247f1
3 changed files with 336 additions and 106 deletions

View File

@@ -8,6 +8,7 @@ package main
#include "xembed_tray_linux.h"
#include <X11/Xlib.h>
#include <stdlib.h>
#include <string.h>
*/
import "C"
@@ -28,17 +29,30 @@ var (
activeMenuHostMu sync.Mutex
)
//export goMenuItemClicked
func goMenuItemClicked(id C.int) {
activeMenuHostMu.Lock()
h := activeMenuHost
activeMenuHostMu.Unlock()
if h != nil {
go h.sendMenuEvent(int32(id))
}
// menuItemInfo is the Go-side representation of one popup menu entry,
// flattened from a dbusMenuLayout tree before it is handed to the C
// popup builder. Submenus populate children; leaves leave it nil.
type menuItemInfo struct {
id int32
label string
enabled bool
isCheck bool
checked bool
isSeparator bool
children []menuItemInfo
}
// dbusMenuLayout mirrors the (ia{sv}av) result returned by
// com.canonical.dbusmenu.GetLayout. The Children variants each wrap a
// nested dbusMenuLayout; we decode them lazily in flattenMenu.
type dbusMenuLayout struct {
ID int32
Properties map[string]dbus.Variant
Children []dbus.Variant
}
// xembedHost manages one XEmbed tray icon for an SNI item.
type xembedHost struct {
conn *dbus.Conn
@@ -58,6 +72,23 @@ 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.
// Returns an error if no XEmbed tray manager is available (graceful fallback).
func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) {
@@ -253,25 +284,16 @@ func (h *xembedHost) contextMenu(x, y int32) {
return
}
// Build C menu item array.
cItems := make([]C.xembed_menu_item, len(items))
cLabels := make([]*C.char, len(items)) // track for freeing
for i, mi := range items {
cItems[i].id = C.int(mi.id)
cItems[i].enabled = boolToInt(mi.enabled)
cItems[i].is_check = boolToInt(mi.isCheck)
cItems[i].checked = boolToInt(mi.checked)
cItems[i].is_separator = boolToInt(mi.isSeparator)
if mi.label != "" {
cLabels[i] = C.CString(mi.label)
cItems[i].label = cLabels[i]
}
}
// Build a C-allocated tree from the Go menu. xembed_show_popup_menu
// deep-copies into its own buffer (so it can outlive this stack
// frame), but it expects valid C strings + pointers in the caller's
// array — we still have to walk the items on the Go side and build
// matching C.xembed_menu_item nodes recursively.
var allocs []unsafe.Pointer
cItems := buildCItems(items, &allocs)
defer func() {
for _, p := range cLabels {
if p != nil {
C.free(unsafe.Pointer(p))
}
for _, p := range allocs {
C.free(p)
}
}()
@@ -280,26 +302,10 @@ func (h *xembedHost) contextMenu(x, y int32) {
activeMenuHost = h
activeMenuHostMu.Unlock()
C.xembed_show_popup_menu(&cItems[0], C.int(len(cItems)),
C.xembed_show_popup_menu(cItems, C.int(len(items)),
nil, C.int(x), C.int(y))
}
// dbusMenuLayout represents a com.canonical.dbusmenu layout item.
type dbusMenuLayout struct {
ID int32
Properties map[string]dbus.Variant
Children []dbus.Variant
}
type menuItemInfo struct {
id int32
label string
enabled bool
isCheck bool
checked bool
isSeparator bool
}
func (h *xembedHost) flattenMenu(layout dbusMenuLayout) []menuItemInfo {
var items []menuItemInfo
@@ -352,6 +358,16 @@ func (h *xembedHost) flattenMenu(layout dbusMenuLayout) []menuItemInfo {
}
}
// Recurse into nested submenus. The dbusmenu spec marks a folder
// item with children-display=="submenu"; the children are already
// in child.Children because GetLayout was called with
// recursionDepth=-1 (all levels).
if v, ok := child.Properties["children-display"]; ok {
if s, ok := v.Value().(string); ok && s == "submenu" {
mi.children = h.flattenMenu(child)
}
}
items = append(items, mi)
}
@@ -369,13 +385,6 @@ func (h *xembedHost) sendMenuEvent(id int32) {
}
}
func boolToInt(b bool) C.int {
if b {
return 1
}
return 0
}
func (h *xembedHost) stop() {
select {
case <-h.stopCh:
@@ -387,3 +396,49 @@ func (h *xembedHost) stop() {
C.xembed_destroy_icon(h.dpy, h.iconWin)
C.XCloseDisplay(h.dpy)
}
// buildCItems recursively translates Go menuItemInfo slices into a
// C-allocated array of xembed_menu_item suitable for passing across the
// Cgo boundary. The C side deep-copies the structure when it stages
// the popup, so any transient labels/children we allocate here can be
// released as soon as xembed_show_popup_menu returns. Every malloc is
// recorded in *allocs so the caller can free it via a single deferred
// loop. Returns nil for empty slices.
func buildCItems(items []menuItemInfo, allocs *[]unsafe.Pointer) *C.xembed_menu_item {
if len(items) == 0 {
return nil
}
size := C.size_t(len(items)) * C.size_t(unsafe.Sizeof(C.xembed_menu_item{}))
arr := C.malloc(size)
*allocs = append(*allocs, arr)
C.memset(arr, 0, size)
slice := (*[1 << 16]C.xembed_menu_item)(arr)[:len(items):len(items)]
for i, mi := range items {
slice[i].id = C.int(mi.id)
slice[i].enabled = boolToInt(mi.enabled)
slice[i].is_check = boolToInt(mi.isCheck)
slice[i].checked = boolToInt(mi.checked)
slice[i].is_separator = boolToInt(mi.isSeparator)
if mi.label != "" {
cstr := C.CString(mi.label)
*allocs = append(*allocs, unsafe.Pointer(cstr))
slice[i].label = cstr
}
if len(mi.children) > 0 {
slice[i].children = buildCItems(mi.children, allocs)
slice[i].child_count = C.int(len(mi.children))
}
}
return (*C.xembed_menu_item)(arr)
}
// boolToInt converts a Go bool to the C int the dbusmenu C API uses
// for boolean flags.
func boolToInt(b bool) C.int {
if b {
return 1
}
return 0
}

View File

@@ -246,8 +246,11 @@ int xembed_poll_event(Display *dpy, Window icon_win,
/* Implemented in Go via //export */
extern void goMenuItemClicked(int id);
/* The popup window, reused across invocations. */
/* The top-level popup window, reused across invocations. Submenu
popups are tracked in a separate list so they all close when the
top-level closes. */
static GtkWidget *popup_win = NULL;
static GList *submenu_popups = NULL; /* list of GtkWidget* */
typedef struct {
xembed_menu_item *items;
@@ -255,38 +258,219 @@ typedef struct {
int x, y;
} popup_data;
/* Deep-free a heap-owned xembed_menu_item array (label + children). */
static void free_items(xembed_menu_item *items, int count) {
if (!items) return;
for (int i = 0; i < count; i++) {
free((void *)items[i].label);
free_items(items[i].children, items[i].child_count);
}
free(items);
}
static void free_popup_data(popup_data *pd) {
if (!pd) return;
for (int i = 0; i < pd->count; i++)
free((void *)pd->items[i].label);
free(pd->items);
free_items(pd->items, pd->count);
free(pd);
}
static void on_button_clicked(GtkButton *btn, gpointer user_data) {
int id = GPOINTER_TO_INT(user_data);
if (popup_win)
/* Close every popup window — top-level plus any open submenus.
Called when the user clicks an actionable item or focus leaves the
top-level window. */
static void close_all_popups(void) {
for (GList *l = submenu_popups; l; l = l->next) {
gtk_widget_destroy(GTK_WIDGET(l->data));
}
g_list_free(submenu_popups);
submenu_popups = NULL;
if (popup_win) {
gtk_widget_hide(popup_win);
}
}
static void on_button_clicked(GtkButton *btn, gpointer user_data) {
(void)btn;
int id = GPOINTER_TO_INT(user_data);
close_all_popups();
goMenuItemClicked(id);
}
static void on_check_toggled(GtkToggleButton *btn, gpointer user_data) {
(void)btn;
int id = GPOINTER_TO_INT(user_data);
if (popup_win)
gtk_widget_hide(popup_win);
close_all_popups();
goMenuItemClicked(id);
}
static gboolean on_popup_focus_out(GtkWidget *widget, GdkEvent *event,
gpointer user_data) {
gtk_widget_hide(widget);
(void)widget; (void)event; (void)user_data;
/* Don't tear the menu down when the top-level loses focus to one of
its own submenu popups. close_all_popups() would destroy the
submenu we just opened. The submenu's own focus-out handler will
close it (and us) when focus leaves the popup tree. */
if (submenu_popups != NULL)
return FALSE;
close_all_popups();
return FALSE;
}
/* Forward declaration — submenu buttons need to schedule a child popup. */
static GtkWidget *build_menu_box(xembed_menu_item *items, int count);
typedef struct {
xembed_menu_item *items;
int count;
GtkWidget *anchor; /* the submenu button — used to position the popup */
} submenu_open_data;
/* Tear-down companion for submenu_open_data: detach from submenu_popups
and destroy. Used when the focus-out handler fires on a submenu. */
static gboolean on_submenu_focus_out(GtkWidget *widget, GdkEvent *event,
gpointer user_data) {
(void)event; (void)user_data;
submenu_popups = g_list_remove(submenu_popups, widget);
gtk_widget_destroy(widget);
return FALSE;
}
static void on_submenu_button_clicked(GtkButton *btn, gpointer user_data) {
submenu_open_data *sd = (submenu_open_data *)user_data;
GtkWidget *win = gtk_window_new(GTK_WINDOW_TOPLEVEL);
gtk_window_set_type_hint(GTK_WINDOW(win), GDK_WINDOW_TYPE_HINT_POPUP_MENU);
gtk_window_set_decorated(GTK_WINDOW(win), FALSE);
gtk_window_set_resizable(GTK_WINDOW(win), FALSE);
gtk_window_set_skip_taskbar_hint(GTK_WINDOW(win), TRUE);
gtk_window_set_skip_pager_hint(GTK_WINDOW(win), TRUE);
gtk_window_set_keep_above(GTK_WINDOW(win), TRUE);
g_signal_connect(win, "focus-out-event",
G_CALLBACK(on_submenu_focus_out), NULL);
GtkWidget *vbox = build_menu_box(sd->items, sd->count);
gtk_container_add(GTK_CONTAINER(win), vbox);
/* GtkButton has no native GdkWindow of its own — gtk_widget_get_window
returns the parent popup's window. To get the button's screen-space
position we read the popup origin (ox, oy) and add the button's
allocation within the popup. */
gint ox, oy;
gdk_window_get_origin(gtk_widget_get_window(GTK_WIDGET(btn)), &ox, &oy);
GtkAllocation alloc;
gtk_widget_get_allocation(GTK_WIDGET(btn), &alloc);
int ax = ox + alloc.x;
int ay = oy + alloc.y;
gtk_widget_show_all(win);
gint sw, sh;
gtk_window_get_size(GTK_WINDOW(win), &sw, &sh);
/* The parent popup grows upward from the tray, so submenu items
sit closer to the bottom of the screen than to the top. Align
the submenu's BOTTOM to the anchor button's bottom: the popup
grows upward, level with the row that opened it. Don't clamp
to the monitor top — that would re-position the submenu next
to an unrelated sibling row above the anchor. */
int final_x = ax + alloc.width;
int final_y = ay + alloc.height - sh;
/* Horizontal flip against the monitor under the anchor button. */
GdkDisplay *display = gtk_widget_get_display(win);
GdkMonitor *monitor = gdk_display_get_monitor_at_point(display, ax, ay);
if (monitor) {
GdkRectangle geom;
gdk_monitor_get_geometry(monitor, &geom);
if (final_x + sw > geom.x + geom.width)
final_x = ax - sw; /* flip to the left */
}
gtk_window_move(GTK_WINDOW(win), final_x, final_y);
gtk_window_present(GTK_WINDOW(win));
submenu_popups = g_list_prepend(submenu_popups, win);
}
/* Build a vbox of GtkWidgets for the supplied items. Used for both the
top-level popup and each submenu popup. The submenu_open_data attached
to submenu buttons is freed when the submenu_popups list is cleared
(we use the button's "destroy" signal). */
static void on_button_destroy_free_data(GtkWidget *widget, gpointer user_data) {
(void)widget;
free(user_data);
}
static GtkWidget *build_menu_box(xembed_menu_item *items, int count) {
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
for (int i = 0; i < count; i++) {
xembed_menu_item *mi = &items[i];
if (mi->is_separator) {
GtkWidget *sep = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL);
gtk_box_pack_start(GTK_BOX(vbox), sep, FALSE, FALSE, 2);
continue;
}
if (mi->is_check) {
GtkWidget *chk = gtk_check_button_new_with_label(
mi->label ? mi->label : "");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(chk), mi->checked);
gtk_widget_set_sensitive(chk, mi->enabled);
g_signal_connect(chk, "toggled",
G_CALLBACK(on_check_toggled),
GINT_TO_POINTER(mi->id));
gtk_box_pack_start(GTK_BOX(vbox), chk, FALSE, FALSE, 0);
continue;
}
/* Plain button (leaf) or submenu opener. Show "Label ▸" for
submenu folders so users see they're nested. */
const char *label_text = mi->label ? mi->label : "";
char *display_label = NULL;
if (mi->child_count > 0 && mi->children) {
/* Compose "label ▸" (BLACK RIGHT-POINTING SMALL TRIANGLE). */
size_t n = strlen(label_text) + 8; /* ascii + " ▸" + NUL */
display_label = (char *)malloc(n);
snprintf(display_label, n, "%s \xE2\x96\xB8", label_text);
label_text = display_label;
}
GtkWidget *btn = gtk_button_new_with_label(label_text);
gtk_widget_set_sensitive(btn, mi->enabled);
gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NONE);
GtkWidget *lbl = gtk_bin_get_child(GTK_BIN(btn));
if (lbl) gtk_label_set_xalign(GTK_LABEL(lbl), 0.0);
free(display_label);
if (mi->child_count > 0 && mi->children) {
submenu_open_data *sd =
(submenu_open_data *)calloc(1, sizeof(submenu_open_data));
sd->items = mi->children;
sd->count = mi->child_count;
sd->anchor = btn;
g_signal_connect(btn, "clicked",
G_CALLBACK(on_submenu_button_clicked), sd);
g_signal_connect(btn, "destroy",
G_CALLBACK(on_button_destroy_free_data), sd);
} else {
g_signal_connect(btn, "clicked",
G_CALLBACK(on_button_clicked),
GINT_TO_POINTER(mi->id));
}
gtk_box_pack_start(GTK_BOX(vbox), btn, FALSE, FALSE, 0);
}
return vbox;
}
static gboolean popup_menu_idle(gpointer user_data) {
popup_data *pd = (popup_data *)user_data;
/* Destroy old popup if it exists. */
/* Destroy old top-level (and orphan submenus) before rebuilding. */
close_all_popups();
if (popup_win) {
gtk_widget_destroy(popup_win);
popup_win = NULL;
@@ -305,43 +489,9 @@ static gboolean popup_menu_idle(gpointer user_data) {
g_signal_connect(popup_win, "focus-out-event",
G_CALLBACK(on_popup_focus_out), NULL);
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
GtkWidget *vbox = build_menu_box(pd->items, pd->count);
gtk_container_add(GTK_CONTAINER(popup_win), vbox);
for (int i = 0; i < pd->count; i++) {
xembed_menu_item *mi = &pd->items[i];
if (mi->is_separator) {
GtkWidget *sep = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL);
gtk_box_pack_start(GTK_BOX(vbox), sep, FALSE, FALSE, 2);
continue;
}
if (mi->is_check) {
GtkWidget *chk = gtk_check_button_new_with_label(
mi->label ? mi->label : "");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(chk), mi->checked);
gtk_widget_set_sensitive(chk, mi->enabled);
g_signal_connect(chk, "toggled",
G_CALLBACK(on_check_toggled),
GINT_TO_POINTER(mi->id));
gtk_box_pack_start(GTK_BOX(vbox), chk, FALSE, FALSE, 0);
} else {
GtkWidget *btn = gtk_button_new_with_label(
mi->label ? mi->label : "");
gtk_widget_set_sensitive(btn, mi->enabled);
gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NONE);
/* Left-align label. */
GtkWidget *label = gtk_bin_get_child(GTK_BIN(btn));
if (label)
gtk_label_set_xalign(GTK_LABEL(label), 0.0);
g_signal_connect(btn, "clicked",
G_CALLBACK(on_button_clicked),
GINT_TO_POINTER(mi->id));
gtk_box_pack_start(GTK_BOX(vbox), btn, FALSE, FALSE, 0);
}
}
gtk_widget_show_all(popup_win);
/* Position the window above the click point (menu grows upward from tray). */
@@ -356,24 +506,45 @@ static gboolean popup_menu_idle(gpointer user_data) {
/* Grab focus so focus-out-event works. */
gtk_window_present(GTK_WINDOW(popup_win));
free_popup_data(pd);
/* The vbox+children retain pointers into pd->items (via submenu
click handlers). free_popup_data() walks the array recursively
to release labels and children buffers — but we need to keep
the items alive while the popup is open. Defer the free until
the popup window is destroyed. */
g_object_set_data_full(G_OBJECT(popup_win), "popup_data", pd,
(GDestroyNotify)free_popup_data);
return G_SOURCE_REMOVE;
}
/* Recursively deep-copy a Go-supplied items array into freshly-allocated
C memory. Each label is strdup'd, each children array is calloc'd. */
static xembed_menu_item *copy_items(xembed_menu_item *src, int count) {
if (count <= 0 || !src) return NULL;
xembed_menu_item *dst =
(xembed_menu_item *)calloc(count, sizeof(xembed_menu_item));
for (int i = 0; i < count; i++) {
dst[i] = src[i];
if (src[i].label)
dst[i].label = strdup(src[i].label);
if (src[i].child_count > 0 && src[i].children) {
dst[i].children = copy_items(src[i].children, src[i].child_count);
dst[i].child_count = src[i].child_count;
} else {
dst[i].children = NULL;
dst[i].child_count = 0;
}
}
return dst;
}
void xembed_show_popup_menu(xembed_menu_item *items, int count,
xembed_menu_click_cb cb, int x, int y) {
(void)cb;
popup_data *pd = (popup_data *)calloc(1, sizeof(popup_data));
pd->items = (xembed_menu_item *)calloc(count, sizeof(xembed_menu_item));
pd->items = copy_items(items, count);
pd->count = count;
pd->x = x;
pd->y = y;
for (int i = 0; i < count; i++) {
pd->items[i] = items[i];
if (items[i].label)
pd->items[i].label = strdup(items[i].label);
}
g_idle_add(popup_menu_idle, pd);
}

View File

@@ -55,13 +55,17 @@ typedef void (*xembed_menu_click_cb)(int id);
// x, y are root coordinates for positioning the popup.
// This must be called from the GTK main thread (use g_idle_add).
typedef struct {
typedef struct xembed_menu_item {
int id; // dbusmenu item ID
const char *label; // display label (NULL for separator)
int enabled; // whether the item is clickable
int is_check; // whether this is a checkbox item
int checked; // checkbox state (0 or 1)
int is_separator;// 1 if this is a separator
// children + child_count populate when this item is a submenu folder
// (dbusmenu's children-display=="submenu"). NULL/0 means leaf item.
struct xembed_menu_item *children;
int child_count;
} xembed_menu_item;
// Schedule a GTK popup menu on the main thread.