#nullable enable namespace Terminal.Gui; /// /// Provides a cascading menu that pops over all other content. Can be used as a context menu or a drop-down /// all other content. Can be used as a context menu or a drop-down /// menu as part of as part of . /// /// /// /// To use as a context menu, register the popover menu with and call /// . /// /// public class PopoverMenu : PopoverBaseImpl, IDesignable { /// /// Initializes a new instance of the class. /// public PopoverMenu () : this ((Menuv2?)null) { } /// public PopoverMenu (IEnumerable? menuItems) : this (new Menuv2 (menuItems)) { } /// public PopoverMenu (IEnumerable? menuItems) : this (new Menuv2 (menuItems)) { } /// /// Initializes a new instance of the class with the specified root . /// public PopoverMenu (Menuv2? root) { Key = DefaultKey; base.Visible = false; Root = root; AddCommand (Command.Right, MoveRight); KeyBindings.Add (Key.CursorRight, Command.Right); AddCommand (Command.Left, MoveLeft); KeyBindings.Add (Key.CursorLeft, Command.Left); // TODO: Remove; for debugging for now AddCommand ( Command.NotBound, ctx => { Logging.Trace ($"popoverMenu NotBound: {ctx}"); return false; }); KeyBindings.Add (Key, Command.Quit); KeyBindings.ReplaceCommands (Application.QuitKey, Command.Quit); AddCommand ( Command.Quit, ctx => { if (!Visible) { return false; } Visible = false; return false; }); return; bool? MoveLeft (ICommandContext? ctx) { if (Focused == Root) { return false; } if (MostFocused is MenuItemv2 { SuperView: Menuv2 focusedMenu }) { focusedMenu.SuperMenuItem?.SetFocus (); return true; } return AdvanceFocus (NavigationDirection.Backward, TabBehavior.TabStop); } bool? MoveRight (ICommandContext? ctx) { if (MostFocused is MenuItemv2 { SubMenu.Visible: true } focused) { focused.SubMenu.SetFocus (); return true; } return false; //AdvanceFocus (NavigationDirection.Forward, TabBehavior.TabStop); } } private Key _key = DefaultKey; /// Specifies the key that will activate the context menu. public Key Key { get => _key; set { Key oldKey = _key; _key = value; KeyChanged?.Invoke (this, new (oldKey, _key)); } } /// Raised when is changed. public event EventHandler? KeyChanged; /// The default key for activating popover menus. [SerializableConfigurationProperty (Scope = typeof (SettingsScope))] public static Key DefaultKey { get; set; } = Key.F10.WithShift; /// /// The mouse flags that will cause the popover menu to be visible. The default is /// which is typically the right mouse button. /// public MouseFlags MouseFlags { get; set; } = MouseFlags.Button3Clicked; /// /// Makes the popover menu visible and locates it at . The actual position of the /// menu /// will be adjusted to /// ensure the menu fully fits on the screen, and the mouse cursor is over the first cell of the /// first MenuItem. /// /// If , the current mouse position will be used. public void MakeVisible (Point? idealScreenPosition = null) { UpdateKeyBindings (); SetPosition (idealScreenPosition); Application.Popover?.Show (this); } /// /// Locates the popover menu at . The actual position of the menu will be /// adjusted to /// ensure the menu fully fits on the screen, and the mouse cursor is over the first cell of the /// first MenuItem (if possible). /// /// If , the current mouse position will be used. public void SetPosition (Point? idealScreenPosition = null) { idealScreenPosition ??= Application.GetLastMousePosition (); if (idealScreenPosition is null || Root is null) { return; } Point pos = idealScreenPosition.Value; if (!Root.IsInitialized) { Root.BeginInit (); Root.EndInit (); Root.Layout (); } pos = GetMostVisibleLocationForSubMenu (Root, pos); Root.X = pos.X; Root.Y = pos.Y; } /// protected override void OnVisibleChanged () { base.OnVisibleChanged (); if (Visible) { AddAndShowSubMenu (_root); } else { HideAndRemoveSubMenu (_root); Application.Popover?.Hide (this); } } private Menuv2? _root; /// /// Gets or sets the that is the root of the Popover Menu. /// public Menuv2? Root { get => _root; set { if (_root == value) { return; } if (_root is { }) { _root.Accepting -= MenuOnAccepting; } HideAndRemoveSubMenu (_root); _root = value; if (_root is { }) { _root.Accepting += MenuOnAccepting; } // TODO: This needs to be done whenever any MenuItem in the menu tree changes to support dynamic menus // TODO: And it needs to clear the old bindings first UpdateKeyBindings (); // TODO: This needs to be done whenever any MenuItem in the menu tree changes to support dynamic menus IEnumerable allMenus = GetAllSubMenus (); foreach (Menuv2 menu in allMenus) { menu.Accepting += MenuOnAccepting; menu.Accepted += MenuAccepted; menu.SelectedMenuItemChanged += MenuOnSelectedMenuItemChanged; } } } private void UpdateKeyBindings () { IEnumerable all = GetMenuItemsOfAllSubMenus (); foreach (MenuItemv2 menuItem in all.Where (mi => mi.Command != Command.NotBound)) { Key? key; if (menuItem.TargetView is { }) { // A TargetView implies HotKey key = menuItem.TargetView.HotKeyBindings.GetFirstFromCommands (menuItem.Command); } else { // No TargetView implies Application HotKey key = Application.KeyBindings.GetFirstFromCommands (menuItem.Command); } if (key is not { IsValid: true }) { continue; } if (menuItem.Key.IsValid) { //Logging.Warning ("Do not specify a Key for MenuItems where a Command is specified. Key will be determined automatically."); } menuItem.Key = key; //Logging.Trace ($"HotKey: {menuItem.Key}->{menuItem.Command}"); } } /// protected override bool OnKeyDownNotHandled (Key key) { // See if any of our MenuItems have this key as Key IEnumerable all = GetMenuItemsOfAllSubMenus (); foreach (MenuItemv2 menuItem in all) { if (menuItem.Key == key) { return menuItem.NewKeyDownEvent (key); } } return base.OnKeyDownNotHandled (key); } /// /// Gets all the submenus in the PopoverMenu. /// /// internal IEnumerable GetAllSubMenus () { List result = []; if (Root == null) { return result; } Stack stack = new (); stack.Push (Root); while (stack.Count > 0) { Menuv2 currentMenu = stack.Pop (); result.Add (currentMenu); foreach (View subView in currentMenu.SubViews) { if (subView is MenuItemv2 menuItem && menuItem.SubMenu != null) { stack.Push (menuItem.SubMenu); } } } return result; } /// /// Gets all the MenuItems in the PopoverMenu. /// /// internal IEnumerable GetMenuItemsOfAllSubMenus () { List result = []; foreach (Menuv2 menu in GetAllSubMenus ()) { foreach (View subView in menu.SubViews) { if (subView is MenuItemv2 menuItem) { result.Add (menuItem); } } } return result; } /// /// Pops up the submenu of the specified MenuItem, if there is one. /// /// internal void ShowSubMenu (MenuItemv2? menuItem) { var menu = menuItem?.SuperView as Menuv2; menu?.Layout (); // If there's a visible peer, remove / hide it if (menu?.SubViews.FirstOrDefault (v => v is MenuItemv2 { SubMenu.Visible: true }) is MenuItemv2 visiblePeer) { HideAndRemoveSubMenu (visiblePeer.SubMenu); visiblePeer.ForceFocusColors = false; } if (menuItem is { SubMenu: { Visible: false } }) { AddAndShowSubMenu (menuItem.SubMenu); Point idealLocation = ScreenToViewport ( new ( menuItem.FrameToScreen ().Right - menuItem.SubMenu.GetAdornmentsThickness ().Left, menuItem.FrameToScreen ().Top - menuItem.SubMenu.GetAdornmentsThickness ().Top)); Point pos = GetMostVisibleLocationForSubMenu (menuItem.SubMenu, idealLocation); menuItem.SubMenu.X = pos.X; menuItem.SubMenu.Y = pos.Y; menuItem.ForceFocusColors = true; } } /// /// Gets the most visible screen-relative location for . /// /// The menu to locate. /// Ideal screen-relative location. /// internal Point GetMostVisibleLocationForSubMenu (Menuv2 menu, Point idealLocation) { var pos = Point.Empty; // Calculate the initial position to the right of the menu item GetLocationEnsuringFullVisibility ( menu, idealLocation.X, idealLocation.Y, out int nx, out int ny); return new (nx, ny); } private void AddAndShowSubMenu (Menuv2? menu) { if (menu is { SuperView: null }) { // TODO: Find the menu item below the mouse, if any, and select it // TODO: Enable No Border menu style menu.Border!.LineStyle = LineStyle.Single; menu.Border.Thickness = new (1); if (!menu.IsInitialized) { menu.BeginInit (); menu.EndInit (); } menu.ClearFocus (); base.Add (menu); // IMPORTANT: This must be done after adding the menu to the super view or Add will try // to set focus to it. menu.Visible = true; menu.Layout (); } } private void HideAndRemoveSubMenu (Menuv2? menu) { if (menu is { Visible: true }) { // If there's a visible submenu, remove / hide it if (menu.SubViews.FirstOrDefault (v => v is MenuItemv2 { SubMenu.Visible: true }) is MenuItemv2 visiblePeer) { HideAndRemoveSubMenu (visiblePeer.SubMenu); visiblePeer.ForceFocusColors = false; } menu.Visible = false; menu.ClearFocus (); base.Remove (menu); if (menu == Root) { Visible = false; } } } private void MenuOnAccepting (object? sender, CommandEventArgs e) { var senderView = sender as View; Logging.Trace ($"Sender: {senderView?.GetType ().Name}, {e.Context?.Source?.Title}"); if (e.Context?.Command != Command.HotKey) { Visible = false; } // This supports the case when a hotkey of a menuitem with a submenu is pressed //e.Cancel = true; } private void MenuAccepted (object? sender, CommandEventArgs e) { //Logging.Trace ($"{e.Context?.Source?.Title}"); if (e.Context?.Source is MenuItemv2 { SubMenu: null }) { HideAndRemoveSubMenu (_root); RaiseAccepted (e.Context); } else if (e.Context?.Source is MenuItemv2 { SubMenu: { } } menuItemWithSubMenu) { ShowSubMenu (menuItemWithSubMenu); } } /// /// Raises the / event indicating a menu (or submenu) /// was accepted and the Menus in the PopoverMenu were hidden. Use this to determine when to hide the PopoverMenu. /// /// /// protected bool? RaiseAccepted (ICommandContext? ctx) { //Logging.Trace ($"RaiseAccepted: {ctx}"); CommandEventArgs args = new () { Context = ctx }; OnAccepted (args); Accepted?.Invoke (this, args); return true; } /// /// Called when the user has accepted an item in this menu (or submenu. This is used to determine when to hide the /// menu. /// /// /// /// protected virtual void OnAccepted (CommandEventArgs args) { } /// /// Raised when the user has accepted an item in this menu (or submenu. This is used to determine when to hide the /// menu. /// /// /// /// See for more information. /// /// public event EventHandler? Accepted; private void MenuOnSelectedMenuItemChanged (object? sender, MenuItemv2? e) { Logging.Trace ($"e: {e?.Title}"); ShowSubMenu (e); } /// protected override void OnSubViewAdded (View view) { if (Root is null && (view is Menuv2 || view is MenuItemv2)) { throw new InvalidOperationException ("Do not add MenuItems or Menus directly to a PopoverMenu. Use the Root property."); } base.OnSubViewAdded (view); } /// protected override void Dispose (bool disposing) { if (disposing) { IEnumerable allMenus = GetAllSubMenus (); foreach (Menuv2 menu in allMenus) { menu.Accepting -= MenuOnAccepting; menu.Accepted -= MenuAccepted; menu.SelectedMenuItemChanged -= MenuOnSelectedMenuItemChanged; } _root?.Dispose (); _root = null; } base.Dispose (disposing); } /// public bool EnableForDesign (ref readonly TContext context) where TContext : notnull { Root = new ( [ new MenuItemv2 (this, Command.Cut), new MenuItemv2 (this, Command.Copy), new MenuItemv2 (this, Command.Paste), new Line (), new MenuItemv2 (this, Command.SelectAll) ]); Visible = true; return true; } }