A game framework written with osu! in mind.
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2// See the LICENCE file in the repository root for full licence text.
3
4using System;
5using System.Collections.Generic;
6using System.Linq;
7using osu.Framework.Bindables;
8using osu.Framework.Extensions;
9using osu.Framework.Extensions.IEnumerableExtensions;
10using osu.Framework.Graphics.Containers;
11using osu.Framework.Graphics.Sprites;
12using osu.Framework.Input;
13using osu.Framework.Input.Bindings;
14using osu.Framework.Input.Events;
15using osu.Framework.Localisation;
16using osuTK.Graphics;
17using osuTK.Input;
18
19namespace osu.Framework.Graphics.UserInterface
20{
21 /// <summary>
22 /// A drop-down menu to select from a group of values.
23 /// </summary>
24 /// <typeparam name="T">Type of value to select.</typeparam>
25 public abstract class Dropdown<T> : CompositeDrawable, IHasCurrentValue<T>
26 {
27 protected internal DropdownHeader Header;
28 protected internal DropdownMenu Menu;
29
30 /// <summary>
31 /// Creates the header part of the control.
32 /// </summary>
33 protected abstract DropdownHeader CreateHeader();
34
35 /// <summary>
36 /// A mapping from menu items to their values.
37 /// </summary>
38 private readonly Dictionary<T, DropdownMenuItem<T>> itemMap = new Dictionary<T, DropdownMenuItem<T>>();
39
40 protected IEnumerable<DropdownMenuItem<T>> MenuItems => itemMap.Values;
41
42 /// <summary>
43 /// Enumerate all values in the dropdown.
44 /// </summary>
45 public IEnumerable<T> Items
46 {
47 get => MenuItems.Select(i => i.Value);
48 set
49 {
50 if (boundItemSource != null)
51 throw new InvalidOperationException($"Cannot manually set {nameof(Items)} when an {nameof(ItemSource)} is bound.");
52
53 setItems(value);
54 }
55 }
56
57 private void setItems(IEnumerable<T> items)
58 {
59 clearItems();
60 if (items == null)
61 return;
62
63 foreach (var entry in items)
64 addDropdownItem(GenerateItemText(entry), entry);
65
66 if (Current.Value == null || !itemMap.Keys.Contains(Current.Value, EqualityComparer<T>.Default))
67 Current.Value = itemMap.Keys.FirstOrDefault();
68 else
69 Current.TriggerChange();
70 }
71
72 private readonly IBindableList<T> itemSource = new BindableList<T>();
73 private IBindableList<T> boundItemSource;
74
75 /// <summary>
76 /// Allows the developer to assign an <see cref="IBindableList{T}"/> as the source
77 /// of items for this dropdown.
78 /// </summary>
79 public IBindableList<T> ItemSource
80 {
81 get => itemSource;
82 set
83 {
84 if (value == null)
85 throw new ArgumentNullException(nameof(value));
86
87 if (boundItemSource != null) itemSource.UnbindFrom(boundItemSource);
88 itemSource.BindTo(boundItemSource = value);
89 }
90 }
91
92 /// <summary>
93 /// Add a menu item directly while automatically generating a label.
94 /// </summary>
95 /// <param name="value">Value selected by the menu item.</param>
96 public void AddDropdownItem(T value) => AddDropdownItem(GenerateItemText(value), value);
97
98 /// <summary>
99 /// Add a menu item directly.
100 /// </summary>
101 /// <param name="text">Text to display on the menu item.</param>
102 /// <param name="value">Value selected by the menu item.</param>
103 protected void AddDropdownItem(LocalisableString text, T value)
104 {
105 if (boundItemSource != null)
106 throw new InvalidOperationException($"Cannot manually add dropdown items when an {nameof(ItemSource)} is bound.");
107
108 addDropdownItem(text, value);
109 }
110
111 private void addDropdownItem(LocalisableString text, T value)
112 {
113 if (itemMap.ContainsKey(value))
114 throw new ArgumentException($"The item {value} already exists in this {nameof(Dropdown<T>)}.");
115
116 var newItem = new DropdownMenuItem<T>(text, value, () =>
117 {
118 if (!Current.Disabled)
119 Current.Value = value;
120
121 Menu.State = MenuState.Closed;
122 });
123
124 Menu.Add(newItem);
125 itemMap[value] = newItem;
126 }
127
128 /// <summary>
129 /// Remove a menu item directly.
130 /// </summary>
131 /// <param name="value">Value of the menu item to be removed.</param>
132 public bool RemoveDropdownItem(T value)
133 {
134 if (boundItemSource != null)
135 throw new InvalidOperationException($"Cannot manually remove items when an {nameof(ItemSource)} is bound.");
136
137 return removeDropdownItem(value);
138 }
139
140 private bool removeDropdownItem(T value)
141 {
142 if (value == null)
143 return false;
144
145 if (!itemMap.TryGetValue(value, out var item))
146 return false;
147
148 Menu.Remove(item);
149 itemMap.Remove(value);
150
151 return true;
152 }
153
154 protected virtual LocalisableString GenerateItemText(T item)
155 {
156 switch (item)
157 {
158 case MenuItem i:
159 return i.Text.Value;
160
161 case IHasText t:
162 return t.Text;
163
164 case Enum e:
165 return e.GetLocalisableDescription();
166
167 default:
168 return item?.ToString() ?? "null";
169 }
170 }
171
172 private readonly BindableWithCurrent<T> current = new BindableWithCurrent<T>();
173
174 public Bindable<T> Current
175 {
176 get => current.Current;
177 set => current.Current = value;
178 }
179
180 private DropdownMenuItem<T> selectedItem;
181
182 protected DropdownMenuItem<T> SelectedItem
183 {
184 get => selectedItem;
185 set
186 {
187 if (Current.Disabled)
188 return;
189
190 selectedItem = value;
191
192 if (value != null)
193 Current.Value = value.Value;
194 }
195 }
196
197 protected Dropdown()
198 {
199 AutoSizeAxes = Axes.Y;
200
201 InternalChild = new FillFlowContainer<Drawable>
202 {
203 Children = new Drawable[]
204 {
205 Header = CreateHeader(),
206 Menu = CreateMenu()
207 },
208 Direction = FillDirection.Vertical,
209 RelativeSizeAxes = Axes.X,
210 AutoSizeAxes = Axes.Y
211 };
212
213 Menu.RelativeSizeAxes = Axes.X;
214
215 Header.Action = Menu.Toggle;
216 Header.ChangeSelection += selectionKeyPressed;
217 Menu.PreselectionConfirmed += preselectionConfirmed;
218 Current.ValueChanged += selectionChanged;
219 Current.DisabledChanged += disabled =>
220 {
221 Header.Enabled.Value = !disabled;
222 if (disabled && Menu.State == MenuState.Open)
223 Menu.State = MenuState.Closed;
224 };
225
226 ItemSource.CollectionChanged += (_, __) => setItems(ItemSource);
227 }
228
229 private void preselectionConfirmed(int selectedIndex)
230 {
231 SelectedItem = MenuItems.ElementAtOrDefault(selectedIndex);
232 Menu.State = MenuState.Closed;
233 }
234
235 private void selectionKeyPressed(DropdownHeader.DropdownSelectionAction action)
236 {
237 if (!MenuItems.Any())
238 return;
239
240 var dropdownMenuItems = MenuItems.ToList();
241
242 switch (action)
243 {
244 case DropdownHeader.DropdownSelectionAction.Previous:
245 SelectedItem = dropdownMenuItems[Math.Clamp(dropdownMenuItems.IndexOf(SelectedItem) - 1, 0, dropdownMenuItems.Count - 1)];
246 break;
247
248 case DropdownHeader.DropdownSelectionAction.Next:
249 SelectedItem = dropdownMenuItems[Math.Clamp(dropdownMenuItems.IndexOf(SelectedItem) + 1, 0, dropdownMenuItems.Count - 1)];
250 break;
251
252 case DropdownHeader.DropdownSelectionAction.First:
253 SelectedItem = dropdownMenuItems[0];
254 break;
255
256 case DropdownHeader.DropdownSelectionAction.Last:
257 SelectedItem = dropdownMenuItems[^1];
258 break;
259
260 default:
261 throw new ArgumentException("Unexpected selection action type.", nameof(action));
262 }
263 }
264
265 protected override void LoadComplete()
266 {
267 base.LoadComplete();
268
269 Header.Label = SelectedItem?.Text.Value ?? default;
270 }
271
272 private void selectionChanged(ValueChangedEvent<T> args)
273 {
274 // refresh if SelectedItem and SelectedValue mismatched
275 // null is not a valid value for Dictionary, so neither here
276 if (args.NewValue == null && SelectedItem != null)
277 {
278 selectedItem = new DropdownMenuItem<T>(default, default);
279 }
280 else if (SelectedItem == null || !EqualityComparer<T>.Default.Equals(SelectedItem.Value, args.NewValue))
281 {
282 if (!itemMap.TryGetValue(args.NewValue, out selectedItem))
283 {
284 selectedItem = new DropdownMenuItem<T>(GenerateItemText(args.NewValue), args.NewValue);
285 }
286 }
287
288 Menu.SelectItem(selectedItem);
289 Header.Label = selectedItem.Text.Value;
290 }
291
292 /// <summary>
293 /// Clear all the menu items.
294 /// </summary>
295 public void ClearItems()
296 {
297 if (boundItemSource != null)
298 throw new InvalidOperationException($"Cannot manually clear items when an {nameof(ItemSource)} is bound.");
299
300 clearItems();
301 }
302
303 private void clearItems()
304 {
305 itemMap.Clear();
306 Menu.Clear();
307 }
308
309 /// <summary>
310 /// Hide the menu item of specified value.
311 /// </summary>
312 /// <param name="val">The value to hide.</param>
313 internal void HideItem(T val)
314 {
315 if (itemMap.TryGetValue(val, out DropdownMenuItem<T> item))
316 {
317 Menu.HideItem(item);
318 updateHeaderVisibility();
319 }
320 }
321
322 /// <summary>
323 /// Show the menu item of specified value.
324 /// </summary>
325 /// <param name="val">The value to show.</param>
326 internal void ShowItem(T val)
327 {
328 if (itemMap.TryGetValue(val, out DropdownMenuItem<T> item))
329 {
330 Menu.ShowItem(item);
331 updateHeaderVisibility();
332 }
333 }
334
335 private void updateHeaderVisibility() => Header.Alpha = Menu.AnyPresent ? 1 : 0;
336
337 /// <summary>
338 /// Creates the menu body.
339 /// </summary>
340 protected abstract DropdownMenu CreateMenu();
341
342 #region DropdownMenu
343
344 public abstract class DropdownMenu : Menu, IKeyBindingHandler<PlatformAction>
345 {
346 protected DropdownMenu()
347 : base(Direction.Vertical)
348 {
349 StateChanged += clearPreselection;
350 }
351
352 public override void Add(MenuItem item)
353 {
354 base.Add(item);
355
356 var drawableDropdownMenuItem = (DrawableDropdownMenuItem)ItemsContainer.Single(drawableItem => drawableItem.Item == item);
357 drawableDropdownMenuItem.PreselectionRequested += PreselectItem;
358 }
359
360 private void clearPreselection(MenuState obj)
361 {
362 if (obj == MenuState.Closed)
363 PreselectItem(null);
364 }
365
366 protected internal IEnumerable<DrawableDropdownMenuItem> DrawableMenuItems => Children.OfType<DrawableDropdownMenuItem>();
367 protected internal IEnumerable<DrawableDropdownMenuItem> VisibleMenuItems => DrawableMenuItems.Where(item => !item.IsMaskedAway);
368
369 public DrawableDropdownMenuItem PreselectedItem => DrawableMenuItems.FirstOrDefault(c => c.IsPreSelected)
370 ?? DrawableMenuItems.FirstOrDefault(c => c.IsSelected);
371
372 public event Action<int> PreselectionConfirmed;
373
374 /// <summary>
375 /// Selects an item from this <see cref="DropdownMenu"/>.
376 /// </summary>
377 /// <param name="item">The item to select.</param>
378 public void SelectItem(DropdownMenuItem<T> item)
379 {
380 Children.OfType<DrawableDropdownMenuItem>().ForEach(c =>
381 {
382 c.IsSelected = c.Item == item;
383 if (c.IsSelected)
384 ContentContainer.ScrollIntoView(c);
385 });
386 }
387
388 /// <summary>
389 /// Shows an item from this <see cref="DropdownMenu"/>.
390 /// </summary>
391 /// <param name="item">The item to show.</param>
392 public void HideItem(DropdownMenuItem<T> item) => Children.FirstOrDefault(c => c.Item == item)?.Hide();
393
394 /// <summary>
395 /// Hides an item from this <see cref="DropdownMenu"/>
396 /// </summary>
397 /// <param name="item"></param>
398 public void ShowItem(DropdownMenuItem<T> item) => Children.FirstOrDefault(c => c.Item == item)?.Show();
399
400 /// <summary>
401 /// Whether any items part of this <see cref="DropdownMenu"/> are present.
402 /// </summary>
403 public bool AnyPresent => Children.Any(c => c.IsPresent);
404
405 protected void PreselectItem(int index) => PreselectItem(Items[Math.Clamp(index, 0, DrawableMenuItems.Count() - 1)]);
406
407 /// <summary>
408 /// Preselects an item from this <see cref="DropdownMenu"/>.
409 /// </summary>
410 /// <param name="item">The item to select.</param>
411 protected void PreselectItem(MenuItem item)
412 {
413 Children.OfType<DrawableDropdownMenuItem>().ForEach(c =>
414 {
415 c.IsPreSelected = c.Item == item;
416 if (c.IsPreSelected)
417 ContentContainer.ScrollIntoView(c);
418 });
419 }
420
421 protected sealed override DrawableMenuItem CreateDrawableMenuItem(MenuItem item) => CreateDrawableDropdownMenuItem(item);
422
423 protected abstract DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item);
424
425 #region DrawableDropdownMenuItem
426
427 // must be public due to mono bug(?) https://github.com/ppy/osu/issues/1204
428 public abstract class DrawableDropdownMenuItem : DrawableMenuItem
429 {
430 public event Action<DropdownMenuItem<T>> PreselectionRequested;
431
432 protected DrawableDropdownMenuItem(MenuItem item)
433 : base(item)
434 {
435 }
436
437 private bool selected;
438
439 public bool IsSelected
440 {
441 get => !Item.Action.Disabled && selected;
442 set
443 {
444 if (selected == value)
445 return;
446
447 selected = value;
448
449 OnSelectChange();
450 }
451 }
452
453 private bool preSelected;
454
455 /// <summary>
456 /// Denotes whether this menu item will be selected on <see cref="Key.Enter"/> press.
457 /// This property is related to selecting menu items using keyboard or hovering.
458 /// </summary>
459 public bool IsPreSelected
460 {
461 get => preSelected;
462 set
463 {
464 if (preSelected == value)
465 return;
466
467 preSelected = value;
468
469 OnSelectChange();
470 }
471 }
472
473 private Color4 backgroundColourSelected = Color4.SlateGray;
474
475 public Color4 BackgroundColourSelected
476 {
477 get => backgroundColourSelected;
478 set
479 {
480 backgroundColourSelected = value;
481 UpdateBackgroundColour();
482 }
483 }
484
485 private Color4 foregroundColourSelected = Color4.White;
486
487 public Color4 ForegroundColourSelected
488 {
489 get => foregroundColourSelected;
490 set
491 {
492 foregroundColourSelected = value;
493 UpdateForegroundColour();
494 }
495 }
496
497 protected virtual void OnSelectChange()
498 {
499 if (!IsLoaded)
500 return;
501
502 UpdateBackgroundColour();
503 UpdateForegroundColour();
504 }
505
506 protected override void UpdateBackgroundColour()
507 {
508 Background.FadeColour(IsPreSelected ? BackgroundColourHover : IsSelected ? BackgroundColourSelected : BackgroundColour);
509 }
510
511 protected override void UpdateForegroundColour()
512 {
513 Foreground.FadeColour(IsPreSelected ? ForegroundColourHover : IsSelected ? ForegroundColourSelected : ForegroundColour);
514 }
515
516 protected override void LoadComplete()
517 {
518 base.LoadComplete();
519 Background.Colour = IsSelected ? BackgroundColourSelected : BackgroundColour;
520 Foreground.Colour = IsSelected ? ForegroundColourSelected : ForegroundColour;
521 }
522
523 protected override bool OnHover(HoverEvent e)
524 {
525 PreselectionRequested?.Invoke(Item as DropdownMenuItem<T>);
526 return base.OnHover(e);
527 }
528 }
529
530 #endregion
531
532 protected override bool OnKeyDown(KeyDownEvent e)
533 {
534 var drawableMenuItemsList = DrawableMenuItems.ToList();
535 if (!drawableMenuItemsList.Any())
536 return base.OnKeyDown(e);
537
538 var currentPreselected = PreselectedItem;
539 var targetPreselectionIndex = drawableMenuItemsList.IndexOf(currentPreselected);
540
541 switch (e.Key)
542 {
543 case Key.Up:
544 PreselectItem(targetPreselectionIndex - 1);
545 return true;
546
547 case Key.Down:
548 PreselectItem(targetPreselectionIndex + 1);
549 return true;
550
551 case Key.PageUp:
552 var firstVisibleItem = VisibleMenuItems.First();
553
554 if (currentPreselected == firstVisibleItem)
555 PreselectItem(targetPreselectionIndex - VisibleMenuItems.Count());
556 else
557 PreselectItem(drawableMenuItemsList.IndexOf(firstVisibleItem));
558 return true;
559
560 case Key.PageDown:
561 var lastVisibleItem = VisibleMenuItems.Last();
562
563 if (currentPreselected == lastVisibleItem)
564 PreselectItem(targetPreselectionIndex + VisibleMenuItems.Count());
565 else
566 PreselectItem(drawableMenuItemsList.IndexOf(lastVisibleItem));
567 return true;
568
569 case Key.Enter:
570 PreselectionConfirmed?.Invoke(targetPreselectionIndex);
571 return true;
572
573 case Key.Escape:
574 State = MenuState.Closed;
575 return true;
576
577 default:
578 return base.OnKeyDown(e);
579 }
580 }
581
582 public bool OnPressed(PlatformAction action)
583 {
584 switch (action)
585 {
586 case PlatformAction.MoveToListStart:
587 PreselectItem(Items.FirstOrDefault());
588 return true;
589
590 case PlatformAction.MoveToListEnd:
591 PreselectItem(Items.LastOrDefault());
592 return true;
593
594 default:
595 return false;
596 }
597 }
598
599 public void OnReleased(PlatformAction action)
600 {
601 }
602 }
603
604 #endregion
605 }
606}