// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Extensions.IEnumerableExtensions; using osuTK.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Layout; using osu.Framework.Utils; using osu.Framework.Threading; using osuTK; using osuTK.Input; namespace osu.Framework.Graphics.UserInterface { public abstract class Menu : CompositeDrawable, IStateful { /// /// Invoked when this 's changes. /// public event Action StateChanged; /// /// Gets or sets the delay before opening sub-s when menu items are hovered. /// protected double HoverOpenDelay = 100; /// /// Whether this menu is always displayed in an open state (ie. a menu bar). /// Clicks are required to activate . /// protected readonly bool TopLevelMenu; /// /// The that contains the content of this . /// protected readonly ScrollContainer ContentContainer; /// /// The that contains the items of this . /// protected FillFlowContainer ItemsContainer; /// /// The container that provides the masking effects for this . /// protected readonly Container MaskingContainer; /// /// Gets the item representations contained by this . /// protected internal IReadOnlyList Children => ItemsContainer.Children; protected readonly Direction Direction; private Menu parentMenu; private Menu submenu; private readonly Box background; private readonly LayoutValue sizeCache = new LayoutValue(Invalidation.RequiredParentSizeToFit, InvalidationSource.Child); private readonly Container submenuContainer; /// /// Constructs a menu. /// /// The direction of layout for this menu. /// Whether the resultant menu is always displayed in an open state (ie. a menu bar). protected Menu(Direction direction, bool topLevelMenu = false) { Direction = direction; TopLevelMenu = topLevelMenu; if (topLevelMenu) state = MenuState.Open; InternalChildren = new Drawable[] { MaskingContainer = new Container { Name = "Our contents", RelativeSizeAxes = Axes.Both, Masking = true, Children = new Drawable[] { background = new Box { RelativeSizeAxes = Axes.Both, Colour = Color4.Black }, ContentContainer = CreateScrollContainer(direction).With(d => { d.RelativeSizeAxes = Axes.Both; d.Masking = false; d.Child = ItemsContainer = new FillFlowContainer { Direction = direction == Direction.Horizontal ? FillDirection.Horizontal : FillDirection.Vertical }; }) } }, submenuContainer = new Container { Name = "Sub menu container", AutoSizeAxes = Axes.Both } }; switch (direction) { case Direction.Horizontal: ItemsContainer.AutoSizeAxes = Axes.X; break; case Direction.Vertical: ItemsContainer.AutoSizeAxes = Axes.Y; break; } // The menu will provide a valid size for the items container based on our own size ItemsContainer.RelativeSizeAxes = Axes.Both & ~ItemsContainer.AutoSizeAxes; AddLayout(sizeCache); } protected override void LoadComplete() { base.LoadComplete(); updateState(); } /// /// Gets or sets the s contained within this . /// public IReadOnlyList Items { get => ItemsContainer.Select(r => r.Item).ToList(); set { Clear(); value?.ForEach(Add); } } /// /// Gets or sets the background colour of this . /// public Color4 BackgroundColour { get => background.Colour; set => background.Colour = value; } /// /// Gets or sets whether the scroll bar of this should be visible. /// public bool ScrollbarVisible { get => ContentContainer.ScrollbarVisible; set => ContentContainer.ScrollbarVisible = value; } private float maxWidth = float.MaxValue; /// /// Gets or sets the maximum allowable width by this . /// public float MaxWidth { get => maxWidth; set { if (Precision.AlmostEquals(maxWidth, value)) return; maxWidth = value; sizeCache.Invalidate(); } } private float maxHeight = float.PositiveInfinity; /// /// Gets or sets the maximum allowable height by this . /// public float MaxHeight { get => maxHeight; set { if (Precision.AlmostEquals(maxHeight, value)) return; maxHeight = value; sizeCache.Invalidate(); } } private MenuState state = MenuState.Closed; /// /// Gets or sets the current state of this . /// public virtual MenuState State { get => state; set { if (TopLevelMenu) { submenu?.Close(); return; } if (state == value) return; state = value; updateState(); StateChanged?.Invoke(State); } } private void updateState() { if (!IsLoaded) return; resetState(); switch (State) { case MenuState.Closed: AnimateClose(); if (HasFocus) GetContainingInputManager()?.ChangeFocus(parentMenu); break; case MenuState.Open: AnimateOpen(); // We may not be present at this point, so must run on the next frame. if (!TopLevelMenu) { Schedule(delegate { if (State == MenuState.Open) GetContainingInputManager().ChangeFocus(this); }); } break; } } private void resetState() { if (!IsLoaded) return; submenu?.Close(); sizeCache.Invalidate(); } /// /// Adds a to this . /// /// The to add. public virtual void Add(MenuItem item) { var drawableItem = CreateDrawableMenuItem(item); drawableItem.Clicked = menuItemClicked; drawableItem.Hovered = menuItemHovered; drawableItem.StateChanged += s => itemStateChanged(drawableItem, s); drawableItem.SetFlowDirection(Direction); ItemsContainer.Add(drawableItem); sizeCache.Invalidate(); } private void itemStateChanged(DrawableMenuItem item, MenuItemState state) { if (state != MenuItemState.Selected) return; if (item != selectedItem && selectedItem != null) selectedItem.State = MenuItemState.NotSelected; selectedItem = item; } /// /// Removes a from this . /// /// The to remove. /// Whether was successfully removed. public bool Remove(MenuItem item) { bool result = ItemsContainer.RemoveAll(d => d.Item == item) > 0; sizeCache.Invalidate(); return result; } /// /// Clears all s in this . /// public void Clear() { ItemsContainer.Clear(); resetState(); } /// /// Opens this . /// public void Open() => State = MenuState.Open; /// /// Closes this . /// public void Close() => State = MenuState.Closed; /// /// Toggles the state of this . /// public void Toggle() => State = State == MenuState.Closed ? MenuState.Open : MenuState.Closed; /// /// Animates the opening of this . /// protected virtual void AnimateOpen() => Show(); /// /// Animates the closing of this . /// protected virtual void AnimateClose() => Hide(); protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); if (!sizeCache.IsValid) { // Our children will be relatively-sized on the axis separate to the menu direction, so we need to compute // that size ourselves, based on the content size of our children, to give them a valid relative size float width = 0; float height = 0; foreach (var item in Children) { width = Math.Max(width, item.ContentDrawWidth); height = Math.Max(height, item.ContentDrawHeight); } // When scrolling in one direction, ItemsContainer is auto-sized in that direction and relative-sized in the other // In the case of the auto-sized direction, we want to use its size. In the case of the relative-sized direction, we want // to use the (above) computed size. width = Direction == Direction.Horizontal ? ItemsContainer.Width : width; height = Direction == Direction.Vertical ? ItemsContainer.Height : height; width = Math.Min(MaxWidth, width); height = Math.Min(MaxHeight, height); // Regardless of the above result, if we are relative-sizing, just use the stored width/height width = RelativeSizeAxes.HasFlagFast(Axes.X) ? Width : width; height = RelativeSizeAxes.HasFlagFast(Axes.Y) ? Height : height; if (State == MenuState.Closed && Direction == Direction.Horizontal) width = 0; if (State == MenuState.Closed && Direction == Direction.Vertical) height = 0; UpdateSize(new Vector2(width, height)); sizeCache.Validate(); } } /// /// Resizes this . /// /// The new size. protected virtual void UpdateSize(Vector2 newSize) => Size = newSize; #region Hover/Focus logic private void menuItemClicked(DrawableMenuItem item) { // We only want to close the sub-menu if we're not a sub menu - if we are a sub menu // then clicks should instead cause the sub menus to instantly show up if (TopLevelMenu && submenu?.State == MenuState.Open) { submenu.Close(); return; } // Check if there is a sub menu to display if (item.Item.Items?.Count == 0) { // This item must have attempted to invoke an action - close all menus if item allows if (item.CloseMenuOnClick) closeAll(); return; } openDelegate?.Cancel(); openSubmenuFor(item); } private DrawableMenuItem selectedItem; /// /// The item which triggered opening us as a submenu. /// private MenuItem triggeringItem; private void openSubmenuFor(DrawableMenuItem item) { item.State = MenuItemState.Selected; if (submenu == null) { submenuContainer.Add(submenu = CreateSubMenu()); submenu.parentMenu = this; submenu.StateChanged += submenuStateChanged; } submenu.triggeringItem = item.Item; submenu.Items = item.Item.Items; submenu.Position = item.ToSpaceOfOtherDrawable(new Vector2( Direction == Direction.Vertical ? item.DrawWidth : 0, Direction == Direction.Horizontal ? item.DrawHeight : 0), this); if (item.Item.Items.Count > 0) { if (submenu.State == MenuState.Open) Schedule(delegate { GetContainingInputManager().ChangeFocus(submenu); }); else submenu.Open(); } else submenu.Close(); } private void submenuStateChanged(MenuState state) { switch (state) { case MenuState.Closed: selectedItem.State = MenuItemState.NotSelected; break; case MenuState.Open: selectedItem.State = MenuItemState.Selected; break; } } private ScheduledDelegate openDelegate; private void menuItemHovered(DrawableMenuItem item) { // If we're not a sub-menu, then hover shouldn't display a sub-menu unless an item is clicked if (TopLevelMenu && submenu?.State != MenuState.Open) return; openDelegate?.Cancel(); if (TopLevelMenu || HoverOpenDelay == 0) openSubmenuFor(item); else { openDelegate = Scheduler.AddDelayed(() => { if (item.IsHovered) openSubmenuFor(item); }, HoverOpenDelay); } } public override bool HandleNonPositionalInput => State == MenuState.Open; protected override bool OnKeyDown(KeyDownEvent e) { if (e.Key == Key.Escape && !TopLevelMenu) { Close(); return true; } return base.OnKeyDown(e); } protected override bool OnClick(ClickEvent e) => true; protected override bool OnHover(HoverEvent e) => true; public override bool AcceptsFocus => !TopLevelMenu; public override bool RequestsFocus => !TopLevelMenu && State == MenuState.Open; protected override void OnFocusLost(FocusLostEvent e) { // Case where a sub-menu was opened the focus will be transferred to that sub-menu while this menu will receive OnFocusLost if (submenu?.State == MenuState.Open) return; if (!TopLevelMenu) // At this point we should have lost focus due to clicks outside the menu structure closeAll(); } /// /// Closes all open s. /// private void closeAll() { Close(); parentMenu?.closeFromChild(triggeringItem); } private void closeFromChild(MenuItem source) { if (IsHovered || (parentMenu?.IsHovered ?? false)) return; if (triggeringItem?.Items?.Contains(source) ?? triggeringItem == null) { Close(); parentMenu?.closeFromChild(triggeringItem); } } #endregion /// /// Creates a sub-menu for of s added to this . /// protected abstract Menu CreateSubMenu(); /// /// Creates the visual representation for a . /// /// The that is to be visualised. /// The visual representation. protected abstract DrawableMenuItem CreateDrawableMenuItem(MenuItem item); /// /// Creates the to hold the items of this . /// /// The scrolling direction. /// The . protected abstract ScrollContainer CreateScrollContainer(Direction direction); #region DrawableMenuItem // must be public due to mono bug(?) https://github.com/ppy/osu/issues/1204 public abstract class DrawableMenuItem : CompositeDrawable, IStateful { /// /// Invoked when this 's changes. /// public event Action StateChanged; /// /// Invoked when this is clicked. This occurs regardless of whether or not was /// invoked or not, or whether contains any sub-s. /// internal Action Clicked; /// /// Invoked when this is hovered. This runs one update frame behind the actual hover event. /// internal Action Hovered; /// /// The which this represents. /// public readonly MenuItem Item; /// /// The content of this , created through . /// protected readonly Drawable Content; /// /// The background of this . /// protected readonly Drawable Background; /// /// The foreground of this . This contains the content of this . /// protected readonly Container Foreground; /// /// Whether to close all menus when this action is clicked. /// public virtual bool CloseMenuOnClick => true; protected DrawableMenuItem(MenuItem item) { Item = item; InternalChildren = new[] { Background = CreateBackground(), Foreground = new Container { AutoSizeAxes = Axes.Both, Child = Content = CreateContent() }, }; if (Content is IHasText textContent) { textContent.Text = item.Text.Value; Item.Text.ValueChanged += e => textContent.Text = e.NewValue; } } /// /// Sets various properties of this that depend on the direction in which /// s flow inside the containing (e.g. sizing axes). /// /// The direction in which s will be flowed. public virtual void SetFlowDirection(Direction direction) { RelativeSizeAxes = direction == Direction.Horizontal ? Axes.Y : Axes.X; AutoSizeAxes = direction == Direction.Horizontal ? Axes.X : Axes.Y; } private Color4 backgroundColour = Color4.DarkSlateGray; /// /// Gets or sets the default background colour. /// public Color4 BackgroundColour { get => backgroundColour; set { backgroundColour = value; UpdateBackgroundColour(); } } private Color4 foregroundColour = Color4.White; /// /// Gets or sets the default foreground colour. /// public Color4 ForegroundColour { get => foregroundColour; set { foregroundColour = value; UpdateForegroundColour(); } } private Color4 backgroundColourHover = Color4.DarkGray; /// /// Gets or sets the background colour when this is hovered. /// public Color4 BackgroundColourHover { get => backgroundColourHover; set { backgroundColourHover = value; UpdateBackgroundColour(); } } private Color4 foregroundColourHover = Color4.White; /// /// Gets or sets the foreground colour when this is hovered. /// public Color4 ForegroundColourHover { get => foregroundColourHover; set { foregroundColourHover = value; UpdateForegroundColour(); } } private MenuItemState state; public MenuItemState State { get => state; set { state = value; UpdateForegroundColour(); UpdateBackgroundColour(); StateChanged?.Invoke(state); } } /// /// The draw width of the text of this . /// public float ContentDrawWidth => Content.DrawWidth; /// /// The draw width of the text of this . /// public float ContentDrawHeight => Content.DrawHeight; /// /// Called after the is modified or the hover state changes. /// protected virtual void UpdateBackgroundColour() { Background.FadeColour(IsHovered ? BackgroundColourHover : BackgroundColour); } /// /// Called after the is modified or the hover state changes. /// protected virtual void UpdateForegroundColour() { Foreground.FadeColour(IsHovered ? ForegroundColourHover : ForegroundColour); } protected override void LoadComplete() { base.LoadComplete(); Background.Colour = BackgroundColour; Foreground.Colour = ForegroundColour; } protected override bool OnHover(HoverEvent e) { UpdateBackgroundColour(); UpdateForegroundColour(); Schedule(() => { if (IsHovered) Hovered?.Invoke(this); }); return false; } protected override void OnHoverLost(HoverLostEvent e) { UpdateBackgroundColour(); UpdateForegroundColour(); base.OnHoverLost(e); } private bool hasSubmenu => Item.Items?.Count > 0; protected override bool OnClick(ClickEvent e) { if (Item.Action.Disabled) return true; if (!hasSubmenu) Item.Action.Value?.Invoke(); Clicked?.Invoke(this); return true; } /// /// Creates the background of this . /// protected virtual Drawable CreateBackground() => new Box { RelativeSizeAxes = Axes.Both }; /// /// Creates the content which will be displayed in this . /// If the returned implements , the text will be automatically /// updated when the is updated. /// protected abstract Drawable CreateContent(); } #endregion } public enum MenuState { Closed, Open } public enum MenuItemState { NotSelected, Selected } }