1 /**
2  * Menu system: menu bars, popup menus, menu items and keyboard accelerators.
3  *
4  * A `MenuItem` is a lightweight value describing one command (id, label,
5  * optional accelerator such as `"Ctrl+N"`, and an `onClicked` event). `Menu`
6  * wraps a native popup `HMENU`; `MenuBar` wraps a window menu `HMENU` and is
7  * attached to a `Window` via `Window.setMenuBar`.
8  *
9  * Menu and accelerator commands both arrive as `WM_COMMAND` with a null
10  * `lParam`; `Window` routes them here through `dispatchMenuCommand`, which fires
11  * the matching item's `onClicked`. Accelerators are collected from every item
12  * carrying an accelerator string into a single `HACCEL` table that the message
13  * loop feeds to `TranslateAccelerator`.
14  *
15  * The native menus provide MSAA accessibility for free: screen readers announce
16  * menu names, item labels, accelerator text, and checked/disabled state.
17  */
18 module deft.menu;
19 
20 version (Windows):
21 
22 import core.sys.windows.windows;
23 
24 import deft.events;
25 import deft.util.strings;
26 import deft.widget : Widget;
27 
28 /// The kind of a menu item.
29 enum MenuItemKind
30 {
31 	/// A normal command item.
32 	normal,
33 	/// A horizontal separator line (no command).
34 	separator,
35 	/// A command item with a check mark that can be toggled.
36 	checkable,
37 }
38 
39 /**
40  * One menu command.
41  *
42  * Construct with an id (pass `0` to have one generated), a label (an `&`
43  * marks the keyboard mnemonic), and an optional accelerator string. Attach a
44  * handler to `onClicked` before appending the item to a `Menu`, or look the
45  * stored item up later with `Menu.findItem`.
46  */
47 struct MenuItem
48 {
49 	/// Command identifier. Unique within the application's menus.
50 	int id;
51 
52 	/// Display label, with `&` marking the mnemonic letter.
53 	string label;
54 
55 	/// Accelerator description, for example `"Ctrl+Shift+N"`; empty for none.
56 	string accelerator;
57 
58 	/// Whether the item is a normal command, a separator, or checkable.
59 	MenuItemKind kind = MenuItemKind.normal;
60 
61 	/// Current checked state (only meaningful for `MenuItemKind.checkable`).
62 	bool checked;
63 
64 	/// Whether the item can be invoked.
65 	bool enabled = true;
66 
67 	/// Fired when the item is chosen (by mouse, mnemonic, or accelerator).
68 	Event!() onClicked;
69 
70 	/**
71 	 * Build a menu item.
72 	 *
73 	 * Params:
74 	 *   id          = command id, or `0` to auto-generate a unique one.
75 	 *   label       = display label (`&` marks the mnemonic).
76 	 *   accelerator = accelerator string such as `"Ctrl+N"`, or empty.
77 	 *   kind        = item kind.
78 	 */
79 	this(int id, string label, string accelerator = "",
80 		MenuItemKind kind = MenuItemKind.normal)
81 	{
82 		this.id = id;
83 		this.label = label;
84 		this.accelerator = accelerator;
85 		this.kind = kind;
86 	}
87 }
88 
89 private __gshared int g_nextMenuId = 30_000;
90 
91 /// Generate a unique menu command id (used when an item is appended with id 0).
92 int nextMenuId()
93 {
94 	return g_nextMenuId++;
95 }
96 
97 /// id → stored item registry, so `WM_COMMAND` can find the originating item.
98 private __gshared MenuItem*[int] g_menuCommands;
99 
100 /**
101  * Dispatch a menu or accelerator command to its item's `onClicked`.
102  *
103  * Returns `true` if an enabled item with the given id was found and fired.
104  */
105 bool dispatchMenuCommand(int id)
106 {
107 	if (auto item = id in g_menuCommands)
108 	{
109 		if ((*item).enabled)
110 		{
111 			(*item).onClicked.fire();
112 			return true;
113 		}
114 	}
115 	return false;
116 }
117 
118 private enum string acceleratorSeparator = "\t";
119 
120 /// The label as shown natively: mnemonic label plus right-aligned accelerator.
121 private string labelWithAccelerator(ref MenuItem item)
122 {
123 	if (item.accelerator.length == 0)
124 		return item.label;
125 	return item.label ~ acceleratorSeparator ~ item.accelerator;
126 }
127 
128 /**
129  * A popup menu — a list of items, separators and submenus.
130  *
131  * Wraps an `HMENU` from `CreatePopupMenu`. Use as a window menu's child (via
132  * `MenuBar.append`), as a submenu (via `appendSubmenu`), or as a standalone
133  * context menu (via `showPopupMenu`).
134  */
135 class Menu
136 {
137 	private HMENU handle_;
138 	private MenuItem*[] items_;
139 	private Menu[] submenus_;
140 	private bool ownsHandle_ = true;
141 	private bool disposed_;
142 
143 	/// Create an empty popup menu.
144 	this()
145 	{
146 		handle_ = CreatePopupMenu();
147 	}
148 
149 	/// The native menu handle.
150 	HMENU handle() @safe pure nothrow @nogc
151 	{
152 		return handle_;
153 	}
154 
155 	/**
156 	 * Append a command item.
157 	 *
158 	 * If `item.id` is 0 a unique id is generated. A heap copy of the item is
159 	 * stored (so its `onClicked` survives) and returned for later wiring.
160 	 */
161 	MenuItem* append(MenuItem item)
162 	{
163 		if (item.id == 0)
164 			item.id = nextMenuId();
165 
166 		auto stored = new MenuItem;
167 		*stored = item;
168 		items_ ~= stored;
169 		g_menuCommands[stored.id] = stored;
170 
171 		UINT flags = MF_STRING;
172 		if (stored.checked)
173 			flags |= MF_CHECKED;
174 		if (!stored.enabled)
175 			flags |= MF_GRAYED;
176 
177 		AppendMenuW(handle_, flags, cast(UINT_PTR) stored.id,
178 			labelWithAccelerator(*stored).toWStringz);
179 		return stored;
180 	}
181 
182 	/// Append a separator line.
183 	void appendSeparator()
184 	{
185 		AppendMenuW(handle_, MF_SEPARATOR, 0, null);
186 	}
187 
188 	/// Append a submenu under `label`.
189 	void appendSubmenu(Menu submenu, string label)
190 	{
191 		submenus_ ~= submenu;
192 		// The parent now owns the submenu's HMENU: DestroyMenu on this menu frees
193 		// its submenus recursively, so the submenu must not free its own handle.
194 		submenu.ownsHandle_ = false;
195 		AppendMenuW(handle_, MF_POPUP, cast(UINT_PTR) submenu.handle_,
196 			label.toWStringz);
197 	}
198 
199 	/// Find a stored item by id, searching this menu and its submenus.
200 	MenuItem* findItem(int id)
201 	{
202 		foreach (it; items_)
203 			if (it.id == id)
204 				return it;
205 		foreach (sub; submenus_)
206 			if (auto found = sub.findItem(id))
207 				return found;
208 		return null;
209 	}
210 
211 	/// Set (or clear) the check mark on item `id`.
212 	void setChecked(int id, bool checked)
213 	{
214 		if (auto it = findItem(id))
215 			it.checked = checked;
216 		CheckMenuItem(handle_, id,
217 			MF_BYCOMMAND | (checked ? MF_CHECKED : MF_UNCHECKED));
218 	}
219 
220 	/// Enable or disable item `id`.
221 	void setEnabled(int id, bool enabled)
222 	{
223 		if (auto it = findItem(id))
224 			it.enabled = enabled;
225 		EnableMenuItem(handle_, id,
226 			MF_BYCOMMAND | (enabled ? MF_ENABLED : MF_GRAYED));
227 	}
228 
229 	/**
230 	 * Change the visible label of item `id` (its accelerator text is preserved),
231 	 * searching this menu and its submenus. Returns `true` if the item was found.
232 	 * Useful for retranslating menus when the UI language changes at runtime.
233 	 */
234 	bool setItemText(int id, string label)
235 	{
236 		foreach (it; items_)
237 			if (it.id == id)
238 			{
239 				it.label = label;
240 				ModifyMenuW(handle_, id, MF_BYCOMMAND | MF_STRING,
241 					cast(UINT_PTR) id, labelWithAccelerator(*it).toWStringz);
242 				return true;
243 			}
244 		foreach (sub; submenus_)
245 			if (sub.setItemText(id, label))
246 				return true;
247 		return false;
248 	}
249 
250 	/// Append every accelerator-bearing item in this menu tree to `accels`.
251 	private void collectAccelerators(ref ACCEL[] accels)
252 	{
253 		foreach (it; items_)
254 		{
255 			auto parsed = parseAccelerator(it.accelerator);
256 			if (parsed.valid)
257 			{
258 				ACCEL a;
259 				a.fVirt = cast(BYTE)(parsed.fVirt | FVIRTKEY);
260 				a.key = parsed.key;
261 				a.cmd = cast(WORD) it.id;
262 				accels ~= a;
263 			}
264 		}
265 		foreach (sub; submenus_)
266 			sub.collectAccelerators(accels);
267 	}
268 
269 	/// Remove this menu's (and its submenus') items from the command registry.
270 	private void unregisterCommands()
271 	{
272 		foreach (it; items_)
273 			g_menuCommands.remove(it.id);
274 		foreach (sub; submenus_)
275 			sub.unregisterCommands();
276 	}
277 
278 	/**
279 	 * Release the menu's native resources: drop its items from the command
280 	 * registry and destroy its `HMENU` (which frees any submenu handles too).
281 	 *
282 	 * Call this on a standalone context menu (one shown with `showPopupMenu`) when
283 	 * you are finished with it — an app that rebuilds context menus per invocation
284 	 * would otherwise leak an `HMENU` and command-registry entries each time. A
285 	 * menu attached to a `MenuBar` or as a submenu is owned by its parent and
286 	 * freed when the parent is disposed; calling `dispose` on it is still safe.
287 	 * Idempotent.
288 	 */
289 	void dispose()
290 	{
291 		if (disposed_)
292 			return;
293 		disposed_ = true;
294 
295 		unregisterCommands();
296 		if (ownsHandle_ && handle_ !is null)
297 			DestroyMenu(handle_);
298 		handle_ = null;
299 	}
300 }
301 
302 /**
303  * A window menu bar — the horizontal strip of top-level menus.
304  *
305  * Wraps an `HMENU` from `CreateMenu`. Build it with `append`, then attach it to
306  * a window with `Window.setMenuBar`, which also installs its accelerator table.
307  */
308 class MenuBar
309 {
310 	private HMENU handle_;
311 	private Menu[] menus_;
312 	private bool disposed_;
313 
314 	/// Create an empty menu bar.
315 	this()
316 	{
317 		handle_ = CreateMenu();
318 	}
319 
320 	/// The native menu handle.
321 	HMENU handle() @safe pure nothrow @nogc
322 	{
323 		return handle_;
324 	}
325 
326 	/// Append a top-level menu under `label`.
327 	void append(Menu menu, string label)
328 	{
329 		menus_ ~= menu;
330 		// The bar owns the menu's HMENU now (DestroyMenu on the bar frees it).
331 		menu.ownsHandle_ = false;
332 		AppendMenuW(handle_, MF_POPUP, cast(UINT_PTR) menu.handle,
333 			label.toWStringz);
334 	}
335 
336 	/// Find a stored item by id across every menu in the bar.
337 	MenuItem* findItem(int id)
338 	{
339 		foreach (m; menus_)
340 			if (auto found = m.findItem(id))
341 				return found;
342 		return null;
343 	}
344 
345 	/// Set (or clear) the check mark on item `id`.
346 	void setChecked(int id, bool checked)
347 	{
348 		foreach (m; menus_)
349 			if (m.findItem(id) !is null)
350 			{
351 				m.setChecked(id, checked);
352 				return;
353 			}
354 	}
355 
356 	/// Enable or disable item `id`.
357 	void setEnabled(int id, bool enabled)
358 	{
359 		foreach (m; menus_)
360 			if (m.findItem(id) !is null)
361 			{
362 				m.setEnabled(id, enabled);
363 				return;
364 			}
365 	}
366 
367 	/**
368 	 * Change the label of item `id` anywhere in the bar (accelerator preserved).
369 	 * Call `DrawMenuBar(window.handle)` afterward if a top-level item changed.
370 	 */
371 	void setItemText(int id, string label)
372 	{
373 		foreach (m; menus_)
374 			if (m.setItemText(id, label))
375 				return;
376 	}
377 
378 	/**
379 	 * Change the title of the top-level menu at `index` (e.g. retranslating
380 	 * "File"/"Edit"). Call `DrawMenuBar(window.handle)` afterward to repaint.
381 	 */
382 	void setMenuTitle(int index, string label)
383 	{
384 		if (index >= 0 && index < menus_.length)
385 			ModifyMenuW(handle_, index, MF_BYPOSITION | MF_POPUP,
386 				cast(UINT_PTR) menus_[index].handle, label.toWStringz);
387 	}
388 
389 	/**
390 	 * Build an accelerator table from every accelerator-bearing item in the bar.
391 	 * Returns null when there are no accelerators.
392 	 */
393 	HACCEL buildAcceleratorTable()
394 	{
395 		ACCEL[] accels;
396 		foreach (m; menus_)
397 			m.collectAccelerators(accels);
398 		if (accels.length == 0)
399 			return null;
400 		return CreateAcceleratorTableW(accels.ptr, cast(int) accels.length);
401 	}
402 
403 	/**
404 	 * Release the menu bar's native resources: drop every item from the command
405 	 * registry and destroy the bar's `HMENU` (which frees its menus' handles too).
406 	 *
407 	 * Call this only when the bar is no longer attached to a live window — a menu
408 	 * assigned to a window is destroyed automatically when the window is destroyed,
409 	 * so disposing it again would be a double free. Use it when you build a bar you
410 	 * never attach, or replace a window's menu bar at runtime. Idempotent.
411 	 */
412 	void dispose()
413 	{
414 		if (disposed_)
415 			return;
416 		disposed_ = true;
417 
418 		foreach (m; menus_)
419 			m.unregisterCommands();
420 		if (handle_ !is null)
421 			DestroyMenu(handle_);
422 		handle_ = null;
423 	}
424 }
425 
426 /**
427  * Show `menu` as a context menu owned by `parent`.
428  *
429  * `x`/`y` are screen coordinates; pass `-1, -1` to position the menu at the
430  * focused control (for a keyboard-triggered menu via the Apps key or
431  * Shift+F10). The chosen command is delivered to `parent` as a `WM_COMMAND`,
432  * so it routes through `dispatchMenuCommand` like any other menu command.
433  */
434 void showPopupMenu(Menu menu, Widget parent, int x = -1, int y = -1)
435 {
436 	if (menu is null || parent is null || parent.handle is null)
437 		return;
438 
439 	if (x == -1 && y == -1)
440 	{
441 		// Keyboard-triggered: anchor at the focused control, else the parent.
442 		HWND focus = GetFocus();
443 		RECT rc;
444 		if (focus !is null && GetWindowRect(focus, &rc))
445 		{
446 			x = rc.left;
447 			y = rc.bottom;
448 		}
449 		else if (GetWindowRect(parent.handle, &rc))
450 		{
451 			x = rc.left + (rc.right - rc.left) / 2;
452 			y = rc.top + (rc.bottom - rc.top) / 2;
453 		}
454 	}
455 
456 	// Required so the menu dismisses correctly when clicking elsewhere.
457 	SetForegroundWindow(parent.handle);
458 	TrackPopupMenu(menu.handle, TPM_LEFTALIGN | TPM_RIGHTBUTTON,
459 		x, y, 0, parent.handle, null);
460 }
461 
462 /**
463  * Result of parsing an accelerator string.
464  *
465  * `valid` is false for an empty or unrecognized string. `fVirt` carries the
466  * `FCONTROL`/`FSHIFT`/`FALT` modifier flags (the caller adds `FVIRTKEY`); `key`
467  * is the virtual-key code.
468  */
469 struct Accelerator
470 {
471 	bool valid;
472 	ubyte fVirt;
473 	ushort key;
474 }
475 
476 private bool asciiEquals(string a, string b)
477 {
478 	if (a.length != b.length)
479 		return false;
480 	foreach (i, c; a)
481 	{
482 		char ca = c;
483 		char cb = b[i];
484 		if (ca >= 'A' && ca <= 'Z')
485 			ca = cast(char)(ca + 32);
486 		if (cb >= 'A' && cb <= 'Z')
487 			cb = cast(char)(cb + 32);
488 		if (ca != cb)
489 			return false;
490 	}
491 	return true;
492 }
493 
494 private string[] splitOnPlus(string s)
495 {
496 	string[] parts;
497 	size_t start = 0;
498 	foreach (i, c; s)
499 	{
500 		if (c == '+')
501 		{
502 			parts ~= s[start .. i];
503 			start = i + 1;
504 		}
505 	}
506 	parts ~= s[start .. $];
507 	return parts;
508 }
509 
510 /// Map a single key token (the part after the modifiers) to a virtual-key code.
511 private bool keyTokenToVk(string token, out ushort vk)
512 {
513 	if (token.length == 0)
514 		return false;
515 
516 	if (token.length == 1)
517 	{
518 		char c = token[0];
519 		if (c >= 'a' && c <= 'z')
520 		{
521 			vk = cast(ushort)(c - 32); // 'A'..'Z'
522 			return true;
523 		}
524 		if (c >= 'A' && c <= 'Z')
525 		{
526 			vk = cast(ushort) c;
527 			return true;
528 		}
529 		if (c >= '0' && c <= '9')
530 		{
531 			vk = cast(ushort) c;
532 			return true;
533 		}
534 		switch (c)
535 		{
536 		case ',': vk = VK_OEM_COMMA; return true;
537 		case '.': vk = VK_OEM_PERIOD; return true;
538 		case ';': vk = 0xBA; return true; // VK_OEM_1
539 		case '/': vk = 0xBF; return true; // VK_OEM_2
540 		case '-': vk = VK_OEM_MINUS; return true;
541 		case '=': vk = VK_OEM_PLUS; return true;
542 		default: return false;
543 		}
544 	}
545 
546 	// Function keys F1..F24.
547 	if ((token[0] == 'f' || token[0] == 'F') && token.length <= 3)
548 	{
549 		int n = 0;
550 		bool digits = true;
551 		foreach (ch; token[1 .. $])
552 		{
553 			if (ch < '0' || ch > '9')
554 			{
555 				digits = false;
556 				break;
557 			}
558 			n = n * 10 + (ch - '0');
559 		}
560 		if (digits && n >= 1 && n <= 24)
561 		{
562 			vk = cast(ushort)(VK_F1 + (n - 1));
563 			return true;
564 		}
565 	}
566 
567 	static struct Named { string name; ushort vk; }
568 	static immutable Named[] names = [
569 		Named("up", VK_UP), Named("down", VK_DOWN),
570 		Named("left", VK_LEFT), Named("right", VK_RIGHT),
571 		Named("home", VK_HOME), Named("end", VK_END),
572 		Named("pageup", VK_PRIOR), Named("pagedown", VK_NEXT),
573 		Named("insert", VK_INSERT), Named("delete", VK_DELETE),
574 		Named("del", VK_DELETE), Named("space", VK_SPACE),
575 		Named("tab", VK_TAB), Named("enter", VK_RETURN),
576 		Named("return", VK_RETURN), Named("escape", VK_ESCAPE),
577 		Named("esc", VK_ESCAPE), Named("backspace", VK_BACK),
578 	];
579 	foreach (n; names)
580 		if (asciiEquals(token, n.name))
581 		{
582 			vk = n.vk;
583 			return true;
584 		}
585 
586 	return false;
587 }
588 
589 /**
590  * Parse an accelerator string such as `"Ctrl+Shift+N"` or `"F5"`.
591  *
592  * Recognizes the `Ctrl`/`Control`, `Alt` and `Shift` modifiers (in any order
593  * and case), letter and digit keys, function keys `F1`–`F24`, and the common
594  * named keys (`Up`, `Delete`, `Enter`, …). Returns a result with `valid` false
595  * for an empty or unrecognized string.
596  */
597 Accelerator parseAccelerator(string spec)
598 {
599 	Accelerator result;
600 	if (spec.length == 0)
601 		return result;
602 
603 	auto parts = splitOnPlus(spec);
604 	if (parts.length == 0)
605 		return result;
606 
607 	ubyte fVirt = 0;
608 	foreach (mod; parts[0 .. $ - 1])
609 	{
610 		if (asciiEquals(mod, "ctrl") || asciiEquals(mod, "control"))
611 			fVirt |= FCONTROL;
612 		else if (asciiEquals(mod, "shift"))
613 			fVirt |= FSHIFT;
614 		else if (asciiEquals(mod, "alt"))
615 			fVirt |= FALT;
616 		else
617 			return result; // unknown modifier
618 	}
619 
620 	ushort vk;
621 	if (!keyTokenToVk(parts[$ - 1], vk))
622 		return result;
623 
624 	result.valid = true;
625 	result.fVirt = fVirt;
626 	result.key = vk;
627 	return result;
628 }
629 
630 unittest
631 {
632 	// A single letter with one modifier.
633 	auto a = parseAccelerator("Ctrl+N");
634 	assert(a.valid);
635 	assert(a.fVirt == FCONTROL);
636 	assert(a.key == 'N');
637 }
638 
639 unittest
640 {
641 	// Two modifiers, any case.
642 	auto a = parseAccelerator("ctrl+shift+n");
643 	assert(a.valid);
644 	assert(a.fVirt == (FCONTROL | FSHIFT));
645 	assert(a.key == 'N');
646 }
647 
648 unittest
649 {
650 	// Alt + Shift with a named arrow key.
651 	auto a = parseAccelerator("Alt+Shift+Up");
652 	assert(a.valid);
653 	assert(a.fVirt == (FALT | FSHIFT));
654 	assert(a.key == VK_UP);
655 }
656 
657 unittest
658 {
659 	// A bare function key, no modifiers.
660 	auto f1 = parseAccelerator("F1");
661 	assert(f1.valid);
662 	assert(f1.fVirt == 0);
663 	assert(f1.key == VK_F1);
664 
665 	// A modified function key.
666 	auto sf1 = parseAccelerator("Shift+F1");
667 	assert(sf1.valid);
668 	assert(sf1.fVirt == FSHIFT);
669 	assert(sf1.key == VK_F1);
670 
671 	// Two-digit function key.
672 	auto f12 = parseAccelerator("F12");
673 	assert(f12.valid);
674 	assert(f12.key == VK_F1 + 11);
675 }
676 
677 unittest
678 {
679 	// Punctuation key.
680 	auto comma = parseAccelerator("Ctrl+,");
681 	assert(comma.valid);
682 	assert(comma.fVirt == FCONTROL);
683 	assert(comma.key == VK_OEM_COMMA);
684 }
685 
686 unittest
687 {
688 	// Empty and malformed strings are rejected, not crashed on.
689 	assert(!parseAccelerator("").valid);
690 	assert(!parseAccelerator("Ctrl+").valid);
691 	assert(!parseAccelerator("Frobnicate+Q").valid);
692 	assert(!parseAccelerator("Ctrl+Nonsense").valid);
693 }
694 
695 unittest
696 {
697 	// Generated ids are unique and monotonic.
698 	auto a = nextMenuId();
699 	auto b = nextMenuId();
700 	assert(b == a + 1);
701 	assert(a >= 30_000);
702 }
703 
704 unittest
705 {
706 	// buildAcceleratorTable collects one ACCEL per accelerator-bearing item across
707 	// the whole bar (including submenus) and ignores items without one. These APIs
708 	// (CreateMenu/AppendMenu/CreateAcceleratorTable) need no window or message loop.
709 	auto bar = new MenuBar();
710 	auto file = new Menu();
711 	file.append(MenuItem(0, "&New", "Ctrl+N"));
712 	file.append(MenuItem(0, "&Open", "Ctrl+O"));
713 	file.append(MenuItem(0, "&Close")); // no accelerator
714 	auto sub = new Menu();
715 	sub.append(MenuItem(0, "&Recent", "Ctrl+R"));
716 	file.appendSubmenu(sub, "Recen&t");
717 	bar.append(file, "&File");
718 
719 	HACCEL table = bar.buildAcceleratorTable();
720 	assert(table !is null);
721 	// CopyAcceleratorTableW with a null buffer returns the entry count.
722 	assert(CopyAcceleratorTableW(table, null, 0) == 3);
723 	DestroyAcceleratorTable(table);
724 
725 	// A bar with no accelerator-bearing items yields a null table.
726 	auto plain = new MenuBar();
727 	auto edit = new Menu();
728 	edit.append(MenuItem(0, "&Undo"));
729 	plain.append(edit, "&Edit");
730 	assert(plain.buildAcceleratorTable() is null);
731 }