A game framework written with osu! in mind.
at master 606 lines 21 kB view raw
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}