From 68c38247f1e724e3de3589453999a4371ae31a2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Wed, 6 May 2026 17:17:54 +0200 Subject: [PATCH] [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) --- client/ui-wails/xembed_host_linux.go | 157 ++++++++++----- client/ui-wails/xembed_tray_linux.c | 279 +++++++++++++++++++++------ client/ui-wails/xembed_tray_linux.h | 6 +- 3 files changed, 336 insertions(+), 106 deletions(-) diff --git a/client/ui-wails/xembed_host_linux.go b/client/ui-wails/xembed_host_linux.go index 33d247b18..1565e0852 100644 --- a/client/ui-wails/xembed_host_linux.go +++ b/client/ui-wails/xembed_host_linux.go @@ -8,6 +8,7 @@ package main #include "xembed_tray_linux.h" #include #include +#include */ 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 +} diff --git a/client/ui-wails/xembed_tray_linux.c b/client/ui-wails/xembed_tray_linux.c index 2db6a4e45..ac4344978 100644 --- a/client/ui-wails/xembed_tray_linux.c +++ b/client/ui-wails/xembed_tray_linux.c @@ -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); } diff --git a/client/ui-wails/xembed_tray_linux.h b/client/ui-wails/xembed_tray_linux.h index 1fcb151b8..4dd3691ab 100644 --- a/client/ui-wails/xembed_tray_linux.h +++ b/client/ui-wails/xembed_tray_linux.h @@ -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.