PopoverMenu.cs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  1. #nullable enable
  2. namespace Terminal.Gui;
  3. /// <summary>
  4. /// Provides a cascading menu that pops over all other content. Can be used as a context menu or a drop-down
  5. /// all other content. Can be used as a context menu or a drop-down
  6. /// menu as part of <see cref="MenuBarv2"/> as part of <see cref="MenuBarv2"/>.
  7. /// </summary>
  8. /// <remarks>
  9. /// <para>
  10. /// To use as a context menu, register the popover menu with <see cref="Application.Popover"/> and call
  11. /// <see cref="MakeVisible"/>.
  12. /// </para>
  13. /// </remarks>
  14. public class PopoverMenu : PopoverBaseImpl, IDesignable
  15. {
  16. /// <summary>
  17. /// Initializes a new instance of the <see cref="PopoverMenu"/> class.
  18. /// </summary>
  19. public PopoverMenu () : this ((Menuv2?)null) { }
  20. /// <summary>
  21. /// Initializes a new instance of the <see cref="PopoverMenu"/> class. If any of the elements of
  22. /// <paramref name="menuItems"/> is <see langword="null"/>,
  23. /// a see <see cref="Line"/> will be created instead.
  24. /// </summary>
  25. public PopoverMenu (IEnumerable<View>? menuItems) : this (
  26. new Menuv2 (menuItems?.Select (item => item ?? new Line ()))
  27. {
  28. Title = "Popover Root"
  29. })
  30. { }
  31. /// <inheritdoc/>
  32. public PopoverMenu (IEnumerable<MenuItemv2>? menuItems) : this (
  33. new Menuv2 (menuItems)
  34. {
  35. Title = "Popover Root"
  36. })
  37. { }
  38. /// <summary>
  39. /// Initializes a new instance of the <see cref="PopoverMenu"/> class with the specified root <see cref="Menuv2"/>.
  40. /// </summary>
  41. public PopoverMenu (Menuv2? root)
  42. {
  43. // Do this to support debugging traces where Title gets set
  44. base.HotKeySpecifier = (Rune)'\xffff';
  45. if (Border is { })
  46. {
  47. Border.Settings &= ~BorderSettings.Title;
  48. }
  49. Key = DefaultKey;
  50. base.Visible = false;
  51. Root = root;
  52. AddCommand (Command.Right, MoveRight);
  53. KeyBindings.Add (Key.CursorRight, Command.Right);
  54. AddCommand (Command.Left, MoveLeft);
  55. KeyBindings.Add (Key.CursorLeft, Command.Left);
  56. AddCommand (Command.Quit, Quit);
  57. return;
  58. bool? Quit (ICommandContext? ctx)
  59. {
  60. Logging.Debug ($"{Title} Command.Quit - {ctx?.Source?.Title}");
  61. if (!Visible)
  62. {
  63. // If we're not visible, the command is not for us
  64. return false;
  65. }
  66. // This ensures the quit command gets propagated to the owner of the popover.
  67. // This is important for MenuBarItems to ensure the MenuBar loses focus when
  68. // the user presses QuitKey to cause the menu to close.
  69. // Note, we override OnAccepting, which will set Visible to false
  70. Logging.Debug ($"{Title} Command.Quit - Calling RaiseAccepting {ctx?.Source?.Title}");
  71. bool? ret = RaiseAccepting (ctx);
  72. if (Visible && ret is not true)
  73. {
  74. Visible = false;
  75. return true;
  76. }
  77. // If we are Visible, returning true will stop the QuitKey from propagating
  78. // If we are not Visible, returning false will allow the QuitKey to propagate
  79. return Visible;
  80. }
  81. bool? MoveLeft (ICommandContext? ctx)
  82. {
  83. if (Focused == Root)
  84. {
  85. return false;
  86. }
  87. if (MostFocused is MenuItemv2 { SuperView: Menuv2 focusedMenu })
  88. {
  89. focusedMenu.SuperMenuItem?.SetFocus ();
  90. return true;
  91. }
  92. return AdvanceFocus (NavigationDirection.Backward, TabBehavior.TabStop);
  93. }
  94. bool? MoveRight (ICommandContext? ctx)
  95. {
  96. if (MostFocused is MenuItemv2 { SubMenu.Visible: true } focused)
  97. {
  98. focused.SubMenu.SetFocus ();
  99. return true;
  100. }
  101. return false;
  102. }
  103. }
  104. private Key _key = DefaultKey;
  105. /// <summary>Specifies the key that will activate the context menu.</summary>
  106. public Key Key
  107. {
  108. get => _key;
  109. set
  110. {
  111. Key oldKey = _key;
  112. _key = value;
  113. KeyChanged?.Invoke (this, new (oldKey, _key));
  114. }
  115. }
  116. /// <summary>Raised when <see cref="Key"/> is changed.</summary>
  117. public event EventHandler<KeyChangedEventArgs>? KeyChanged;
  118. /// <summary>The default key for activating popover menus.</summary>
  119. [SerializableConfigurationProperty (Scope = typeof (SettingsScope))]
  120. public static Key DefaultKey { get; set; } = Key.F10.WithShift;
  121. /// <summary>
  122. /// The mouse flags that will cause the popover menu to be visible. The default is
  123. /// <see cref="MouseFlags.Button3Clicked"/> which is typically the right mouse button.
  124. /// </summary>
  125. public MouseFlags MouseFlags { get; set; } = MouseFlags.Button3Clicked;
  126. /// <summary>
  127. /// Makes the popover menu visible and locates it at <paramref name="idealScreenPosition"/>. The actual position of the
  128. /// menu
  129. /// will be adjusted to
  130. /// ensure the menu fully fits on the screen, and the mouse cursor is over the first cell of the
  131. /// first MenuItem.
  132. /// </summary>
  133. /// <param name="idealScreenPosition">If <see langword="null"/>, the current mouse position will be used.</param>
  134. public void MakeVisible (Point? idealScreenPosition = null)
  135. {
  136. if (Visible)
  137. {
  138. Logging.Debug ($"{Title} - Already Visible");
  139. return;
  140. }
  141. UpdateKeyBindings ();
  142. SetPosition (idealScreenPosition);
  143. Application.Popover?.Show (this);
  144. }
  145. /// <summary>
  146. /// Locates the popover menu at <paramref name="idealScreenPosition"/>. The actual position of the menu will be
  147. /// adjusted to
  148. /// ensure the menu fully fits on the screen, and the mouse cursor is over the first cell of the
  149. /// first MenuItem (if possible).
  150. /// </summary>
  151. /// <param name="idealScreenPosition">If <see langword="null"/>, the current mouse position will be used.</param>
  152. public void SetPosition (Point? idealScreenPosition = null)
  153. {
  154. idealScreenPosition ??= Application.GetLastMousePosition ();
  155. if (idealScreenPosition is null || Root is null)
  156. {
  157. return;
  158. }
  159. Point pos = idealScreenPosition.Value;
  160. if (!Root.IsInitialized)
  161. {
  162. Root.BeginInit ();
  163. Root.EndInit ();
  164. Root.Layout ();
  165. }
  166. pos = GetMostVisibleLocationForSubMenu (Root, pos);
  167. Root.X = pos.X;
  168. Root.Y = pos.Y;
  169. }
  170. /// <inheritdoc/>
  171. protected override void OnVisibleChanged ()
  172. {
  173. Logging.Debug ($"{Title} - Visible: {Visible}");
  174. base.OnVisibleChanged ();
  175. if (Visible)
  176. {
  177. AddAndShowSubMenu (_root);
  178. }
  179. else
  180. {
  181. HideAndRemoveSubMenu (_root);
  182. Application.Popover?.Hide (this);
  183. }
  184. }
  185. private Menuv2? _root;
  186. /// <summary>
  187. /// Gets or sets the <see cref="Menuv2"/> that is the root of the Popover Menu.
  188. /// </summary>
  189. public Menuv2? Root
  190. {
  191. get => _root;
  192. set
  193. {
  194. if (_root == value)
  195. {
  196. return;
  197. }
  198. HideAndRemoveSubMenu (_root);
  199. _root = value;
  200. // TODO: This needs to be done whenever any MenuItem in the menu tree changes to support dynamic menus
  201. // TODO: And it needs to clear the old bindings first
  202. UpdateKeyBindings ();
  203. // TODO: This needs to be done whenever any MenuItem in the menu tree changes to support dynamic menus
  204. IEnumerable<Menuv2> allMenus = GetAllSubMenus ();
  205. foreach (Menuv2 menu in allMenus)
  206. {
  207. menu.Visible = false;
  208. menu.Accepting += MenuOnAccepting;
  209. menu.Accepted += MenuAccepted;
  210. menu.SelectedMenuItemChanged += MenuOnSelectedMenuItemChanged;
  211. }
  212. }
  213. }
  214. private void UpdateKeyBindings ()
  215. {
  216. IEnumerable<MenuItemv2> all = GetMenuItemsOfAllSubMenus ();
  217. foreach (MenuItemv2 menuItem in all.Where (mi => mi.Command != Command.NotBound))
  218. {
  219. Key? key;
  220. if (menuItem.TargetView is { })
  221. {
  222. // A TargetView implies HotKey
  223. key = menuItem.TargetView.HotKeyBindings.GetFirstFromCommands (menuItem.Command);
  224. }
  225. else
  226. {
  227. // No TargetView implies Application HotKey
  228. key = Application.KeyBindings.GetFirstFromCommands (menuItem.Command);
  229. }
  230. if (key is not { IsValid: true })
  231. {
  232. continue;
  233. }
  234. if (menuItem.Key.IsValid)
  235. {
  236. //Logging.Warning ("Do not specify a Key for MenuItems where a Command is specified. Key will be determined automatically.");
  237. }
  238. menuItem.Key = key;
  239. Logging.Debug ($"{Title} - HotKey: {menuItem.Key}->{menuItem.Command}");
  240. }
  241. }
  242. /// <inheritdoc/>
  243. protected override bool OnKeyDownNotHandled (Key key)
  244. {
  245. // See if any of our MenuItems have this key as Key
  246. IEnumerable<MenuItemv2> all = GetMenuItemsOfAllSubMenus ();
  247. foreach (MenuItemv2 menuItem in all)
  248. {
  249. if (key != Application.QuitKey && menuItem.Key == key)
  250. {
  251. Logging.Debug ($"{Title} - key: {key}");
  252. return menuItem.NewKeyDownEvent (key);
  253. }
  254. }
  255. return base.OnKeyDownNotHandled (key);
  256. }
  257. /// <summary>
  258. /// Gets all the submenus in the PopoverMenu.
  259. /// </summary>
  260. /// <returns></returns>
  261. public IEnumerable<Menuv2> GetAllSubMenus ()
  262. {
  263. List<Menuv2> result = [];
  264. if (Root == null)
  265. {
  266. return result;
  267. }
  268. Stack<Menuv2> stack = new ();
  269. stack.Push (Root);
  270. while (stack.Count > 0)
  271. {
  272. Menuv2 currentMenu = stack.Pop ();
  273. result.Add (currentMenu);
  274. foreach (View subView in currentMenu.SubViews)
  275. {
  276. if (subView is MenuItemv2 { SubMenu: { } } menuItem)
  277. {
  278. stack.Push (menuItem.SubMenu);
  279. }
  280. }
  281. }
  282. return result;
  283. }
  284. /// <summary>
  285. /// Gets all the MenuItems in the PopoverMenu.
  286. /// </summary>
  287. /// <returns></returns>
  288. internal IEnumerable<MenuItemv2> GetMenuItemsOfAllSubMenus ()
  289. {
  290. List<MenuItemv2> result = [];
  291. foreach (Menuv2 menu in GetAllSubMenus ())
  292. {
  293. foreach (View subView in menu.SubViews)
  294. {
  295. if (subView is MenuItemv2 menuItem)
  296. {
  297. result.Add (menuItem);
  298. }
  299. }
  300. }
  301. return result;
  302. }
  303. /// <summary>
  304. /// Pops up the submenu of the specified MenuItem, if there is one.
  305. /// </summary>
  306. /// <param name="menuItem"></param>
  307. internal void ShowSubMenu (MenuItemv2? menuItem)
  308. {
  309. var menu = menuItem?.SuperView as Menuv2;
  310. Logging.Debug ($"{Title} - menuItem: {menuItem?.Title}, menu: {menu?.Title}");
  311. menu?.Layout ();
  312. // If there's a visible peer, remove / hide it
  313. if (menu?.SubViews.FirstOrDefault (v => v is MenuItemv2 { SubMenu.Visible: true }) is MenuItemv2 visiblePeer)
  314. {
  315. HideAndRemoveSubMenu (visiblePeer.SubMenu);
  316. visiblePeer.ForceFocusColors = false;
  317. }
  318. if (menuItem is { SubMenu: { Visible: false } })
  319. {
  320. AddAndShowSubMenu (menuItem.SubMenu);
  321. Point idealLocation = ScreenToViewport (
  322. new (
  323. menuItem.FrameToScreen ().Right - menuItem.SubMenu.GetAdornmentsThickness ().Left,
  324. menuItem.FrameToScreen ().Top - menuItem.SubMenu.GetAdornmentsThickness ().Top));
  325. Point pos = GetMostVisibleLocationForSubMenu (menuItem.SubMenu, idealLocation);
  326. menuItem.SubMenu.X = pos.X;
  327. menuItem.SubMenu.Y = pos.Y;
  328. menuItem.ForceFocusColors = true;
  329. }
  330. }
  331. /// <summary>
  332. /// Gets the most visible screen-relative location for <paramref name="menu"/>.
  333. /// </summary>
  334. /// <param name="menu">The menu to locate.</param>
  335. /// <param name="idealLocation">Ideal screen-relative location.</param>
  336. /// <returns></returns>
  337. internal Point GetMostVisibleLocationForSubMenu (Menuv2 menu, Point idealLocation)
  338. {
  339. var pos = Point.Empty;
  340. // Calculate the initial position to the right of the menu item
  341. GetLocationEnsuringFullVisibility (
  342. menu,
  343. idealLocation.X,
  344. idealLocation.Y,
  345. out int nx,
  346. out int ny);
  347. return new (nx, ny);
  348. }
  349. private void AddAndShowSubMenu (Menuv2? menu)
  350. {
  351. if (menu is { SuperView: null, Visible: false })
  352. {
  353. Logging.Debug ($"{Title} ({menu?.Title}) - menu.Visible: {menu?.Visible}");
  354. // TODO: Find the menu item below the mouse, if any, and select it
  355. if (!menu!.IsInitialized)
  356. {
  357. menu.BeginInit ();
  358. menu.EndInit ();
  359. }
  360. menu.ClearFocus ();
  361. base.Add (menu);
  362. // IMPORTANT: This must be done after adding the menu to the super view or Add will try
  363. // to set focus to it.
  364. menu.Visible = true;
  365. menu.Layout ();
  366. }
  367. }
  368. private void HideAndRemoveSubMenu (Menuv2? menu)
  369. {
  370. if (menu is { Visible: true })
  371. {
  372. Logging.Debug ($"{Title} ({menu?.Title}) - menu.Visible: {menu?.Visible}");
  373. // If there's a visible submenu, remove / hide it
  374. if (menu.SubViews.FirstOrDefault (v => v is MenuItemv2 { SubMenu.Visible: true }) is MenuItemv2 visiblePeer)
  375. {
  376. HideAndRemoveSubMenu (visiblePeer.SubMenu);
  377. visiblePeer.ForceFocusColors = false;
  378. }
  379. menu.Visible = false;
  380. menu.ClearFocus ();
  381. base.Remove (menu);
  382. if (menu == Root)
  383. {
  384. Visible = false;
  385. }
  386. }
  387. }
  388. private void MenuOnAccepting (object? sender, CommandEventArgs e)
  389. {
  390. var senderView = sender as View;
  391. Logging.Debug ($"{Title} ({e.Context?.Source?.Title}) Command: {e.Context?.Command} - Sender: {senderView?.GetType ().Name}");
  392. if (e.Context?.Command != Command.HotKey)
  393. {
  394. Logging.Debug ($"{Title} - Setting Visible = false");
  395. Visible = false;
  396. }
  397. if (e.Context is CommandContext<KeyBinding> keyCommandContext)
  398. {
  399. if (keyCommandContext.Binding.Key is { } && keyCommandContext.Binding.Key == Application.QuitKey && SuperView is { Visible: true })
  400. {
  401. Logging.Debug ($"{Title} - Setting e.Handled = true - Application.QuitKey/Command = Command.Quit");
  402. e.Handled = true;
  403. }
  404. }
  405. }
  406. private void MenuAccepted (object? sender, CommandEventArgs e)
  407. {
  408. Logging.Debug ($"{Title} ({e.Context?.Source?.Title}) Command: {e.Context?.Command}");
  409. if (e.Context?.Source is MenuItemv2 { SubMenu: null })
  410. {
  411. HideAndRemoveSubMenu (_root);
  412. }
  413. else if (e.Context?.Source is MenuItemv2 { SubMenu: { } } menuItemWithSubMenu)
  414. {
  415. ShowSubMenu (menuItemWithSubMenu);
  416. }
  417. RaiseAccepted (e.Context);
  418. }
  419. /// <inheritdoc/>
  420. protected override bool OnAccepting (CommandEventArgs args)
  421. {
  422. Logging.Debug ($"{Title} ({args.Context?.Source?.Title}) Command: {args.Context?.Command}");
  423. // If we're not visible, ignore any keys that are not hotkeys
  424. CommandContext<KeyBinding>? keyCommandContext = args.Context as CommandContext<KeyBinding>? ?? default (CommandContext<KeyBinding>);
  425. if (!Visible && keyCommandContext is { Binding.Key: { } })
  426. {
  427. if (GetMenuItemsOfAllSubMenus ().All (i => i.Key != keyCommandContext.Value.Binding.Key))
  428. {
  429. Logging.Debug ($"{Title} ({args.Context?.Source?.Title}) Command: {args.Context?.Command} - ignore any keys that are not hotkeys");
  430. return false;
  431. }
  432. }
  433. Logging.Debug ($"{Title} - calling base.OnAccepting: {args.Context?.Command}");
  434. bool? ret = base.OnAccepting (args);
  435. if (ret is true || args.Handled)
  436. {
  437. return args.Handled = true;
  438. }
  439. // Only raise Accepted if the command came from one of our MenuItems
  440. //if (GetMenuItemsOfAllSubMenus ().Contains (args.Context?.Source))
  441. {
  442. Logging.Debug ($"{Title} - Calling RaiseAccepted {args.Context?.Command}");
  443. RaiseAccepted (args.Context);
  444. }
  445. // Always return false to enable accepting to continue propagating
  446. return false;
  447. }
  448. /// <summary>
  449. /// Raises the <see cref="OnAccepted"/>/<see cref="Accepted"/> event indicating a menu (or submenu)
  450. /// was accepted and the Menus in the PopoverMenu were hidden. Use this to determine when to hide the PopoverMenu.
  451. /// </summary>
  452. /// <param name="ctx"></param>
  453. /// <returns></returns>
  454. protected void RaiseAccepted (ICommandContext? ctx)
  455. {
  456. Logging.Debug ($"{Title} - RaiseAccepted: {ctx}");
  457. CommandEventArgs args = new () { Context = ctx };
  458. OnAccepted (args);
  459. Accepted?.Invoke (this, args);
  460. }
  461. /// <summary>
  462. /// Called when the user has accepted an item in this menu (or submenu. This is used to determine when to hide the
  463. /// menu.
  464. /// </summary>
  465. /// <remarks>
  466. /// </remarks>
  467. /// <param name="args"></param>
  468. protected virtual void OnAccepted (CommandEventArgs args) { }
  469. /// <summary>
  470. /// Raised when the user has accepted an item in this menu (or submenu. This is used to determine when to hide the
  471. /// menu.
  472. /// </summary>
  473. /// <remarks>
  474. /// <para>
  475. /// See <see cref="RaiseAccepted"/> for more information.
  476. /// </para>
  477. /// </remarks>
  478. public event EventHandler<CommandEventArgs>? Accepted;
  479. private void MenuOnSelectedMenuItemChanged (object? sender, MenuItemv2? e)
  480. {
  481. Logging.Debug ($"{Title} - e.Title: {e?.Title}");
  482. ShowSubMenu (e);
  483. }
  484. /// <inheritdoc/>
  485. protected override void OnSubViewAdded (View view)
  486. {
  487. if (Root is null && (view is Menuv2 || view is MenuItemv2))
  488. {
  489. throw new InvalidOperationException ("Do not add MenuItems or Menus directly to a PopoverMenu. Use the Root property.");
  490. }
  491. base.OnSubViewAdded (view);
  492. }
  493. /// <inheritdoc/>
  494. protected override void Dispose (bool disposing)
  495. {
  496. if (disposing)
  497. {
  498. IEnumerable<Menuv2> allMenus = GetAllSubMenus ();
  499. foreach (Menuv2 menu in allMenus)
  500. {
  501. menu.Accepting -= MenuOnAccepting;
  502. menu.Accepted -= MenuAccepted;
  503. menu.SelectedMenuItemChanged -= MenuOnSelectedMenuItemChanged;
  504. }
  505. _root?.Dispose ();
  506. _root = null;
  507. }
  508. base.Dispose (disposing);
  509. }
  510. /// <inheritdoc/>
  511. public bool EnableForDesign<TContext> (ref TContext context) where TContext : notnull
  512. {
  513. // Note: This menu is used by unit tests. If you modify it, you'll likely have to update
  514. // unit tests.
  515. Root = new (
  516. [
  517. new MenuItemv2 (context as View, Command.Cut),
  518. new MenuItemv2 (context as View, Command.Copy),
  519. new MenuItemv2 (context as View, Command.Paste),
  520. new Line (),
  521. new MenuItemv2 (context as View, Command.SelectAll),
  522. new Line (),
  523. new MenuItemv2 (context as View, Command.Quit)
  524. ])
  525. {
  526. Title = "Popover Demo Root"
  527. };
  528. // NOTE: This is a workaround for the fact that the PopoverMenu is not visible in the designer
  529. // NOTE: without being activated via Application.Popover. But we want it to be visible.
  530. // NOTE: If you use PopoverView.EnableForDesign for real Popover scenarios, change back to false
  531. // NOTE: after calling EnableForDesign.
  532. //Visible = true;
  533. return true;
  534. }
  535. }