mirror of
https://github.com/SK-la/osu-framework.git
synced 2026-03-13 11:20:31 +00:00
983 lines
36 KiB
C#
983 lines
36 KiB
C#
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
|
// See the LICENCE file in the repository root for full licence text.
|
|
|
|
#nullable disable
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using JetBrains.Annotations;
|
|
using osu.Framework.Extensions.EnumExtensions;
|
|
using osu.Framework.Extensions.IEnumerableExtensions;
|
|
using osu.Framework.Extensions.ObjectExtensions;
|
|
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.Localisation;
|
|
using osu.Framework.Utils;
|
|
using osu.Framework.Threading;
|
|
using osuTK;
|
|
using osuTK.Input;
|
|
|
|
namespace osu.Framework.Graphics.UserInterface
|
|
{
|
|
public abstract partial class Menu : CompositeDrawable, IStateful<MenuState>
|
|
{
|
|
/// <summary>
|
|
/// Invoked when this <see cref="Menu"/>'s <see cref="State"/> changes.
|
|
/// </summary>
|
|
[CanBeNull]
|
|
public event Action<MenuState> StateChanged;
|
|
|
|
/// <summary>
|
|
/// Gets or sets the delay before opening sub-<see cref="Menu"/>s when menu items are hovered.
|
|
/// </summary>
|
|
protected double HoverOpenDelay = 100;
|
|
|
|
/// <summary>
|
|
/// Whether this menu is always displayed in an open state (ie. a menu bar).
|
|
/// Clicks are required to activate <see cref="DrawableMenuItem"/>.
|
|
/// </summary>
|
|
protected readonly bool TopLevelMenu;
|
|
|
|
/// <summary>
|
|
/// The <see cref="Container{T}"/> that contains the content of this <see cref="Menu"/>.
|
|
/// </summary>
|
|
protected readonly ScrollContainer<Drawable> ContentContainer;
|
|
|
|
/// <summary>
|
|
/// The <see cref="Container{T}"/> that contains the items of this <see cref="Menu"/>.
|
|
/// </summary>
|
|
// this is intentionally not a FillFlowContainer, as to not allow the consumers to mutate the layout position of menu items,
|
|
// since we manage it ourselves to define a specific order for menu items and allow inserting ones between others.
|
|
protected Container<DrawableMenuItem> ItemsContainer => itemsFlow;
|
|
|
|
private FillFlowContainer<DrawableMenuItem> itemsFlow;
|
|
|
|
/// <summary>
|
|
/// The container that provides the masking effects for this <see cref="Menu"/>.
|
|
/// </summary>
|
|
protected readonly Container MaskingContainer;
|
|
|
|
/// <summary>
|
|
/// Gets the item representations contained by this <see cref="Menu"/>.
|
|
/// </summary>
|
|
protected internal IReadOnlyList<DrawableMenuItem> Children => itemsFlow.Children;
|
|
|
|
protected readonly Direction Direction;
|
|
|
|
private Menu parentMenu;
|
|
private Menu submenu;
|
|
|
|
private readonly Box background;
|
|
|
|
private readonly Container<Menu> submenuContainer;
|
|
private readonly LayoutValue positionLayout = new LayoutValue(Invalidation.DrawInfo | Invalidation.RequiredParentSizeToFit);
|
|
|
|
/// <summary>
|
|
/// Constructs a menu.
|
|
/// </summary>
|
|
/// <param name="direction">The direction of layout for this menu.</param>
|
|
/// <param name="topLevelMenu">Whether the resultant menu is always displayed in an open state (ie. a menu bar).</param>
|
|
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 = itemsFlow = (FillFlowContainer<DrawableMenuItem>)CreateItemsFlow(direction == Direction.Horizontal ? FillDirection.Horizontal : FillDirection.Vertical);
|
|
})
|
|
}
|
|
},
|
|
submenuContainer = new Container<Menu>
|
|
{
|
|
Name = "Sub menu container",
|
|
AutoSizeAxes = Axes.Both
|
|
}
|
|
};
|
|
|
|
switch (direction)
|
|
{
|
|
case Direction.Horizontal:
|
|
itemsFlow.AutoSizeAxes = Axes.X;
|
|
break;
|
|
|
|
case Direction.Vertical:
|
|
itemsFlow.AutoSizeAxes = Axes.Y;
|
|
break;
|
|
}
|
|
|
|
// The menu will provide a valid size for the items container based on our own size
|
|
itemsFlow.RelativeSizeAxes = Axes.Both & ~itemsFlow.AutoSizeAxes;
|
|
|
|
AddLayout(positionLayout);
|
|
}
|
|
|
|
protected override void LoadComplete()
|
|
{
|
|
base.LoadComplete();
|
|
updateState();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the <see cref="MenuItem"/>s contained within this <see cref="Menu"/>.
|
|
/// </summary>
|
|
public IReadOnlyList<MenuItem> Items
|
|
{
|
|
get => itemsFlow.Select(r => r.Item).ToList();
|
|
set
|
|
{
|
|
Clear();
|
|
value?.ForEach(Add);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the background colour of this <see cref="Menu"/>.
|
|
/// </summary>
|
|
public Color4 BackgroundColour
|
|
{
|
|
get => background.Colour;
|
|
set => background.Colour = value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets whether the scroll bar of this <see cref="Menu"/> should be visible.
|
|
/// </summary>
|
|
public bool ScrollbarVisible
|
|
{
|
|
get => ContentContainer.ScrollbarVisible;
|
|
set => ContentContainer.ScrollbarVisible = value;
|
|
}
|
|
|
|
private float maxWidth = float.MaxValue;
|
|
|
|
/// <summary>
|
|
/// Gets or sets the maximum allowable width by this <see cref="Menu"/>.
|
|
/// </summary>
|
|
public float MaxWidth
|
|
{
|
|
get => maxWidth;
|
|
set
|
|
{
|
|
if (Precision.AlmostEquals(maxWidth, value))
|
|
return;
|
|
|
|
maxWidth = value;
|
|
|
|
((IItemsFlow)itemsFlow).SizeCache.Invalidate();
|
|
}
|
|
}
|
|
|
|
private float maxHeight = float.PositiveInfinity;
|
|
|
|
/// <summary>
|
|
/// Gets or sets the maximum allowable height by this <see cref="Menu"/>.
|
|
/// </summary>
|
|
public float MaxHeight
|
|
{
|
|
get => maxHeight;
|
|
set
|
|
{
|
|
if (Precision.AlmostEquals(maxHeight, value))
|
|
return;
|
|
|
|
maxHeight = value;
|
|
|
|
((IItemsFlow)itemsFlow).SizeCache.Invalidate();
|
|
}
|
|
}
|
|
|
|
private MenuState state = MenuState.Closed;
|
|
|
|
/// <summary>
|
|
/// Gets or sets the current state of this <see cref="Menu"/>.
|
|
/// </summary>
|
|
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)
|
|
GetContainingFocusManager()?.ChangeFocus(parentMenu);
|
|
break;
|
|
|
|
case MenuState.Open:
|
|
ContentContainer.ScrollToStart(false);
|
|
|
|
AnimateOpen();
|
|
|
|
// We may not be present at this point, so must run on the next frame.
|
|
if (!TopLevelMenu)
|
|
{
|
|
Schedule(delegate
|
|
{
|
|
if (State == MenuState.Open) GetContainingFocusManager().AsNonNull().ChangeFocus(this);
|
|
});
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void resetState()
|
|
{
|
|
if (!IsLoaded)
|
|
return;
|
|
|
|
submenu?.Close();
|
|
((IItemsFlow)itemsFlow).SizeCache.Invalidate();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a <see cref="MenuItem"/> to this <see cref="Menu"/>.
|
|
/// </summary>
|
|
/// <param name="item">The <see cref="MenuItem"/> to add.</param>
|
|
public void Add(MenuItem item) => Insert(itemsFlow.Count, item);
|
|
|
|
/// <summary>
|
|
/// Inserts a <see cref="MenuItem"/> at a specified position inside this <see cref="Menu"/>.
|
|
/// </summary>
|
|
/// <param name="position">The position to insert this item at.</param>
|
|
/// <param name="item">The <see cref="MenuItem"/> to insert.</param>
|
|
public void Insert(int position, MenuItem item)
|
|
{
|
|
var drawableItem = CreateDrawableMenuItem(item);
|
|
drawableItem.Clicked = menuItemClicked;
|
|
drawableItem.Hovered = menuItemHovered;
|
|
drawableItem.StateChanged += s => itemStateChanged(drawableItem, s);
|
|
|
|
drawableItem.SetFlowDirection(Direction);
|
|
|
|
var items = Children.OrderBy(itemsFlow.GetLayoutPosition).ToList();
|
|
|
|
for (int i = position; i < items.Count; i++)
|
|
itemsFlow.SetLayoutPosition(items[i], i + 1);
|
|
|
|
itemsFlow.Insert(position, drawableItem);
|
|
((IItemsFlow)itemsFlow).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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes a <see cref="MenuItem"/> from this <see cref="Menu"/>.
|
|
/// </summary>
|
|
/// <param name="item">The <see cref="MenuItem"/> to remove.</param>
|
|
/// <returns>Whether <paramref name="item"/> was successfully removed.</returns>
|
|
public bool Remove(MenuItem item)
|
|
{
|
|
var items = Children.OrderBy(itemsFlow.GetLayoutPosition).ToList();
|
|
bool removed = false;
|
|
|
|
for (int i = 0; i < items.Count; i++)
|
|
{
|
|
var d = items[i];
|
|
|
|
if (d.Item == item)
|
|
{
|
|
for (int j = i + 1; j < items.Count; j++)
|
|
itemsFlow.SetLayoutPosition(items[j], j - 1);
|
|
|
|
itemsFlow.Remove(d, true);
|
|
items.RemoveAt(i--);
|
|
removed = true;
|
|
}
|
|
}
|
|
|
|
((IItemsFlow)itemsFlow).SizeCache.Invalidate();
|
|
return removed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears all <see cref="MenuItem"/>s in this <see cref="Menu"/>.
|
|
/// </summary>
|
|
public void Clear()
|
|
{
|
|
itemsFlow.Clear();
|
|
resetState();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens this <see cref="Menu"/>.
|
|
/// </summary>
|
|
public void Open() => State = MenuState.Open;
|
|
|
|
/// <summary>
|
|
/// Closes this <see cref="Menu"/>.
|
|
/// </summary>
|
|
public void Close() => State = MenuState.Closed;
|
|
|
|
/// <summary>
|
|
/// Toggles the state of this <see cref="Menu"/>.
|
|
/// </summary>
|
|
public void Toggle() => State = State == MenuState.Closed ? MenuState.Open : MenuState.Closed;
|
|
|
|
/// <summary>
|
|
/// Animates the opening of this <see cref="Menu"/>.
|
|
/// </summary>
|
|
protected virtual void AnimateOpen() => Show();
|
|
|
|
/// <summary>
|
|
/// Animates the closing of this <see cref="Menu"/>.
|
|
/// </summary>
|
|
protected virtual void AnimateClose() => Hide();
|
|
|
|
protected override void Update()
|
|
{
|
|
base.Update();
|
|
|
|
if (!positionLayout.IsValid && State == MenuState.Open && parentMenu != null)
|
|
{
|
|
var inputManager = GetContainingInputManager().AsNonNull();
|
|
|
|
// This is the default position to which this menu should be anchored to the parent menu item which triggered it (top left of the triggering item)
|
|
var triggeringItemTopLeftPosition = triggeringItem.ToSpaceOfOtherDrawable(Vector2.Zero, parentMenu);
|
|
|
|
// The "maximum" position is the worst case position of the bottom right corner of this menu
|
|
// if this menu is anchored top-left to the triggering item.
|
|
var menuMaximumPosition = triggeringItem.ToSpaceOfOtherDrawable(
|
|
new Vector2(
|
|
triggeringItem.DrawWidth + DrawWidth,
|
|
triggeringItem.DrawHeight + DrawHeight), inputManager);
|
|
|
|
// The "minimum" position is the worst case position of the top left corner of this menu
|
|
// if this menu is anchored bottom-right to the parent menu item that triggered it.
|
|
var menuMinimumPosition = triggeringItem.ToSpaceOfOtherDrawable(new Vector2(-DrawWidth, -DrawHeight), inputManager);
|
|
|
|
// We will be making anchor adjustments by changing the parent's "submenu container" to be positioned and anchored correctly to the parent menu.
|
|
// Therefore note that all X and Y adjustments below will occur in the parent menu's coordinates.
|
|
var parentSubmenuContainer = parentMenu.submenuContainer;
|
|
|
|
if (parentMenu.Direction == Direction.Vertical)
|
|
{
|
|
// If this menu won't fit on the screen horizontally if it's anchored to the right of its triggering item, but it will fit when anchored to the left...
|
|
if (menuMaximumPosition.X > inputManager.DrawWidth && menuMinimumPosition.X > 0)
|
|
{
|
|
// switch the origin and position of the submenu container so that it's right-aligned to the left side of the triggering item.
|
|
parentSubmenuContainer.Origin = switchAxisAnchors(parentSubmenuContainer.Origin, Anchor.x0, Anchor.x2);
|
|
parentSubmenuContainer.X = triggeringItemTopLeftPosition.X;
|
|
}
|
|
else
|
|
{
|
|
// otherwise, switch the origin and position of the submenu container so that it's left-aligned to the right side of the triggering item.
|
|
parentSubmenuContainer.Origin = switchAxisAnchors(parentSubmenuContainer.Origin, Anchor.x2, Anchor.x0);
|
|
parentSubmenuContainer.X = triggeringItemTopLeftPosition.X + triggeringItem.DrawWidth;
|
|
}
|
|
|
|
// If this menu won't fit on the screen vertically if its top edge is aligned to the top of the triggering item,
|
|
// but it will fit if its bottom edge is aligned to the bottom of the triggering item...
|
|
if (menuMaximumPosition.Y > inputManager.DrawHeight && menuMinimumPosition.Y > 0)
|
|
{
|
|
// switch the origin and position of the submenu container so that it's bottom-aligned to the bottom of the triggering item.
|
|
parentSubmenuContainer.Origin = switchAxisAnchors(parentSubmenuContainer.Origin, Anchor.y0, Anchor.y2);
|
|
parentSubmenuContainer.Y = triggeringItemTopLeftPosition.Y + triggeringItem.DrawHeight;
|
|
}
|
|
else
|
|
{
|
|
// otherwise, switch the origin and position of the submenu container so that it's top-aligned to the top of the triggering item.
|
|
parentSubmenuContainer.Origin = switchAxisAnchors(parentSubmenuContainer.Origin, Anchor.y2, Anchor.y0);
|
|
parentSubmenuContainer.Y = triggeringItemTopLeftPosition.Y;
|
|
}
|
|
}
|
|
// the "horizontal" case is the same as above, but with the axes everywhere swapped.
|
|
else
|
|
{
|
|
if (menuMaximumPosition.Y > inputManager.DrawHeight && menuMinimumPosition.Y > 0)
|
|
{
|
|
parentSubmenuContainer.Origin = switchAxisAnchors(parentSubmenuContainer.Origin, Anchor.y0, Anchor.y2);
|
|
parentSubmenuContainer.Y = triggeringItemTopLeftPosition.Y;
|
|
}
|
|
else
|
|
{
|
|
parentSubmenuContainer.Origin = switchAxisAnchors(parentSubmenuContainer.Origin, Anchor.y2, Anchor.y0);
|
|
parentSubmenuContainer.Y = triggeringItemTopLeftPosition.Y + triggeringItem.DrawHeight;
|
|
}
|
|
|
|
if (menuMaximumPosition.X > inputManager.DrawWidth && menuMinimumPosition.X > 0)
|
|
{
|
|
parentSubmenuContainer.Origin = switchAxisAnchors(parentSubmenuContainer.Origin, Anchor.x0, Anchor.x2);
|
|
parentSubmenuContainer.X = triggeringItemTopLeftPosition.X + triggeringItem.DrawWidth;
|
|
}
|
|
else
|
|
{
|
|
parentSubmenuContainer.Origin = switchAxisAnchors(parentSubmenuContainer.Origin, Anchor.x2, Anchor.x0);
|
|
parentSubmenuContainer.X = triggeringItemTopLeftPosition.X;
|
|
}
|
|
}
|
|
|
|
positionLayout.Validate();
|
|
|
|
static Anchor switchAxisAnchors(Anchor originalValue, Anchor toDisable, Anchor toEnable) => (originalValue & ~toDisable) | toEnable;
|
|
}
|
|
}
|
|
|
|
protected override void UpdateAfterChildren()
|
|
{
|
|
base.UpdateAfterChildren();
|
|
|
|
if (!((IItemsFlow)itemsFlow).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, itemsFlow 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 ? itemsFlow.Width : width;
|
|
height = Direction == Direction.Vertical ? itemsFlow.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));
|
|
|
|
((IItemsFlow)itemsFlow).SizeCache.Validate();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resizes this <see cref="Menu"/>.
|
|
/// </summary>
|
|
/// <param name="newSize">The new size.</param>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// The item which triggered opening us as a submenu.
|
|
/// </summary>
|
|
private DrawableMenuItem 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;
|
|
submenu.positionLayout.Invalidate();
|
|
|
|
submenu.Items = item.Item.Items;
|
|
|
|
if (item.Item.Items.Count > 0)
|
|
{
|
|
if (submenu.State == MenuState.Open)
|
|
Schedule(delegate { GetContainingFocusManager().AsNonNull().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 OnMouseDown(MouseDownEvent e) => true;
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Closes all open <see cref="Menu"/>s.
|
|
/// </summary>
|
|
private void closeAll()
|
|
{
|
|
Close();
|
|
parentMenu?.closeFromChild(triggeringItem.Item);
|
|
}
|
|
|
|
private void closeFromChild(MenuItem source)
|
|
{
|
|
if (IsHovered || (parentMenu?.IsHovered ?? false)) return;
|
|
|
|
if (triggeringItem?.Item.Items.Contains(source) ?? triggeringItem == null)
|
|
{
|
|
Close();
|
|
parentMenu?.closeFromChild(triggeringItem?.Item);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// Creates a sub-menu for <see cref="MenuItem.Items"/> of <see cref="MenuItem"/>s added to this <see cref="Menu"/>.
|
|
/// </summary>
|
|
protected abstract Menu CreateSubMenu();
|
|
|
|
/// <summary>
|
|
/// Creates the visual representation for a <see cref="MenuItem"/>.
|
|
/// </summary>
|
|
/// <param name="item">The <see cref="MenuItem"/> that is to be visualised.</param>
|
|
/// <returns>The visual representation.</returns>
|
|
protected abstract DrawableMenuItem CreateDrawableMenuItem(MenuItem item);
|
|
|
|
/// <summary>
|
|
/// Creates the <see cref="ScrollContainer{T}"/> to hold the items of this <see cref="Menu"/>.
|
|
/// </summary>
|
|
/// <param name="direction">The scrolling direction.</param>
|
|
/// <returns>The <see cref="ScrollContainer{T}"/>.</returns>
|
|
protected abstract ScrollContainer<Drawable> CreateScrollContainer(Direction direction);
|
|
|
|
internal virtual IItemsFlow CreateItemsFlow(FillDirection direction) => new ItemsFlow { Direction = direction };
|
|
|
|
#region DrawableMenuItem
|
|
|
|
// must be public due to mono bug(?) https://github.com/ppy/osu/issues/1204
|
|
public abstract partial class DrawableMenuItem : CompositeDrawable, IStateful<MenuItemState>
|
|
{
|
|
/// <summary>
|
|
/// Invoked when this <see cref="DrawableMenuItem"/>'s <see cref="State"/> changes.
|
|
/// </summary>
|
|
[CanBeNull]
|
|
public event Action<MenuItemState> StateChanged;
|
|
|
|
/// <summary>
|
|
/// Invoked when this <see cref="DrawableMenuItem"/> is clicked and successfully invokes an action or opens a submenu.
|
|
/// </summary>
|
|
internal Action<DrawableMenuItem> Clicked;
|
|
|
|
/// <summary>
|
|
/// Invoked when this <see cref="DrawableMenuItem"/> is hovered. This runs one update frame behind the actual hover event.
|
|
/// </summary>
|
|
internal Action<DrawableMenuItem> Hovered;
|
|
|
|
/// <summary>
|
|
/// The <see cref="MenuItem"/> which this <see cref="DrawableMenuItem"/> represents.
|
|
/// </summary>
|
|
public readonly MenuItem Item;
|
|
|
|
/// <summary>
|
|
/// The content of this <see cref="DrawableMenuItem"/>, created through <see cref="CreateContent"/>.
|
|
/// </summary>
|
|
protected readonly Drawable Content;
|
|
|
|
/// <summary>
|
|
/// The background of this <see cref="DrawableMenuItem"/>.
|
|
/// </summary>
|
|
protected readonly Drawable Background;
|
|
|
|
/// <summary>
|
|
/// The foreground of this <see cref="DrawableMenuItem"/>. This contains the content of this <see cref="DrawableMenuItem"/>.
|
|
/// </summary>
|
|
protected readonly Container Foreground;
|
|
|
|
/// <summary>
|
|
/// Whether to close all menus when this action <see cref="DrawableMenuItem"/> is clicked.
|
|
/// </summary>
|
|
public virtual bool CloseMenuOnClick => true;
|
|
|
|
public IEnumerable<LocalisableString> FilterTerms => Item.Text.Value.Yield();
|
|
|
|
protected DrawableMenuItem(MenuItem item)
|
|
{
|
|
Item = item;
|
|
|
|
// Edge case where action might be changed while item is already hovered.
|
|
Item.Action.BindDisabledChanged(_ =>
|
|
{
|
|
Scheduler.AddOnce(UpdateBackgroundColour);
|
|
Scheduler.AddOnce(UpdateForegroundColour);
|
|
});
|
|
|
|
Item.Action.BindValueChanged(_ =>
|
|
{
|
|
Scheduler.AddOnce(UpdateBackgroundColour);
|
|
Scheduler.AddOnce(UpdateForegroundColour);
|
|
});
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets various properties of this <see cref="DrawableMenuItem"/> that depend on the direction in which
|
|
/// <see cref="DrawableMenuItem"/>s flow inside the containing <see cref="Menu"/> (e.g. sizing axes).
|
|
/// </summary>
|
|
/// <param name="direction">The direction in which <see cref="DrawableMenuItem"/>s will be flowed.</param>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Gets or sets the default background colour.
|
|
/// </summary>
|
|
public Color4 BackgroundColour
|
|
{
|
|
get => backgroundColour;
|
|
set
|
|
{
|
|
backgroundColour = value;
|
|
Scheduler.AddOnce(UpdateBackgroundColour);
|
|
}
|
|
}
|
|
|
|
private Color4 foregroundColour = Color4.White;
|
|
|
|
/// <summary>
|
|
/// Gets or sets the default foreground colour.
|
|
/// </summary>
|
|
public Color4 ForegroundColour
|
|
{
|
|
get => foregroundColour;
|
|
set
|
|
{
|
|
foregroundColour = value;
|
|
Scheduler.AddOnce(UpdateForegroundColour);
|
|
}
|
|
}
|
|
|
|
private Color4 backgroundColourHover = Color4.DarkGray;
|
|
|
|
/// <summary>
|
|
/// Gets or sets the background colour when this <see cref="DrawableMenuItem"/> is hovered.
|
|
/// </summary>
|
|
public Color4 BackgroundColourHover
|
|
{
|
|
get => backgroundColourHover;
|
|
set
|
|
{
|
|
backgroundColourHover = value;
|
|
Scheduler.AddOnce(UpdateBackgroundColour);
|
|
}
|
|
}
|
|
|
|
private Color4 foregroundColourHover = Color4.White;
|
|
|
|
/// <summary>
|
|
/// Gets or sets the foreground colour when this <see cref="DrawableMenuItem"/> is hovered.
|
|
/// </summary>
|
|
public Color4 ForegroundColourHover
|
|
{
|
|
get => foregroundColourHover;
|
|
set
|
|
{
|
|
foregroundColourHover = value;
|
|
Scheduler.AddOnce(UpdateForegroundColour);
|
|
}
|
|
}
|
|
|
|
private MenuItemState state;
|
|
|
|
public MenuItemState State
|
|
{
|
|
get => state;
|
|
set
|
|
{
|
|
state = value;
|
|
|
|
Scheduler.AddOnce(UpdateBackgroundColour);
|
|
Scheduler.AddOnce(UpdateForegroundColour);
|
|
|
|
StateChanged?.Invoke(state);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The draw width of the text of this <see cref="DrawableMenuItem"/>.
|
|
/// </summary>
|
|
public float ContentDrawWidth => Content.DrawWidth;
|
|
|
|
/// <summary>
|
|
/// The draw width of the text of this <see cref="DrawableMenuItem"/>.
|
|
/// </summary>
|
|
public float ContentDrawHeight => Content.DrawHeight;
|
|
|
|
/// <summary>
|
|
/// Whether the underlying <see cref="Item"/> has an assigned action or a submenu, and is not in a disabled state.
|
|
/// </summary>
|
|
protected bool IsActionable => hasSubmenu || (!Item.Action.Disabled && Item.Action.Value != null);
|
|
|
|
private bool hasSubmenu => Item.Items.Count > 0;
|
|
|
|
/// <summary>
|
|
/// Called after the <see cref="BackgroundColour"/> is modified or the hover state changes.
|
|
/// </summary>
|
|
protected virtual void UpdateBackgroundColour()
|
|
{
|
|
Background.FadeColour(IsHovered && IsActionable ? BackgroundColourHover : BackgroundColour);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called after the <see cref="ForegroundColour"/> is modified or the hover state changes.
|
|
/// </summary>
|
|
protected virtual void UpdateForegroundColour()
|
|
{
|
|
Foreground.FadeColour(IsHovered && IsActionable ? ForegroundColourHover : ForegroundColour);
|
|
}
|
|
|
|
protected override void LoadComplete()
|
|
{
|
|
base.LoadComplete();
|
|
|
|
Scheduler.AddOnce(UpdateBackgroundColour);
|
|
Scheduler.AddOnce(UpdateForegroundColour);
|
|
}
|
|
|
|
protected override bool OnHover(HoverEvent e)
|
|
{
|
|
Scheduler.AddOnce(UpdateBackgroundColour);
|
|
Scheduler.AddOnce(UpdateForegroundColour);
|
|
|
|
Schedule(() =>
|
|
{
|
|
if (IsHovered)
|
|
Hovered?.Invoke(this);
|
|
});
|
|
|
|
return false;
|
|
}
|
|
|
|
protected override void OnHoverLost(HoverLostEvent e)
|
|
{
|
|
Scheduler.AddOnce(UpdateBackgroundColour);
|
|
Scheduler.AddOnce(UpdateForegroundColour);
|
|
base.OnHoverLost(e);
|
|
}
|
|
|
|
protected override bool OnClick(ClickEvent e)
|
|
{
|
|
if (hasSubmenu)
|
|
{
|
|
Clicked?.Invoke(this);
|
|
return true;
|
|
}
|
|
|
|
if (!IsActionable)
|
|
return true;
|
|
|
|
Item.Action.Value?.Invoke();
|
|
Clicked?.Invoke(this);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the background of this <see cref="DrawableMenuItem"/>.
|
|
/// </summary>
|
|
protected virtual Drawable CreateBackground() => new Box { RelativeSizeAxes = Axes.Both };
|
|
|
|
/// <summary>
|
|
/// Creates the content which will be displayed in this <see cref="DrawableMenuItem"/>.
|
|
/// If the <see cref="Drawable"/> returned implements <see cref="IHasText"/>, the text will be automatically
|
|
/// updated when the <see cref="MenuItem.Text"/> is updated.
|
|
/// </summary>
|
|
protected abstract Drawable CreateContent();
|
|
}
|
|
|
|
#endregion
|
|
|
|
internal interface IItemsFlow : IFillFlowContainer
|
|
{
|
|
LayoutValue SizeCache { get; }
|
|
}
|
|
|
|
private partial class ItemsFlow : FillFlowContainer<DrawableMenuItem>, IItemsFlow
|
|
{
|
|
public LayoutValue SizeCache { get; } = new LayoutValue(Invalidation.RequiredParentSizeToFit, InvalidationSource.Self);
|
|
|
|
public ItemsFlow()
|
|
{
|
|
AddLayout(SizeCache);
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum MenuState
|
|
{
|
|
Closed,
|
|
Open
|
|
}
|
|
|
|
public enum MenuItemState
|
|
{
|
|
NotSelected,
|
|
Selected
|
|
}
|
|
}
|