janela 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lib.mjs +12 -0
- package/package.json +1 -1
- package/runtime/janela.ts +105 -0
- package/runtime/types.ts +49 -0
- package/shim/wvshim.cc +331 -0
package/bin/lib.mjs
CHANGED
|
@@ -223,6 +223,18 @@ export function ffiManifest(shimLib, { platform = process.platform, macSdkPath =
|
|
|
223
223
|
returns: "i32",
|
|
224
224
|
},
|
|
225
225
|
{ name: "wvSetFullscreen", symbol: "wv_set_fullscreen", params: ["i32", "i32"], returns: "i32" },
|
|
226
|
+
STR("wvSetMenu", "wv_set_menu"),
|
|
227
|
+
// Retained like wvOnInvoke: registered once, valid until the app exits.
|
|
228
|
+
// The clicked item's id rides in as a `string` param (format 3).
|
|
229
|
+
{
|
|
230
|
+
name: "wvOnMenu", symbol: "wv_on_menu",
|
|
231
|
+
params: [
|
|
232
|
+
"i32",
|
|
233
|
+
{ callback: { id: "menu", params: ["string", { context: "menu" }], returns: "i32", lifetime: "retained" } },
|
|
234
|
+
{ context: "menu" },
|
|
235
|
+
],
|
|
236
|
+
returns: "i32",
|
|
237
|
+
},
|
|
226
238
|
];
|
|
227
239
|
|
|
228
240
|
if (platform === "win32") {
|
package/package.json
CHANGED
package/runtime/janela.ts
CHANGED
|
@@ -42,6 +42,8 @@ declare function wvDialog(
|
|
|
42
42
|
filters: string,
|
|
43
43
|
): number;
|
|
44
44
|
declare function wvSetFullscreen(h: number, on: number): number;
|
|
45
|
+
declare function wvSetMenu(h: number, spec: string): number;
|
|
46
|
+
declare function wvOnMenu(h: number, cb: (id: string) => number): number;
|
|
45
47
|
|
|
46
48
|
const JOB_PENDING = 0;
|
|
47
49
|
const JOB_OK = 1;
|
|
@@ -90,6 +92,7 @@ export type {
|
|
|
90
92
|
DialogFilter,
|
|
91
93
|
Events,
|
|
92
94
|
FsCallback,
|
|
95
|
+
MenuEntry,
|
|
93
96
|
OpenDialogOptions,
|
|
94
97
|
SaveDialogOptions,
|
|
95
98
|
WindowConfig,
|
|
@@ -99,6 +102,7 @@ export type {
|
|
|
99
102
|
// A project's `import { defineCommands } from "janela/host"` is rewritten to
|
|
100
103
|
// this module by the CLI before scriptc sees it.
|
|
101
104
|
export { defineCommands, defineEvents } from "./types";
|
|
105
|
+
export { menuItem, menuSeparator, submenu } from "./types";
|
|
102
106
|
|
|
103
107
|
import type {
|
|
104
108
|
AsyncCommandHandler,
|
|
@@ -110,6 +114,7 @@ import type {
|
|
|
110
114
|
DialogFilter,
|
|
111
115
|
Events,
|
|
112
116
|
FsCallback,
|
|
117
|
+
MenuEntry,
|
|
113
118
|
OpenDialogOptions,
|
|
114
119
|
SaveDialogOptions,
|
|
115
120
|
WindowConfig,
|
|
@@ -156,6 +161,68 @@ const TIMER_JOBS = -1;
|
|
|
156
161
|
* interface (being signature-only) never is. A class receiver works even as a
|
|
157
162
|
* plain function parameter, which is what `setup(app)` is.
|
|
158
163
|
*/
|
|
164
|
+
// ---- menu flattening ---------------------------------------------------
|
|
165
|
+
//
|
|
166
|
+
// The shim renders menus but parses nothing structural: the tree is flattened
|
|
167
|
+
// here into one row per line with 0x1f between fields, which needs only a
|
|
168
|
+
// split on the native side. Keeping the parsing in TypeScript is the whole
|
|
169
|
+
// point — the native side stays a renderer.
|
|
170
|
+
|
|
171
|
+
// NSEventModifierFlags.
|
|
172
|
+
const MENU_SHIFT = 131072;
|
|
173
|
+
const MENU_CONTROL = 262144;
|
|
174
|
+
const MENU_OPTION = 524288;
|
|
175
|
+
const MENU_COMMAND = 1048576;
|
|
176
|
+
|
|
177
|
+
/** "CmdOrCtrl+Shift+O" -> "o<US>1179648". Unknown words are taken as the key. */
|
|
178
|
+
function parseAccel(accel: string): string {
|
|
179
|
+
const parts = accel.split("+");
|
|
180
|
+
let mods = 0;
|
|
181
|
+
let key = "";
|
|
182
|
+
for (let i = 0; i < parts.length; i++) {
|
|
183
|
+
const p = parts[i].toLowerCase();
|
|
184
|
+
if (p === "cmd" || p === "command" || p === "meta" || p === "super") {
|
|
185
|
+
mods = mods | MENU_COMMAND;
|
|
186
|
+
} else if (p === "cmdorctrl" || p === "commandorcontrol") {
|
|
187
|
+
// macOS is the only platform with custom menus so far, so this is
|
|
188
|
+
// Command here; the Windows and Linux renderers will read it as Control.
|
|
189
|
+
mods = mods | MENU_COMMAND;
|
|
190
|
+
} else if (p === "ctrl" || p === "control") {
|
|
191
|
+
mods = mods | MENU_CONTROL;
|
|
192
|
+
} else if (p === "alt" || p === "option") {
|
|
193
|
+
mods = mods | MENU_OPTION;
|
|
194
|
+
} else if (p === "shift") {
|
|
195
|
+
mods = mods | MENU_SHIFT;
|
|
196
|
+
} else if (p !== "") {
|
|
197
|
+
key = p;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return key + "\x1f" + mods;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 0x1f and newlines are the wire format's own delimiters. */
|
|
204
|
+
function menuSafe(v: string): string {
|
|
205
|
+
return v.split("\x1f").join(" ").split("\n").join(" ");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function flattenMenu(entries: MenuEntry[], rows: string[]): void {
|
|
209
|
+
for (let i = 0; i < entries.length; i++) {
|
|
210
|
+
const e = entries[i];
|
|
211
|
+
if (e.separator) {
|
|
212
|
+
rows.push("-");
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const label = menuSafe(e.label);
|
|
216
|
+
if (e.items.length > 0) {
|
|
217
|
+
rows.push("S\x1f" + label);
|
|
218
|
+
flattenMenu(e.items, rows);
|
|
219
|
+
rows.push("E");
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
rows.push("I\x1f" + menuSafe(e.id) + "\x1f" + label + "\x1f" + parseAccel(e.accel));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
159
226
|
export class JanelaAppImpl<
|
|
160
227
|
C extends CommandShapes = CommandShapes,
|
|
161
228
|
E = Record<string, unknown>,
|
|
@@ -509,6 +576,44 @@ export class JanelaAppImpl<
|
|
|
509
576
|
wvSetFullscreen(this.handle, on ? 1 : 0);
|
|
510
577
|
}
|
|
511
578
|
|
|
579
|
+
/**
|
|
580
|
+
* Put submenus in the application menu.
|
|
581
|
+
*
|
|
582
|
+
* They are ADDED to the standard ones rather than replacing them, so a
|
|
583
|
+
* custom menu can never cost the app Cmd+Q or Cmd+V — replacing the bar
|
|
584
|
+
* wholesale is what would. Calling it again replaces only what a previous
|
|
585
|
+
* call added, so the menu can shrink as well as grow.
|
|
586
|
+
*
|
|
587
|
+
* Clicks arrive on `onMenu` with the entry's `id`.
|
|
588
|
+
*
|
|
589
|
+
* ```ts
|
|
590
|
+
* app.setMenu([
|
|
591
|
+
* { label: "File", items: [
|
|
592
|
+
* { label: "Open…", id: "open", accel: "CmdOrCtrl+O" },
|
|
593
|
+
* { separator: true },
|
|
594
|
+
* { label: "Close", id: "close" },
|
|
595
|
+
* ]},
|
|
596
|
+
* ]);
|
|
597
|
+
* app.onMenu((id) => { if (id === "open") … });
|
|
598
|
+
* ```
|
|
599
|
+
*
|
|
600
|
+
* Returns false where custom menus are not supported yet — everything but
|
|
601
|
+
* macOS. The standard menu is unaffected either way.
|
|
602
|
+
*/
|
|
603
|
+
setMenu(entries: MenuEntry[]): boolean {
|
|
604
|
+
const rows: string[] = [];
|
|
605
|
+
flattenMenu(entries, rows);
|
|
606
|
+
return wvSetMenu(this.handle, rows.join("\n")) === 0;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Called with the `id` of the clicked menu entry. */
|
|
610
|
+
onMenu(cb: (id: string) => void): void {
|
|
611
|
+
wvOnMenu(this.handle, (id) => {
|
|
612
|
+
cb(id);
|
|
613
|
+
return 0;
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
512
617
|
/**
|
|
513
618
|
* Fire an event into the page; the payload is delivered as a value. Under a
|
|
514
619
|
* contract, the name must be declared and the payload must match its type.
|
package/runtime/types.ts
CHANGED
|
@@ -41,6 +41,55 @@ export type AsyncCommandHandler = (
|
|
|
41
41
|
*/
|
|
42
42
|
export type FsCallback = (err: string | null, text: string) => void;
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* One entry in the application menu.
|
|
46
|
+
*
|
|
47
|
+
* Every field is required rather than optional, and the entries are built with
|
|
48
|
+
* `submenu`, `menuItem` and `menuSeparator` rather than written as literals.
|
|
49
|
+
* That is a scriptc constraint made into a nicer API: a record whose fields are
|
|
50
|
+
* all optional infers as `{ label: string | undefined, … }`, and an array
|
|
51
|
+
* mixing a submenu, an item and a separator is a union that will not re-tag
|
|
52
|
+
* into it ("union types must match exactly", SC2003). The helpers each return
|
|
53
|
+
* the same total shape, so the array is homogeneous and the call site reads as
|
|
54
|
+
* a tree.
|
|
55
|
+
*
|
|
56
|
+
* ```ts
|
|
57
|
+
* app.setMenu([
|
|
58
|
+
* submenu("File", [
|
|
59
|
+
* menuItem("Open…", "open", "CmdOrCtrl+O"),
|
|
60
|
+
* menuSeparator(),
|
|
61
|
+
* ]),
|
|
62
|
+
* ]);
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export type MenuEntry = {
|
|
66
|
+
label: string;
|
|
67
|
+
/** Sent to `onMenu` when clicked. Empty for submenus and separators. */
|
|
68
|
+
id: string;
|
|
69
|
+
/**
|
|
70
|
+
* A shortcut like "CmdOrCtrl+O" or "CmdOrCtrl+Shift+S". Modifiers: Cmd,
|
|
71
|
+
* Ctrl, CmdOrCtrl, Alt/Option, Shift. Empty for none.
|
|
72
|
+
*/
|
|
73
|
+
accel: string;
|
|
74
|
+
separator: boolean;
|
|
75
|
+
/** Children. Empty for a leaf. */
|
|
76
|
+
items: MenuEntry[];
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** A clickable entry. `accel` is "" for no shortcut. */
|
|
80
|
+
export function menuItem(label: string, id: string, accel: string): MenuEntry {
|
|
81
|
+
return { label: label, id: id, accel: accel, separator: false, items: [] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A divider. */
|
|
85
|
+
export function menuSeparator(): MenuEntry {
|
|
86
|
+
return { label: "", id: "", accel: "", separator: true, items: [] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A submenu holding other entries. Nestable. */
|
|
90
|
+
export function submenu(label: string, items: MenuEntry[]): MenuEntry {
|
|
91
|
+
return { label: label, id: "", accel: "", separator: false, items: items };
|
|
92
|
+
}
|
|
44
93
|
/** A named group of extensions offered in a dialog's file-type popup. */
|
|
45
94
|
export interface DialogFilter {
|
|
46
95
|
name: string;
|
package/shim/wvshim.cc
CHANGED
|
@@ -75,6 +75,15 @@ struct App {
|
|
|
75
75
|
void *on_invoke_ctx = nullptr;
|
|
76
76
|
void (*on_timer)(int32_t, void *) = nullptr;
|
|
77
77
|
void *on_timer_ctx = nullptr;
|
|
78
|
+
int32_t (*on_menu)(const uint8_t *, size_t, void *) = nullptr;
|
|
79
|
+
void *on_menu_ctx = nullptr;
|
|
80
|
+
|
|
81
|
+
// Custom menu item ids, indexed by the NSMenuItem's tag. The menu bar is
|
|
82
|
+
// process-global on macOS, so only one app can own it — see g_menu_owner.
|
|
83
|
+
std::vector<std::string> menu_ids;
|
|
84
|
+
// How many submenus the standard bar installed, so setMenu appends after
|
|
85
|
+
// them and can remove only its own on a later call.
|
|
86
|
+
size_t std_menu_count = 0;
|
|
78
87
|
|
|
79
88
|
// Staging for the in-flight request.
|
|
80
89
|
std::string req; // JSON args array from JS
|
|
@@ -358,6 +367,284 @@ bool run_file_dialog(const DialogRequest &req, std::vector<std::string> &out,
|
|
|
358
367
|
|
|
359
368
|
#if defined(__APPLE__)
|
|
360
369
|
|
|
370
|
+
// A standard macOS main menu.
|
|
371
|
+
//
|
|
372
|
+
// On macOS a Command-key shortcut is a MENU key equivalent, not a
|
|
373
|
+
// window-manager gesture the way Alt+F4 is on Windows. With no main menu
|
|
374
|
+
// nothing claims Cmd+Q, Cmd+C, Cmd+V, Cmd+Z or Cmd+A, and a webview app simply
|
|
375
|
+
// appears to ignore them — the window had zero menu bars, which is exactly
|
|
376
|
+
// what that looks like from the outside.
|
|
377
|
+
//
|
|
378
|
+
// webview.h leaves this to the embedder on purpose: webview/webview#127 is
|
|
379
|
+
// still open, and #237, which added precisely this Edit menu, was closed with
|
|
380
|
+
// "anyone who is impatient can always take this code on their own". Tauri does
|
|
381
|
+
// the same thing one layer up, through muda.
|
|
382
|
+
//
|
|
383
|
+
// Every item here is a STANDARD AppKit selector travelling up the responder
|
|
384
|
+
// chain, so there is nothing to call back into TypeScript for and no new FFI
|
|
385
|
+
// surface. WKWebView answers the editing ones itself. Custom menus — a
|
|
386
|
+
// declarative table passed down from the host — are a separate feature.
|
|
387
|
+
static void install_main_menu() {
|
|
388
|
+
using namespace webview::detail;
|
|
389
|
+
objc::autoreleasepool arp;
|
|
390
|
+
|
|
391
|
+
id app = objc::msg_send<id>(objc::get_class("NSApplication"),
|
|
392
|
+
objc::selector("sharedApplication"));
|
|
393
|
+
if (!app) return;
|
|
394
|
+
// Idempotent: an embedder that already installed a menu keeps it.
|
|
395
|
+
if (objc::msg_send<id>(app, objc::selector("mainMenu"))) return;
|
|
396
|
+
|
|
397
|
+
// NSEventModifierFlags. The default for a key equivalent is Command alone,
|
|
398
|
+
// so only the combinations need naming.
|
|
399
|
+
const NSUInteger kShift = 1UL << 17;
|
|
400
|
+
const NSUInteger kControl = 1UL << 18;
|
|
401
|
+
const NSUInteger kOption = 1UL << 19;
|
|
402
|
+
const NSUInteger kCommand = 1UL << 20;
|
|
403
|
+
|
|
404
|
+
auto str = [](const std::string &v) {
|
|
405
|
+
return cocoa::NSString_stringWithUTF8String(v);
|
|
406
|
+
};
|
|
407
|
+
auto alloc_init = [](const char *cls) {
|
|
408
|
+
return objc::msg_send<id>(
|
|
409
|
+
objc::msg_send<id>(objc::get_class(cls), objc::selector("alloc")),
|
|
410
|
+
objc::selector("init"));
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// AppKit labels the first submenu from the bundle, not from this title, but
|
|
414
|
+
// "About <name>" and "Quit <name>" are ours to spell.
|
|
415
|
+
id process = objc::msg_send<id>(objc::get_class("NSProcessInfo"),
|
|
416
|
+
objc::selector("processInfo"));
|
|
417
|
+
const char *raw =
|
|
418
|
+
process ? objc::msg_send<const char *>(
|
|
419
|
+
objc::msg_send<id>(process, objc::selector("processName")),
|
|
420
|
+
objc::selector("UTF8String"))
|
|
421
|
+
: nullptr;
|
|
422
|
+
std::string name = raw ? raw : "App";
|
|
423
|
+
|
|
424
|
+
id menubar = alloc_init("NSMenu");
|
|
425
|
+
|
|
426
|
+
auto submenu = [&](const std::string &title) {
|
|
427
|
+
id holder = alloc_init("NSMenuItem");
|
|
428
|
+
id menu = objc::msg_send<id>(
|
|
429
|
+
objc::msg_send<id>(objc::get_class("NSMenu"), objc::selector("alloc")),
|
|
430
|
+
objc::selector("initWithTitle:"), str(title));
|
|
431
|
+
objc::msg_send<void>(holder, objc::selector("setSubmenu:"), menu);
|
|
432
|
+
objc::msg_send<void>(menubar, objc::selector("addItem:"), holder);
|
|
433
|
+
return menu;
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
auto item = [&](id menu, const std::string &title, const char *sel,
|
|
437
|
+
const std::string &key, NSUInteger mods) {
|
|
438
|
+
id it = objc::msg_send<id>(
|
|
439
|
+
objc::msg_send<id>(objc::get_class("NSMenuItem"),
|
|
440
|
+
objc::selector("alloc")),
|
|
441
|
+
objc::selector("initWithTitle:action:keyEquivalent:"), str(title),
|
|
442
|
+
sel ? objc::selector(sel) : nullptr, str(key));
|
|
443
|
+
if (mods) {
|
|
444
|
+
objc::msg_send<void>(
|
|
445
|
+
it, objc::selector("setKeyEquivalentModifierMask:"), mods);
|
|
446
|
+
}
|
|
447
|
+
objc::msg_send<void>(menu, objc::selector("addItem:"), it);
|
|
448
|
+
};
|
|
449
|
+
auto separator = [&](id menu) {
|
|
450
|
+
objc::msg_send<void>(menu, objc::selector("addItem:"),
|
|
451
|
+
objc::msg_send<id>(objc::get_class("NSMenuItem"),
|
|
452
|
+
objc::selector("separatorItem")));
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
id app_menu = submenu(name);
|
|
456
|
+
item(app_menu, "About " + name, "orderFrontStandardAboutPanel:", "", 0);
|
|
457
|
+
separator(app_menu);
|
|
458
|
+
item(app_menu, "Hide " + name, "hide:", "h", 0);
|
|
459
|
+
item(app_menu, "Hide Others", "hideOtherApplications:", "h",
|
|
460
|
+
kOption | kCommand);
|
|
461
|
+
item(app_menu, "Show All", "unhideAllApplications:", "", 0);
|
|
462
|
+
separator(app_menu);
|
|
463
|
+
// performClose:, not terminate: — measured, not assumed. Both quit and
|
|
464
|
+
// both exit 0, but terminate: exits the process itself, so the host never
|
|
465
|
+
// returns from wv_run and anything after app.run() is skipped;
|
|
466
|
+
// performClose: goes through the same window-close path the red button
|
|
467
|
+
// uses, the run loop unwinds, and the host prints its own "run returned 0".
|
|
468
|
+
// One shutdown path instead of two. muda picks terminate: because Tauri's
|
|
469
|
+
// app logic is native and multi-window; janela is single-window, so closing
|
|
470
|
+
// the window IS quitting. If janela ever grows multiple windows this has to
|
|
471
|
+
// become a real Quit that closes all of them.
|
|
472
|
+
//
|
|
473
|
+
// (An earlier note here claimed performClose: did not fire at all. That was
|
|
474
|
+
// a bad test: posted key events go to the FRONTMOST app, and the app was not
|
|
475
|
+
// active. With it activated first, both actions work.)
|
|
476
|
+
item(app_menu, "Quit " + name, "performClose:", "q", 0);
|
|
477
|
+
|
|
478
|
+
id edit = submenu("Edit");
|
|
479
|
+
item(edit, "Undo", "undo:", "z", 0);
|
|
480
|
+
item(edit, "Redo", "redo:", "z", kShift | kCommand);
|
|
481
|
+
separator(edit);
|
|
482
|
+
item(edit, "Cut", "cut:", "x", 0);
|
|
483
|
+
item(edit, "Copy", "copy:", "c", 0);
|
|
484
|
+
item(edit, "Paste", "paste:", "v", 0);
|
|
485
|
+
item(edit, "Select All", "selectAll:", "a", 0);
|
|
486
|
+
|
|
487
|
+
id view = submenu("View");
|
|
488
|
+
item(view, "Enter Full Screen", "toggleFullScreen:", "f",
|
|
489
|
+
kControl | kCommand);
|
|
490
|
+
|
|
491
|
+
id window = submenu("Window");
|
|
492
|
+
item(window, "Minimize", "performMiniaturize:", "m", 0);
|
|
493
|
+
item(window, "Close", "performClose:", "w", 0);
|
|
494
|
+
|
|
495
|
+
objc::msg_send<void>(app, objc::selector("setMainMenu:"), menubar);
|
|
496
|
+
// Lets AppKit add the standard Window-menu bookkeeping (window list,
|
|
497
|
+
// Bring All to Front).
|
|
498
|
+
objc::msg_send<void>(app, objc::selector("setWindowsMenu:"), window);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// How many submenus the standard bar has; custom ones are appended after these
|
|
502
|
+
// and only those are removed on a later setMenu.
|
|
503
|
+
static size_t standard_menu_count() {
|
|
504
|
+
using namespace webview::detail;
|
|
505
|
+
objc::autoreleasepool arp;
|
|
506
|
+
id app = objc::msg_send<id>(objc::get_class("NSApplication"),
|
|
507
|
+
objc::selector("sharedApplication"));
|
|
508
|
+
id menubar = app ? objc::msg_send<id>(app, objc::selector("mainMenu")) : nullptr;
|
|
509
|
+
if (!menubar) return 0;
|
|
510
|
+
return static_cast<size_t>(
|
|
511
|
+
objc::msg_send<NSUInteger>(menubar, objc::selector("numberOfItems")));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---- custom menus ------------------------------------------------------
|
|
515
|
+
//
|
|
516
|
+
// The host describes its menus declaratively in TypeScript and this renders
|
|
517
|
+
// them. Nothing here parses JSON: the runtime flattens the tree into one row
|
|
518
|
+
// per line, fields separated by 0x1f, which needs a split and nothing more.
|
|
519
|
+
//
|
|
520
|
+
// S<US>Label open a submenu
|
|
521
|
+
// E close it
|
|
522
|
+
// I<US>tag<US>Label<US>key<US>mods an item
|
|
523
|
+
// - a separator
|
|
524
|
+
//
|
|
525
|
+
// A click sends the item's tag back, which indexes App::menu_ids, and the id
|
|
526
|
+
// string goes up to TypeScript on the retained on_menu callback — the same
|
|
527
|
+
// shape as on_invoke. The menu bar is process-global on macOS, so exactly one
|
|
528
|
+
// app owns it at a time.
|
|
529
|
+
static int32_t g_menu_owner = -1;
|
|
530
|
+
|
|
531
|
+
static void menu_clicked(long tag) {
|
|
532
|
+
App *a = g_menu_owner >= 0 ? app_at(g_menu_owner) : nullptr;
|
|
533
|
+
if (!a || !a->on_menu) return;
|
|
534
|
+
if (tag < 0 || static_cast<size_t>(tag) >= a->menu_ids.size()) return;
|
|
535
|
+
const std::string &id = a->menu_ids[static_cast<size_t>(tag)];
|
|
536
|
+
a->on_menu(reinterpret_cast<const uint8_t *>(id.data()), id.size(),
|
|
537
|
+
a->on_menu_ctx);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// One shared target for every custom item; the tag says which one fired.
|
|
541
|
+
static id menu_target() {
|
|
542
|
+
static id instance = nullptr;
|
|
543
|
+
if (instance) return instance;
|
|
544
|
+
constexpr auto class_name = "JanelaMenuTarget";
|
|
545
|
+
// Registering the same class twice crashes, and this runs once per process
|
|
546
|
+
// anyway — the lookup keeps a reload honest.
|
|
547
|
+
Class cls = objc_lookUpClass(class_name);
|
|
548
|
+
if (!cls) {
|
|
549
|
+
cls = objc_allocateClassPair(
|
|
550
|
+
webview::detail::objc::get_class("NSObject"), class_name, 0);
|
|
551
|
+
class_addMethod(cls, webview::detail::objc::selector("janelaMenuAction:"),
|
|
552
|
+
(IMP)(+[](id, SEL, id sender) {
|
|
553
|
+
menu_clicked(webview::detail::objc::msg_send<long>(
|
|
554
|
+
sender, webview::detail::objc::selector("tag")));
|
|
555
|
+
}),
|
|
556
|
+
"v@:@");
|
|
557
|
+
objc_registerClassPair(cls);
|
|
558
|
+
}
|
|
559
|
+
instance = webview::detail::objc::msg_send<id>(
|
|
560
|
+
webview::detail::objc::msg_send<id>((id)cls,
|
|
561
|
+
webview::detail::objc::selector("alloc")),
|
|
562
|
+
webview::detail::objc::selector("init"));
|
|
563
|
+
return instance;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Appends the host's submenus to the standard menu bar rather than replacing
|
|
567
|
+
// it: a custom menu must not be able to cost the app Cmd+Q and Cmd+V, which is
|
|
568
|
+
// what replacing the bar wholesale would do.
|
|
569
|
+
static int32_t apply_custom_menu(App *a, const std::string &spec) {
|
|
570
|
+
using namespace webview::detail;
|
|
571
|
+
objc::autoreleasepool arp;
|
|
572
|
+
|
|
573
|
+
id app = objc::msg_send<id>(objc::get_class("NSApplication"),
|
|
574
|
+
objc::selector("sharedApplication"));
|
|
575
|
+
id menubar = objc::msg_send<id>(app, objc::selector("mainMenu"));
|
|
576
|
+
if (!menubar) return -1;
|
|
577
|
+
|
|
578
|
+
// Drop whatever a previous call added, so setMenu is idempotent and can
|
|
579
|
+
// shrink the bar as well as grow it.
|
|
580
|
+
for (size_t n = objc::msg_send<NSUInteger>(menubar,
|
|
581
|
+
objc::selector("numberOfItems"));
|
|
582
|
+
n > a->std_menu_count; n--) {
|
|
583
|
+
objc::msg_send<void>(menubar, objc::selector("removeItemAtIndex:"),
|
|
584
|
+
static_cast<NSInteger>(n - 1));
|
|
585
|
+
}
|
|
586
|
+
a->menu_ids.clear();
|
|
587
|
+
|
|
588
|
+
std::vector<id> stack;
|
|
589
|
+
stack.push_back(menubar);
|
|
590
|
+
|
|
591
|
+
for (const std::string &line : split_on(spec, '\n')) {
|
|
592
|
+
if (line.empty()) continue;
|
|
593
|
+
std::vector<std::string> f = split_on(line, '\x1f');
|
|
594
|
+
const std::string &kind = f[0];
|
|
595
|
+
|
|
596
|
+
if (kind == "S" && f.size() >= 2) {
|
|
597
|
+
id holder = objc::msg_send<id>(
|
|
598
|
+
objc::msg_send<id>(objc::get_class("NSMenuItem"),
|
|
599
|
+
objc::selector("alloc")),
|
|
600
|
+
objc::selector("init"));
|
|
601
|
+
id menu = objc::msg_send<id>(
|
|
602
|
+
objc::msg_send<id>(objc::get_class("NSMenu"),
|
|
603
|
+
objc::selector("alloc")),
|
|
604
|
+
objc::selector("initWithTitle:"),
|
|
605
|
+
cocoa::NSString_stringWithUTF8String(f[1]));
|
|
606
|
+
objc::msg_send<void>(holder, objc::selector("setTitle:"),
|
|
607
|
+
cocoa::NSString_stringWithUTF8String(f[1]));
|
|
608
|
+
objc::msg_send<void>(holder, objc::selector("setSubmenu:"), menu);
|
|
609
|
+
objc::msg_send<void>(stack.back(), objc::selector("addItem:"), holder);
|
|
610
|
+
stack.push_back(menu);
|
|
611
|
+
} else if (kind == "E") {
|
|
612
|
+
if (stack.size() > 1) stack.pop_back();
|
|
613
|
+
} else if (kind == "-") {
|
|
614
|
+
if (stack.size() > 1) {
|
|
615
|
+
objc::msg_send<void>(
|
|
616
|
+
stack.back(), objc::selector("addItem:"),
|
|
617
|
+
objc::msg_send<id>(objc::get_class("NSMenuItem"),
|
|
618
|
+
objc::selector("separatorItem")));
|
|
619
|
+
}
|
|
620
|
+
} else if (kind == "I" && f.size() >= 5 && stack.size() > 1) {
|
|
621
|
+
a->menu_ids.push_back(f[1]);
|
|
622
|
+
long tag = static_cast<long>(a->menu_ids.size()) - 1;
|
|
623
|
+
id it = objc::msg_send<id>(
|
|
624
|
+
objc::msg_send<id>(objc::get_class("NSMenuItem"),
|
|
625
|
+
objc::selector("alloc")),
|
|
626
|
+
objc::selector("initWithTitle:action:keyEquivalent:"),
|
|
627
|
+
cocoa::NSString_stringWithUTF8String(f[2]),
|
|
628
|
+
objc::selector("janelaMenuAction:"),
|
|
629
|
+
cocoa::NSString_stringWithUTF8String(f[3]));
|
|
630
|
+
objc::msg_send<void>(it, objc::selector("setTarget:"), menu_target());
|
|
631
|
+
objc::msg_send<void>(it, objc::selector("setTag:"), tag);
|
|
632
|
+
// Always set the mask, including 0: AppKit's default for a key
|
|
633
|
+
// equivalent is Command, so leaving it alone would turn an accelerator
|
|
634
|
+
// with no modifiers into a Command shortcut.
|
|
635
|
+
NSUInteger mods = static_cast<NSUInteger>(strtoul(f[4].c_str(), nullptr, 10));
|
|
636
|
+
objc::msg_send<void>(it, objc::selector("setKeyEquivalentModifierMask:"),
|
|
637
|
+
mods);
|
|
638
|
+
objc::msg_send<void>(stack.back(), objc::selector("addItem:"), it);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
g_menu_owner = -1;
|
|
642
|
+
for (int32_t i = 0; i < 8; i++) {
|
|
643
|
+
if (app_at(i) == a) { g_menu_owner = i; break; }
|
|
644
|
+
}
|
|
645
|
+
return 0;
|
|
646
|
+
}
|
|
647
|
+
|
|
361
648
|
bool run_file_dialog(const DialogRequest &req, std::vector<std::string> &out,
|
|
362
649
|
std::string &error) {
|
|
363
650
|
(void)error;
|
|
@@ -608,6 +895,22 @@ bool run_file_dialog(const DialogRequest &req, std::vector<std::string> &out,
|
|
|
608
895
|
|
|
609
896
|
#endif
|
|
610
897
|
|
|
898
|
+
#if !defined(__APPLE__)
|
|
899
|
+
// Windows and Linux need no menu for these shortcuts: Alt+F4 is a
|
|
900
|
+
// window-manager message the win32 backend already answers as WM_CLOSE, and
|
|
901
|
+
// the editing keys are handled inside WebView2 and WebKitGTK. A menu here
|
|
902
|
+
// would be a feature, not a fix.
|
|
903
|
+
static void install_main_menu() {}
|
|
904
|
+
static size_t standard_menu_count() { return 0; }
|
|
905
|
+
|
|
906
|
+
// Custom menus are macOS-only for now. Two things have to be solved first:
|
|
907
|
+
// webview.h's win32 loop never calls TranslateAcceleratorW, without which
|
|
908
|
+
// accelerators do not fire, and the GTK backend keeps both GTK 3 and GTK 4
|
|
909
|
+
// alive where GTK 4 removed GtkMenuBar. Reporting -1 lets the host say
|
|
910
|
+
// "unsupported here" rather than pretend it worked.
|
|
911
|
+
static int32_t apply_custom_menu(App *, const std::string &) { return -1; }
|
|
912
|
+
#endif
|
|
913
|
+
|
|
611
914
|
// Runs on the UI thread with no TS frame beneath it — see the note on the job
|
|
612
915
|
// pool above for why that matters.
|
|
613
916
|
void dialog_on_ui_thread(webview_t, void *arg) {
|
|
@@ -728,6 +1031,10 @@ int32_t wv_create(int32_t debug) {
|
|
|
728
1031
|
if (g_apps[i].used) continue;
|
|
729
1032
|
webview_t w = webview_create(debug, nullptr);
|
|
730
1033
|
if (!w) return -1;
|
|
1034
|
+
// After webview_create, which is what brings NSApplication into being.
|
|
1035
|
+
// Both are no-ops off macOS.
|
|
1036
|
+
install_main_menu();
|
|
1037
|
+
g_apps[i].std_menu_count = standard_menu_count();
|
|
731
1038
|
// Field-wise reset: App holds a thread and atomics, so it is not
|
|
732
1039
|
// copy-assignable from a temporary.
|
|
733
1040
|
g_apps[i].binds.clear();
|
|
@@ -735,6 +1042,9 @@ int32_t wv_create(int32_t debug) {
|
|
|
735
1042
|
g_apps[i].on_invoke_ctx = nullptr;
|
|
736
1043
|
g_apps[i].on_timer = nullptr;
|
|
737
1044
|
g_apps[i].on_timer_ctx = nullptr;
|
|
1045
|
+
g_apps[i].on_menu = nullptr;
|
|
1046
|
+
g_apps[i].on_menu_ctx = nullptr;
|
|
1047
|
+
g_apps[i].menu_ids.clear();
|
|
738
1048
|
g_apps[i].req.clear();
|
|
739
1049
|
g_apps[i].cur_id.clear();
|
|
740
1050
|
g_apps[i].reply.clear();
|
|
@@ -1037,6 +1347,27 @@ int32_t wv_dialog(int32_t h, int32_t kind, int32_t flags, const uint8_t *tp,
|
|
|
1037
1347
|
|
|
1038
1348
|
// ---- window control ---------------------------------------------------------
|
|
1039
1349
|
|
|
1350
|
+
// Renders the host's declarative menu. The spec is one row per line with 0x1f
|
|
1351
|
+
// between fields; the runtime flattens the tree, so nothing here parses JSON.
|
|
1352
|
+
// Returns -1 where custom menus are not supported yet (everything but macOS),
|
|
1353
|
+
// which the host reports rather than swallowing.
|
|
1354
|
+
int32_t wv_set_menu(int32_t h, const uint8_t *p, size_t n) {
|
|
1355
|
+
App *a = app_at(h);
|
|
1356
|
+
if (!a) return -1;
|
|
1357
|
+
return apply_custom_menu(a, to_str(p, n));
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Retained, like wv_on_invoke: registered once and valid until the app exits.
|
|
1361
|
+
int32_t wv_on_menu(int32_t h,
|
|
1362
|
+
int32_t (*cb)(const uint8_t *, size_t, void *),
|
|
1363
|
+
void *ctx) {
|
|
1364
|
+
App *a = app_at(h);
|
|
1365
|
+
if (!a) return -1;
|
|
1366
|
+
a->on_menu = cb;
|
|
1367
|
+
a->on_menu_ctx = ctx;
|
|
1368
|
+
return 0;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1040
1371
|
int32_t wv_set_fullscreen(int32_t h, int32_t on) {
|
|
1041
1372
|
App *a = app_at(h);
|
|
1042
1373
|
if (!a) return -1;
|