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 }