|
| 1 | +import { JSCallback, type Pointer } from 'bun:ffi'; |
| 2 | +import type { MenuRealizer } from '../../api/menu'; |
| 3 | +import type { NativeMenuItemSpec } from '../macos/cocoa-menu'; |
| 4 | +import { cstr } from '../cstr'; |
| 5 | +import { G_CONNECT_DEFAULT, loadGObjectFFI } from './gobject-ffi'; |
| 6 | +import { loadGMenuFFI } from './gtk-menu-ffi'; |
| 7 | + |
| 8 | +/** |
| 9 | + * Builds native GTK 4 application menus from the backend-neutral menu spec and |
| 10 | + * routes item clicks back to JS — the Linux equivalent of `cocoa-menu.ts`. |
| 11 | + * |
| 12 | + * A GTK menu is a `GMenu` *model* paired with a `GSimpleActionGroup`. Each |
| 13 | + * clickable item is given a uniquely named `GSimpleAction` (e.g. `menu-0`); the |
| 14 | + * model entry references it as `"sambar.menu-0"`. Activating the action — by a |
| 15 | + * click, an accelerator, or `g_action_group_activate_action` — emits the |
| 16 | + * action's `activate` signal, which fires the item's JS `onClick`. This mirrors |
| 17 | + * the macOS `sambarMenuAction:` registry pattern. |
| 18 | + * |
| 19 | + * One action group is shared by the whole tree (submenus add their actions to |
| 20 | + * the SAME group), so a single `gtk_widget_insert_action_group(window, |
| 21 | + * "sambar", group)` makes every item live. |
| 22 | + * |
| 23 | + * JSCallback lifecycle: the `activate` thunks are LONG-LIVED — they must stay |
| 24 | + * reachable for the menu's lifetime or GObject jumps into freed memory on the |
| 25 | + * next click (a past SIGSEGV class). Every thunk is therefore retained in the |
| 26 | + * per-menu {@link MenuEntry} (held by {@link menuEntries}) and NEVER closed |
| 27 | + * synchronously inside its own invocation. In v1 they are not closed at all. |
| 28 | + * |
| 29 | + * The native GIO/GObject calls are funnelled through an injectable |
| 30 | + * {@link Bindings} so the realizer's tree-walking, action-naming, and |
| 31 | + * click-routing logic is unit-testable on a non-Linux host without `dlopen`. |
| 32 | + */ |
| 33 | + |
| 34 | +/** ABI shape for `GSimpleAction::activate`: `(action, parameter, user_data) -> void`. */ |
| 35 | +export const ACTION_ACTIVATE_CB_DEF = { args: ['ptr', 'ptr', 'ptr'], returns: 'void' } as const; |
| 36 | + |
| 37 | +/** The action-group namespace prefix inserted into the window. */ |
| 38 | +const ACTION_GROUP_PREFIX = 'sambar'; |
| 39 | + |
| 40 | +let actionCounter = 0; |
| 41 | + |
| 42 | +/** A fresh, process-unique action name like `menu-0`, `menu-1`, … */ |
| 43 | +export const actionName = (): string => `menu-${actionCounter++}`; |
| 44 | + |
| 45 | +/** Namespace an action name for a `GMenu` `detailed_action` (e.g. `sambar.menu-0`). */ |
| 46 | +export const detailedAction = (name: string): string => `${ACTION_GROUP_PREFIX}.${name}`; |
| 47 | + |
| 48 | +/** |
| 49 | + * The native operations the realizer needs. Real implementation wraps GIO + |
| 50 | + * GObject FFI and {@link JSCallback}; tests inject a recording fake. Handles are |
| 51 | + * `bigint` here (the fake uses tagged numbers); the real binding casts to/from |
| 52 | + * `Pointer`. |
| 53 | + */ |
| 54 | +export type Bindings = { |
| 55 | + gMenuNew(): bigint; |
| 56 | + gMenuAppend(menu: bigint, label: string, detailed: string): void; |
| 57 | + gMenuAppendSubmenu(menu: bigint, label: string, submenu: bigint): void; |
| 58 | + gMenuAppendSection(menu: bigint, section: bigint): void; |
| 59 | + gSimpleActionGroupNew(): bigint; |
| 60 | + gSimpleActionNew(name: string): bigint; |
| 61 | + gSimpleActionSetEnabled(action: bigint, enabled: number): void; |
| 62 | + gActionMapAddAction(group: bigint, action: bigint): void; |
| 63 | + /** Connect a retained `activate` handler to `action`; returns the retained thunk. */ |
| 64 | + connectActivate(action: bigint, thunk: () => void): unknown; |
| 65 | + /** Programmatically fire `detailed` on `group` (the testing/verification path). */ |
| 66 | + activateAction(group: bigint, detailed: string, parameter: bigint | null): void; |
| 67 | +}; |
| 68 | + |
| 69 | +/** The realized native artefacts for one top-level menu, kept in {@link menuEntries}. */ |
| 70 | +export type MenuEntry = { |
| 71 | + /** The top-level `GMenu` model pointer (also the realizer's bigint handle). */ |
| 72 | + readonly model: bigint; |
| 73 | + /** The `GSimpleActionGroup` shared by the whole tree. */ |
| 74 | + readonly group: bigint; |
| 75 | + /** Action names (e.g. `menu-0`) in realization order, for lookup/verification. */ |
| 76 | + readonly actionNames: string[]; |
| 77 | + /** Retained activate thunks — kept alive for the menu's lifetime. */ |
| 78 | + readonly retained: unknown[]; |
| 79 | + /** Count of retained thunks (one per clickable item). */ |
| 80 | + readonly retainedCount: number; |
| 81 | +}; |
| 82 | + |
| 83 | +const menuEntries = new Map<bigint, MenuEntry>(); |
| 84 | + |
| 85 | +let currentAppMenu: { readonly model: bigint; readonly group: bigint } | undefined; |
| 86 | + |
| 87 | +let injectedBindings: Bindings | undefined; |
| 88 | + |
| 89 | +/** The real GIO/GObject-backed bindings (constructed lazily on Linux). */ |
| 90 | +const realBindings = (): Bindings => { |
| 91 | + const gio = loadGMenuFFI(); |
| 92 | + const gobject = loadGObjectFFI(); |
| 93 | + const asPtr = (h: bigint): Pointer => Number(h) as unknown as Pointer; |
| 94 | + const asHandle = (p: Pointer | null): bigint => BigInt(p === null ? 0 : (p as unknown as number)); |
| 95 | + return { |
| 96 | + gMenuNew: () => asHandle(gio.symbols.g_menu_new()), |
| 97 | + gMenuAppend: (menu, label, detailed) => |
| 98 | + gio.symbols.g_menu_append(asPtr(menu), cstr(label), cstr(detailed)), |
| 99 | + gMenuAppendSubmenu: (menu, label, submenu) => |
| 100 | + gio.symbols.g_menu_append_submenu(asPtr(menu), cstr(label), asPtr(submenu)), |
| 101 | + gMenuAppendSection: (menu, section) => |
| 102 | + gio.symbols.g_menu_append_section(asPtr(menu), null, asPtr(section)), |
| 103 | + gSimpleActionGroupNew: () => asHandle(gio.symbols.g_simple_action_group_new()), |
| 104 | + gSimpleActionNew: (name) => asHandle(gio.symbols.g_simple_action_new(cstr(name), null)), |
| 105 | + gSimpleActionSetEnabled: (action, enabled) => |
| 106 | + gio.symbols.g_simple_action_set_enabled(asPtr(action), enabled), |
| 107 | + gActionMapAddAction: (group, action) => |
| 108 | + gio.symbols.g_action_map_add_action(asPtr(group), asPtr(action)), |
| 109 | + connectActivate: (action, thunk) => { |
| 110 | + const callback = new JSCallback( |
| 111 | + (_action: Pointer, _parameter: Pointer, _userData: Pointer): void => { |
| 112 | + thunk(); |
| 113 | + }, |
| 114 | + ACTION_ACTIVATE_CB_DEF, |
| 115 | + ); |
| 116 | + gobject.symbols.g_signal_connect_data( |
| 117 | + asPtr(action), |
| 118 | + cstr('activate'), |
| 119 | + callback.ptr, |
| 120 | + null, |
| 121 | + null, |
| 122 | + G_CONNECT_DEFAULT, |
| 123 | + ); |
| 124 | + return callback; |
| 125 | + }, |
| 126 | + activateAction: (group, detailed, parameter) => |
| 127 | + gio.symbols.g_action_group_activate_action( |
| 128 | + asPtr(group), |
| 129 | + cstr(detailed), |
| 130 | + parameter === null ? null : asPtr(parameter), |
| 131 | + ), |
| 132 | + }; |
| 133 | +}; |
| 134 | + |
| 135 | +const bindings = (): Bindings => injectedBindings ?? realBindings(); |
| 136 | + |
| 137 | +/** Override the native bindings. Test-only. */ |
| 138 | +export const setBindingsForTesting = (fake: Bindings | undefined): void => { |
| 139 | + injectedBindings = fake; |
| 140 | +}; |
| 141 | + |
| 142 | +type WalkContext = { |
| 143 | + readonly b: Bindings; |
| 144 | + readonly group: bigint; |
| 145 | + readonly actionNames: string[]; |
| 146 | + readonly retained: unknown[]; |
| 147 | +}; |
| 148 | + |
| 149 | +/** Append every spec in `items` to the `model`, wiring actions into the shared context. */ |
| 150 | +const appendItems = ( |
| 151 | + ctx: WalkContext, |
| 152 | + model: bigint, |
| 153 | + items: ReadonlyArray<NativeMenuItemSpec>, |
| 154 | +): void => { |
| 155 | + for (const spec of items) { |
| 156 | + if (spec.type === 'separator') { |
| 157 | + ctx.b.gMenuAppendSection(model, ctx.b.gMenuNew()); |
| 158 | + continue; |
| 159 | + } |
| 160 | + if (spec.type === 'submenu' && spec.submenu !== undefined) { |
| 161 | + const child = ctx.b.gMenuNew(); |
| 162 | + appendItems(ctx, child, spec.submenu); |
| 163 | + ctx.b.gMenuAppendSubmenu(model, spec.label, child); |
| 164 | + continue; |
| 165 | + } |
| 166 | + if (spec.type === 'normal' && spec.onClick !== undefined) { |
| 167 | + const name = actionName(); |
| 168 | + const action = ctx.b.gSimpleActionNew(name); |
| 169 | + ctx.b.gSimpleActionSetEnabled(action, spec.enabled ? 1 : 0); |
| 170 | + const retained = ctx.b.connectActivate(action, spec.onClick); |
| 171 | + ctx.b.gActionMapAddAction(ctx.group, action); |
| 172 | + ctx.actionNames.push(name); |
| 173 | + ctx.retained.push(retained); |
| 174 | + ctx.b.gMenuAppend(model, spec.label, detailedAction(name)); |
| 175 | + continue; |
| 176 | + } |
| 177 | + // A normal item with no onClick: a static, inert label (e.g. a heading). |
| 178 | + ctx.b.gMenuAppend(model, spec.label, detailedAction(actionName())); |
| 179 | + } |
| 180 | +}; |
| 181 | + |
| 182 | +/** Build a `GMenu` model + shared `GSimpleActionGroup` for `items`; returns the model handle. */ |
| 183 | +const realize = (items: ReadonlyArray<NativeMenuItemSpec>): bigint => { |
| 184 | + const b = bindings(); |
| 185 | + const model = b.gMenuNew(); |
| 186 | + const group = b.gSimpleActionGroupNew(); |
| 187 | + const ctx: WalkContext = { b, group, actionNames: [], retained: [] }; |
| 188 | + appendItems(ctx, model, items); |
| 189 | + menuEntries.set(model, { |
| 190 | + model, |
| 191 | + group, |
| 192 | + actionNames: ctx.actionNames, |
| 193 | + retained: ctx.retained, |
| 194 | + retainedCount: ctx.retained.length, |
| 195 | + }); |
| 196 | + return model; |
| 197 | +}; |
| 198 | + |
| 199 | +/** Install `menuHandle` as the current application menu (applied to future windows). */ |
| 200 | +const setApplicationMenu = (menuHandle: bigint): void => { |
| 201 | + const entry = menuEntries.get(menuHandle); |
| 202 | + if (entry === undefined) { |
| 203 | + throw new Error(`setApplicationMenu: unknown menu handle ${menuHandle}`); |
| 204 | + } |
| 205 | + currentAppMenu = { model: entry.model, group: entry.group }; |
| 206 | +}; |
| 207 | + |
| 208 | +/** The realized artefacts for a handle, or `undefined`. Used by tests + verification. */ |
| 209 | +export const getMenuEntry = (handle: bigint): MenuEntry | undefined => menuEntries.get(handle); |
| 210 | + |
| 211 | +/** |
| 212 | + * The model + action group of the current application menu, or `undefined` if |
| 213 | + * none is set. Read by {@link LinuxWindow} construction to attach a menu bar. |
| 214 | + */ |
| 215 | +export const getCurrentAppMenu = (): { model: bigint; group: bigint } | undefined => currentAppMenu; |
| 216 | + |
| 217 | +/** Replace the current application menu state directly. @internal */ |
| 218 | +export const setCurrentAppMenu = (menu: { model: bigint; group: bigint } | undefined): void => { |
| 219 | + currentAppMenu = menu; |
| 220 | +}; |
| 221 | + |
| 222 | +/** Clear the stored application menu. Test-only. */ |
| 223 | +export const resetCurrentAppMenuForTesting = (): void => { |
| 224 | + currentAppMenu = undefined; |
| 225 | +}; |
| 226 | + |
| 227 | +/** The Linux native menu realizer (GMenu + GSimpleActionGroup + GtkPopoverMenuBar). */ |
| 228 | +export const linuxMenuRealizer: MenuRealizer = { |
| 229 | + realize, |
| 230 | + setApplicationMenu, |
| 231 | +}; |
0 commit comments