#nullable enable using System.Diagnostics; namespace Terminal.Gui; /// /// Provides a cascading popover menu. /// public class PopoverMenu : PopoverBaseImpl { /// /// Initializes a new instance of the class. /// public PopoverMenu () : this (null) { } /// /// Initializes a new instance of the class with the specified root . /// public PopoverMenu (Menuv2? root) { base.Visible = false; //base.ColorScheme = Colors.ColorSchemes ["Menu"]; 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 (DefaultKey, Command.Quit); KeyBindings.ReplaceCommands (Application.QuitKey, Command.Quit); AddCommand ( Command.Quit, ctx => { if (!Visible) { return false; } Visible = false; return RaiseAccepted (ctx); }); 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 (Focused == Root) { return false; } if (MostFocused is MenuItemv2 { SubMenu.Visible: true } focused) { focused.SubMenu.SetFocus (); return true; } return AdvanceFocus (NavigationDirection.Forward, TabBehavior.TabStop); } } /// /// The mouse flags that will cause the popover menu to be visible. The default is /// which is typically the right mouse button. /// public static MouseFlags MouseFlags { get; set; } = MouseFlags.Button3Clicked; /// The default key for activating popover menus. [SerializableConfigurationProperty (Scope = typeof (SettingsScope))] public static Key DefaultKey { get; set; } = Key.F10.WithShift; /// /// 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?.ShowPopover (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 { } && Root is { }) { 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?.HidePopover (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; } UpdateKeyBindings (); IEnumerable allMenus = GetAllSubMenus (); foreach (Menuv2 menu in allMenus) { menu.Accepting += MenuOnAccepting; menu.Accepted += MenuAccepted; menu.SelectedMenuItemChanged += MenuOnSelectedMenuItemChanged; } } } private void UpdateKeyBindings () { // TODO: This needs to be done whenever any MenuItem in the menu tree changes to support dynamic menus // TODO: And it needs to clear them first IEnumerable all = GetMenuItemsOfAllSubMenus (); foreach (MenuItemv2 menuItem in all.Where(mi => mi.Command != Command.NotBound)) { if (menuItem.TargetView is { }) { // A TargetView implies HotKey // Automatically set MenuItem.Key Key? key = menuItem.TargetView.HotKeyBindings.GetFirstFromCommands (menuItem.Command); if (key is { IsValid: true }) { 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}"); } } else { // No TargetView implies Application HotKey Key? key = Application.KeyBindings.GetFirstFromCommands (menuItem.Command); if (key is { IsValid: true }) { if (menuItem.Key.IsValid) { // Logging.Warning ("App HotKey: Do not specify a Key for MenuItems where a Command is specified. Key will be determined automatically."); } menuItem.Key = key; Logging.Trace ($"App HotKey: {menuItem.Key}->{menuItem.Command}"); } } } foreach (MenuItemv2 menuItem in all.Where (mi => mi is { Command: Command.NotBound, Key.IsValid: true })) { } } /// 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; if (menu is { }) { menu.Layout (); } // If there's a visible peer, remove / hide it // Debug.Assert (menu is null || menu?.SubViews.Count (v => v is MenuItemv2 { SubMenu.Visible: true }) < 2); 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 // Debug.Assert (menu.SubViews.Count (v => v is MenuItemv2 { SubMenu.Visible: true }) <= 1); 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) { if (e.Context?.Command != Command.HotKey) { Visible = false; } else { // This supports the case when a hotkey of a menuitem with a submenu is pressed e.Cancel = true; } Logging.Trace ($"{e.Context?.Source?.Title}"); } 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); } } /// /// Riases 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}"); ShowSubMenu (e); } /// 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); } }