// 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.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osuTK.Graphics; using osuTK.Input; namespace osu.Framework.Graphics.UserInterface { /// /// A drop-down menu to select from a group of values. /// /// Type of value to select. public abstract class Dropdown : CompositeDrawable, IHasCurrentValue { protected internal DropdownHeader Header; protected internal DropdownMenu Menu; /// /// Creates the header part of the control. /// protected abstract DropdownHeader CreateHeader(); /// /// A mapping from menu items to their values. /// private readonly Dictionary> itemMap = new Dictionary>(); protected IEnumerable> MenuItems => itemMap.Values; /// /// Enumerate all values in the dropdown. /// public IEnumerable Items { get => MenuItems.Select(i => i.Value); set { if (boundItemSource != null) throw new InvalidOperationException($"Cannot manually set {nameof(Items)} when an {nameof(ItemSource)} is bound."); setItems(value); } } private void setItems(IEnumerable items) { clearItems(); if (items == null) return; foreach (var entry in items) addDropdownItem(GenerateItemText(entry), entry); if (Current.Value == null || !itemMap.Keys.Contains(Current.Value, EqualityComparer.Default)) Current.Value = itemMap.Keys.FirstOrDefault(); else Current.TriggerChange(); } private readonly IBindableList itemSource = new BindableList(); private IBindableList boundItemSource; /// /// Allows the developer to assign an as the source /// of items for this dropdown. /// public IBindableList ItemSource { get => itemSource; set { if (value == null) throw new ArgumentNullException(nameof(value)); if (boundItemSource != null) itemSource.UnbindFrom(boundItemSource); itemSource.BindTo(boundItemSource = value); } } /// /// Add a menu item directly while automatically generating a label. /// /// Value selected by the menu item. public void AddDropdownItem(T value) => AddDropdownItem(GenerateItemText(value), value); /// /// Add a menu item directly. /// /// Text to display on the menu item. /// Value selected by the menu item. protected void AddDropdownItem(LocalisableString text, T value) { if (boundItemSource != null) throw new InvalidOperationException($"Cannot manually add dropdown items when an {nameof(ItemSource)} is bound."); addDropdownItem(text, value); } private void addDropdownItem(LocalisableString text, T value) { if (itemMap.ContainsKey(value)) throw new ArgumentException($"The item {value} already exists in this {nameof(Dropdown)}."); var newItem = new DropdownMenuItem(text, value, () => { if (!Current.Disabled) Current.Value = value; Menu.State = MenuState.Closed; }); Menu.Add(newItem); itemMap[value] = newItem; } /// /// Remove a menu item directly. /// /// Value of the menu item to be removed. public bool RemoveDropdownItem(T value) { if (boundItemSource != null) throw new InvalidOperationException($"Cannot manually remove items when an {nameof(ItemSource)} is bound."); return removeDropdownItem(value); } private bool removeDropdownItem(T value) { if (value == null) return false; if (!itemMap.TryGetValue(value, out var item)) return false; Menu.Remove(item); itemMap.Remove(value); return true; } protected virtual LocalisableString GenerateItemText(T item) { switch (item) { case MenuItem i: return i.Text.Value; case IHasText t: return t.Text; case Enum e: return e.GetLocalisableDescription(); default: return item?.ToString() ?? "null"; } } private readonly BindableWithCurrent current = new BindableWithCurrent(); public Bindable Current { get => current.Current; set => current.Current = value; } private DropdownMenuItem selectedItem; protected DropdownMenuItem SelectedItem { get => selectedItem; set { if (Current.Disabled) return; selectedItem = value; if (value != null) Current.Value = value.Value; } } protected Dropdown() { AutoSizeAxes = Axes.Y; InternalChild = new FillFlowContainer { Children = new Drawable[] { Header = CreateHeader(), Menu = CreateMenu() }, Direction = FillDirection.Vertical, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y }; Menu.RelativeSizeAxes = Axes.X; Header.Action = Menu.Toggle; Header.ChangeSelection += selectionKeyPressed; Menu.PreselectionConfirmed += preselectionConfirmed; Current.ValueChanged += selectionChanged; Current.DisabledChanged += disabled => { Header.Enabled.Value = !disabled; if (disabled && Menu.State == MenuState.Open) Menu.State = MenuState.Closed; }; ItemSource.CollectionChanged += (_, __) => setItems(ItemSource); } private void preselectionConfirmed(int selectedIndex) { SelectedItem = MenuItems.ElementAtOrDefault(selectedIndex); Menu.State = MenuState.Closed; } private void selectionKeyPressed(DropdownHeader.DropdownSelectionAction action) { if (!MenuItems.Any()) return; var dropdownMenuItems = MenuItems.ToList(); switch (action) { case DropdownHeader.DropdownSelectionAction.Previous: SelectedItem = dropdownMenuItems[Math.Clamp(dropdownMenuItems.IndexOf(SelectedItem) - 1, 0, dropdownMenuItems.Count - 1)]; break; case DropdownHeader.DropdownSelectionAction.Next: SelectedItem = dropdownMenuItems[Math.Clamp(dropdownMenuItems.IndexOf(SelectedItem) + 1, 0, dropdownMenuItems.Count - 1)]; break; case DropdownHeader.DropdownSelectionAction.First: SelectedItem = dropdownMenuItems[0]; break; case DropdownHeader.DropdownSelectionAction.Last: SelectedItem = dropdownMenuItems[^1]; break; default: throw new ArgumentException("Unexpected selection action type.", nameof(action)); } } protected override void LoadComplete() { base.LoadComplete(); Header.Label = SelectedItem?.Text.Value ?? default; } private void selectionChanged(ValueChangedEvent args) { // refresh if SelectedItem and SelectedValue mismatched // null is not a valid value for Dictionary, so neither here if (args.NewValue == null && SelectedItem != null) { selectedItem = new DropdownMenuItem(default, default); } else if (SelectedItem == null || !EqualityComparer.Default.Equals(SelectedItem.Value, args.NewValue)) { if (!itemMap.TryGetValue(args.NewValue, out selectedItem)) { selectedItem = new DropdownMenuItem(GenerateItemText(args.NewValue), args.NewValue); } } Menu.SelectItem(selectedItem); Header.Label = selectedItem.Text.Value; } /// /// Clear all the menu items. /// public void ClearItems() { if (boundItemSource != null) throw new InvalidOperationException($"Cannot manually clear items when an {nameof(ItemSource)} is bound."); clearItems(); } private void clearItems() { itemMap.Clear(); Menu.Clear(); } /// /// Hide the menu item of specified value. /// /// The value to hide. internal void HideItem(T val) { if (itemMap.TryGetValue(val, out DropdownMenuItem item)) { Menu.HideItem(item); updateHeaderVisibility(); } } /// /// Show the menu item of specified value. /// /// The value to show. internal void ShowItem(T val) { if (itemMap.TryGetValue(val, out DropdownMenuItem item)) { Menu.ShowItem(item); updateHeaderVisibility(); } } private void updateHeaderVisibility() => Header.Alpha = Menu.AnyPresent ? 1 : 0; /// /// Creates the menu body. /// protected abstract DropdownMenu CreateMenu(); #region DropdownMenu public abstract class DropdownMenu : Menu, IKeyBindingHandler { protected DropdownMenu() : base(Direction.Vertical) { StateChanged += clearPreselection; } public override void Add(MenuItem item) { base.Add(item); var drawableDropdownMenuItem = (DrawableDropdownMenuItem)ItemsContainer.Single(drawableItem => drawableItem.Item == item); drawableDropdownMenuItem.PreselectionRequested += PreselectItem; } private void clearPreselection(MenuState obj) { if (obj == MenuState.Closed) PreselectItem(null); } protected internal IEnumerable DrawableMenuItems => Children.OfType(); protected internal IEnumerable VisibleMenuItems => DrawableMenuItems.Where(item => !item.IsMaskedAway); public DrawableDropdownMenuItem PreselectedItem => DrawableMenuItems.FirstOrDefault(c => c.IsPreSelected) ?? DrawableMenuItems.FirstOrDefault(c => c.IsSelected); public event Action PreselectionConfirmed; /// /// Selects an item from this . /// /// The item to select. public void SelectItem(DropdownMenuItem item) { Children.OfType().ForEach(c => { c.IsSelected = c.Item == item; if (c.IsSelected) ContentContainer.ScrollIntoView(c); }); } /// /// Shows an item from this . /// /// The item to show. public void HideItem(DropdownMenuItem item) => Children.FirstOrDefault(c => c.Item == item)?.Hide(); /// /// Hides an item from this /// /// public void ShowItem(DropdownMenuItem item) => Children.FirstOrDefault(c => c.Item == item)?.Show(); /// /// Whether any items part of this are present. /// public bool AnyPresent => Children.Any(c => c.IsPresent); protected void PreselectItem(int index) => PreselectItem(Items[Math.Clamp(index, 0, DrawableMenuItems.Count() - 1)]); /// /// Preselects an item from this . /// /// The item to select. protected void PreselectItem(MenuItem item) { Children.OfType().ForEach(c => { c.IsPreSelected = c.Item == item; if (c.IsPreSelected) ContentContainer.ScrollIntoView(c); }); } protected sealed override DrawableMenuItem CreateDrawableMenuItem(MenuItem item) => CreateDrawableDropdownMenuItem(item); protected abstract DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item); #region DrawableDropdownMenuItem // must be public due to mono bug(?) https://github.com/ppy/osu/issues/1204 public abstract class DrawableDropdownMenuItem : DrawableMenuItem { public event Action> PreselectionRequested; protected DrawableDropdownMenuItem(MenuItem item) : base(item) { } private bool selected; public bool IsSelected { get => !Item.Action.Disabled && selected; set { if (selected == value) return; selected = value; OnSelectChange(); } } private bool preSelected; /// /// Denotes whether this menu item will be selected on press. /// This property is related to selecting menu items using keyboard or hovering. /// public bool IsPreSelected { get => preSelected; set { if (preSelected == value) return; preSelected = value; OnSelectChange(); } } private Color4 backgroundColourSelected = Color4.SlateGray; public Color4 BackgroundColourSelected { get => backgroundColourSelected; set { backgroundColourSelected = value; UpdateBackgroundColour(); } } private Color4 foregroundColourSelected = Color4.White; public Color4 ForegroundColourSelected { get => foregroundColourSelected; set { foregroundColourSelected = value; UpdateForegroundColour(); } } protected virtual void OnSelectChange() { if (!IsLoaded) return; UpdateBackgroundColour(); UpdateForegroundColour(); } protected override void UpdateBackgroundColour() { Background.FadeColour(IsPreSelected ? BackgroundColourHover : IsSelected ? BackgroundColourSelected : BackgroundColour); } protected override void UpdateForegroundColour() { Foreground.FadeColour(IsPreSelected ? ForegroundColourHover : IsSelected ? ForegroundColourSelected : ForegroundColour); } protected override void LoadComplete() { base.LoadComplete(); Background.Colour = IsSelected ? BackgroundColourSelected : BackgroundColour; Foreground.Colour = IsSelected ? ForegroundColourSelected : ForegroundColour; } protected override bool OnHover(HoverEvent e) { PreselectionRequested?.Invoke(Item as DropdownMenuItem); return base.OnHover(e); } } #endregion protected override bool OnKeyDown(KeyDownEvent e) { var drawableMenuItemsList = DrawableMenuItems.ToList(); if (!drawableMenuItemsList.Any()) return base.OnKeyDown(e); var currentPreselected = PreselectedItem; var targetPreselectionIndex = drawableMenuItemsList.IndexOf(currentPreselected); switch (e.Key) { case Key.Up: PreselectItem(targetPreselectionIndex - 1); return true; case Key.Down: PreselectItem(targetPreselectionIndex + 1); return true; case Key.PageUp: var firstVisibleItem = VisibleMenuItems.First(); if (currentPreselected == firstVisibleItem) PreselectItem(targetPreselectionIndex - VisibleMenuItems.Count()); else PreselectItem(drawableMenuItemsList.IndexOf(firstVisibleItem)); return true; case Key.PageDown: var lastVisibleItem = VisibleMenuItems.Last(); if (currentPreselected == lastVisibleItem) PreselectItem(targetPreselectionIndex + VisibleMenuItems.Count()); else PreselectItem(drawableMenuItemsList.IndexOf(lastVisibleItem)); return true; case Key.Enter: PreselectionConfirmed?.Invoke(targetPreselectionIndex); return true; case Key.Escape: State = MenuState.Closed; return true; default: return base.OnKeyDown(e); } } public bool OnPressed(PlatformAction action) { switch (action) { case PlatformAction.MoveToListStart: PreselectItem(Items.FirstOrDefault()); return true; case PlatformAction.MoveToListEnd: PreselectItem(Items.LastOrDefault()); return true; default: return false; } } public void OnReleased(PlatformAction action) { } } #endregion } }