using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics.Contracts; using System.Numerics; using Robust.Client.Graphics; using Robust.Shared.Collections; using Robust.Shared.Graphics; using Robust.Shared.Input; using Robust.Shared.Maths; using Timer = Robust.Shared.Timing.Timer; /// /// Represents a scrollable list of items in a user interface. /// namespace Robust.Client.UserInterface.Controls { [Virtual] public class ItemList : Control, IList { private bool _isAtBottom = true; /// The size of all the child widgets, in pixels private int _totalContentHeight; private VScrollBar _scrollBar; private readonly List _itemList = new(); public event Action? OnItemSelected; public event Action? OnItemDeselected; public event Action? OnItemHover; public const string StylePropertyBackground = "itemlist-background"; public const string StylePropertyItemBackground = "item-background"; public const string StylePropertySelectedItemBackground = "selected-item-background"; public const string StylePropertyDisabledItemBackground = "disabled-item-background"; /// /// Gets or sets the ItemSeparation of individual list items /// public int ItemSeparation { get; set; } = 0; // Default value is 0px public int Count => _itemList.Count; public bool IsReadOnly => false; public bool ScrollFollowing { get; set; } = false; public int ButtonDeselectDelay { get; set; } = 100; public ItemListSelectMode SelectMode { get; set; } = ItemListSelectMode.Single; public ItemList() { MouseFilter = MouseFilterMode.Pass; RectClipContent = true; _scrollBar = new VScrollBar { Name = "_v_scroll", HorizontalAlignment = HAlignment.Right }; AddChild(_scrollBar); _scrollBar.OnValueChanged += _ => _isAtBottom = _scrollBar.IsAtEnd; } private void RecalculateContentHeight() { _totalContentHeight = 0; foreach (var item in _itemList) { var itemHeight = 0f; if (item.Icon != null) { itemHeight = item.IconSize.Y * item.IconScale; } itemHeight = Math.Max(itemHeight, ActualFont.GetHeight(UIScale)); itemHeight += ActualItemBackground.MinimumSize.Y * UIScale; _totalContentHeight += (int)Math.Ceiling(itemHeight); _totalContentHeight += ItemSeparation; } //Remove unneeded ItemSeparation on last item. _totalContentHeight -= ItemSeparation; _scrollBar.MaxValue = Math.Max(_scrollBar.Page, _totalContentHeight); _updateScrollbarVisibility(); } public void Add(IEnumerable items) { foreach (var item in items) { if(item.Owner != this) throw new ArgumentException("Item is owned by another ItemList!"); _itemList.Add(item); item.OnSelected += Select; item.OnDeselected += Deselect; } Recalculate(); } public void Add(Item item) { if(item.Owner != this) throw new ArgumentException("Item is owned by another ItemList!"); _itemList.Add(item); item.OnSelected += Select; item.OnDeselected += Deselect; Recalculate(); } public void AddItems(IEnumerable texts, Texture? icon = null, bool selectable = true, object? metadata = null, float iconScale = 1) { var items = new ValueList(); foreach (var text in texts) { items.Add(new Item(this) {Text = text, Icon = icon, IconScale = iconScale, Selectable = selectable, Metadata = metadata}); } Add(items); } public Item AddItem(string text, Texture? icon = null, bool selectable = true, object? metadata = null, float iconScale = 1) { var item = new Item(this) {Text = text, Icon = icon, IconScale = iconScale, Selectable = selectable, Metadata = metadata}; Add(item); return item; } public void Clear() { // Handle this manually so we can just clear all at once. foreach (var item in _itemList) { item.OnSelected -= Select; item.OnDeselected -= Deselect; } _itemList.Clear(); Recalculate(); _totalContentHeight = 0; } public bool Contains(Item item) { return _itemList.Contains(item); } public void CopyTo(Item[] array, int arrayIndex) { _itemList.CopyTo(array, arrayIndex); } private void InternalRemoveAt(int index) { if (_itemList.Count <= index) return; // If you modify this then also make sure to update Clear! var item = _itemList[index]; _itemList.RemoveAt(index); item.OnSelected -= Select; item.OnDeselected -= Deselect; } public bool Remove(Item item) { var value = _itemList.Remove(item); item.OnSelected -= Select; item.OnDeselected -= Deselect; Recalculate(); return value; } public void RemoveAt(int index) { InternalRemoveAt(index); Recalculate(); } public IEnumerator GetEnumerator() { return _itemList.GetEnumerator(); } IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); } public int IndexOf(Item item) { return _itemList.IndexOf(item); } private void InternalInsert(int index, Item item) { if(item.Owner != this) throw new ArgumentException("Item is owned by another ItemList!"); _itemList.Insert(index, item); item.OnSelected += Select; item.OnDeselected += Deselect; } public void Insert(int index, Item item) { InternalInsert(index, item); Recalculate(); } private void Recalculate() { RecalculateContentHeight(); if (_isAtBottom && ScrollFollowing) _scrollBar.MoveToEnd(); } /// /// Replace the current list of items with the items in newItems. /// newItems should be in the order which they should appear in the list, /// and items are considered equal if the Item text is equal in each item. /// /// Provided the existing items have not been re-ordered relative to each /// other, any items which already exist in the list are not destroyed, /// which maintains consistency of scrollbars, selected items, etc. /// /// The list of items to update this list to public void SetItems(List newItems) { SetItems(newItems, (a,b) => string.Compare(a.Text, b.Text)); } /// /// This variant allows for a custom equality operator to compare items, when /// comparing the Item text is not desired. /// /// Comparison function to compare existing to new items. public void SetItems(List newItems, Comparison itemCmp) { // Walk through the existing items in this list and in newItems // in parallel to synchronize our items with those in newItems. int i = this.Count - 1; int j = newItems.Count - 1; while(i >= 0 && j >= 0) { var cmpResult = itemCmp(this[i], newItems[j]); if (cmpResult == 0) { // This item exists in both our list and `newItems`. Nothing to do. i--; j--; } else if (cmpResult > 0) { // Item exists in our list, but not in `newItems`. Remove it. InternalRemoveAt(i); i--; } else if (cmpResult < 0) { // A new entry which doesn't exist in our list. Insert it. InternalInsert(i + 1, newItems[j]); j--; } } // Any remaining items in our list don't exist in `newItems` so remove them while (i >= 0) { InternalRemoveAt(i); i--; } // And finally, any remaining items in `newItems` don't exist in our list. Create them. while (j >= 0) { InternalInsert(0, newItems[j]); j--; } Recalculate(); } // Without this attribute, this would compile into a property called "Item", causing problems with the Item class. [System.Runtime.CompilerServices.IndexerName("IndexItem")] public Item this[int index] { get => _itemList[index]; set => _itemList[index] = value; } public IEnumerable GetSelected() { var list = new List(); for (var i = 0; i < _itemList.Count; i++) { var item = _itemList[i]; if (item.Selected) list.Add(item); } return list; } private void Select(int idx) { if(SelectMode != ItemListSelectMode.Multiple) ClearSelected(idx); OnItemSelected?.Invoke(new ItemListSelectedEventArgs(idx, this)); } private void Select(Item item) { var idx = IndexOf(item); if (idx != -1) Select(idx); } private void Deselect(int idx) { OnItemDeselected?.Invoke(new ItemListDeselectedEventArgs(idx, this)); } private void Deselect(Item item) { var idx = IndexOf(item); if (idx == -1) return; Deselect(idx); } public void ClearSelected(int? except = null) { for (var i = 0; i < _itemList.Count; i++) { if (i == except) continue; var item = _itemList[i]; item.Selected = false; } } public void SortItemsByText() => Sort((p, q) => string.Compare(p.Text, q.Text, StringComparison.Ordinal)); public void Sort(Comparison comparison) => _itemList.Sort(comparison); public void EnsureCurrentIsVisible() { // TODO: Implement this. } public int GetItemAtPosition(Vector2 position, bool exact = false) { throw new NotImplementedException(); } public Font ActualFont { get { if (TryGetStyleProperty("font", out var font)) { return font; } return UserInterfaceManager.ThemeDefaults.DefaultFont; } } public Color ActualFontColor { get { if (TryGetStyleProperty("font-color", out Color fontColor)) { return fontColor; } return Color.White; } } public StyleBox ActualBackground { get { if (TryGetStyleProperty(StylePropertyBackground, out var bg)) { return bg; } return new StyleBoxFlat(); } } public StyleBox ActualItemBackground { get { if (TryGetStyleProperty(StylePropertyItemBackground, out var bg)) { return bg; } return new StyleBoxFlat(); } } public StyleBox ActualSelectedItemBackground { get { if (TryGetStyleProperty(StylePropertySelectedItemBackground, out var bg)) { return bg; } return new StyleBoxFlat(); } } public StyleBox ActualDisabledItemBackground { get { if (TryGetStyleProperty(StylePropertyDisabledItemBackground, out var bg)) { return bg; } return new StyleBoxFlat(); } } public void ScrollToBottom() { _scrollBar.MoveToEnd(); _isAtBottom = true; } protected internal override void Draw(DrawingHandleScreen handle) { base.Draw(handle); var sizeBox = PixelSizeBox; var font = ActualFont; var listBg = ActualBackground; var iconBg = ActualItemBackground; var iconSelectedBg = ActualSelectedItemBackground; var iconDisabledBg = ActualDisabledItemBackground; var offset = -_scrollBar.Value; listBg.Draw(handle, PixelSizeBox, UIScale); foreach (var item in _itemList) { var bg = iconBg; if (item.Disabled) bg = iconDisabledBg; if (item.Selected) { bg = iconSelectedBg; } var itemHeight = 0f; if (item.Icon != null) { itemHeight = item.IconSize.Y * item.IconScale; } itemHeight = Math.Max(itemHeight, font.GetHeight(UIScale)); itemHeight += ActualItemBackground.MinimumSize.Y * UIScale; var region = UIBox2.FromDimensions(0, offset, PixelWidth, itemHeight); item.Region = region; if (region.Intersects(sizeBox)) { bg.Draw(handle, item.Region.Value, UIScale); var contentBox = bg.GetContentBox(item.Region.Value, UIScale); var drawOffset = contentBox.TopLeft; if (item.Icon != null) { if (item.IconRegion.Size == Vector2.Zero) { handle.DrawTextureRect(item.Icon, UIBox2.FromDimensions(drawOffset, item.Icon.Size * item.IconScale), item.IconModulate); } else { handle.DrawTextureRectRegion(item.Icon, UIBox2.FromDimensions(drawOffset, item.Icon.Size * item.IconScale), item.IconRegion, item.IconModulate); } } if (item.Text != null) { var textBox = new UIBox2(contentBox.Left + item.IconSize.X * item.IconScale, contentBox.Top, contentBox.Right, contentBox.Bottom); DrawTextInternal(handle, item.Text, textBox); } } offset += itemHeight; // Add a ItemSeparation at the bottom of each item. offset += ItemSeparation; } } protected void DrawTextInternal(DrawingHandleScreen handle, string text, UIBox2 box) { var font = ActualFont; var color = ActualFontColor; var offsetY = (int) (box.Height - font.GetHeight(UIScale)) / 2; var baseLine = new Vector2i(5, offsetY + font.GetAscent(UIScale)) + box.TopLeft; foreach (var rune in text.EnumerateRunes()) { if (!font.TryGetCharMetrics(rune, UIScale, out var metrics)) { continue; } if (!(baseLine.X < box.Left || baseLine.X + metrics.Advance > box.Right)) { font.DrawChar(handle, rune, baseLine, UIScale, color); } baseLine += new Vector2(metrics.Advance, 0); } } protected override Vector2 MeasureOverride(Vector2 availableSize) { var size = Vector2.Zero; if (ActualBackground != null) { size += ActualBackground.MinimumSize; } return size; } protected internal override void KeyBindDown(GUIBoundKeyEventArgs args) { base.KeyBindDown(args); if (SelectMode == ItemListSelectMode.None || args.Function != EngineKeyFunctions.UIClick) { return; } foreach (var item in _itemList) { if (item.Region == null) continue; if (!item.Region.Value.Contains(args.RelativePixelPosition)) continue; if (item.Selectable && !item.Disabled) { if (item.Selected && SelectMode != ItemListSelectMode.Button) { if(SelectMode != ItemListSelectMode.Multiple) ClearSelected(); item.Selected = false; return; } item.Selected = true; if (SelectMode == ItemListSelectMode.Button) Timer.Spawn(ButtonDeselectDelay, () => { item.Selected = false; } ); } break; } } protected internal override void MouseMove(GUIMouseMoveEventArgs args) { base.MouseMove(args); DefaultCursorShape = CursorShape.Arrow; for (var idx = 0; idx < _itemList.Count; idx++) { var item = _itemList[idx]; if (item.Region == null) continue; if (!item.Region.Value.Contains(args.RelativePosition)) continue; if (SelectMode != ItemListSelectMode.None) { if (item.Disabled) { DefaultCursorShape = CursorShape.NotAllowed; } else if (item.Selectable) { DefaultCursorShape = CursorShape.Pointer; } else { DefaultCursorShape = CursorShape.Arrow; } } OnItemHover?.Invoke(new ItemListHoverEventArgs(idx, this)); break; } } protected internal override void MouseExited() { base.MouseExited(); DefaultCursorShape = CursorShape.Arrow; } protected internal override void MouseWheel(GUIMouseWheelEventArgs args) { base.MouseWheel(args); if (MathHelper.CloseToPercent(0, args.Delta.Y)) { return; } _scrollBar.ValueTarget -= _getScrollSpeed() * args.Delta.Y; _isAtBottom = _scrollBar.IsAtEnd; args.Handle(); } [Pure] private int _getScrollSpeed() { var font = ActualFont; return font.GetHeight(UIScale) * 2; } protected override void Resized() { base.Resized(); var styleBoxSize = (ActualBackground?.MinimumSize.Y ?? 0) * UIScale; _scrollBar.Page = PixelSize.Y - styleBoxSize; RecalculateContentHeight(); } protected internal override void UIScaleChanged() { RecalculateContentHeight(); base.UIScaleChanged(); } private void _updateScrollbarVisibility() { _scrollBar.Visible = _totalContentHeight + ActualBackground.MinimumSize.Y > Height; } public abstract class ItemListEventArgs : EventArgs { /// /// The ItemList this event originated from. /// public ItemList ItemList { get; } protected ItemListEventArgs(ItemList list) { ItemList = list; } } public sealed class ItemListSelectedEventArgs : ItemListEventArgs { /// /// The index of the item that was selected. /// public int ItemIndex; public ItemListSelectedEventArgs(int itemIndex, ItemList list) : base(list) { ItemIndex = itemIndex; } } public sealed class ItemListDeselectedEventArgs : ItemListEventArgs { /// /// The index of the item that was selected. /// public int ItemIndex; public ItemListDeselectedEventArgs(int itemIndex, ItemList list) : base(list) { ItemIndex = itemIndex; } } public sealed class ItemListHoverEventArgs : ItemListEventArgs { /// /// The index of the item that was selected. /// public int ItemIndex; public ItemListHoverEventArgs(int itemIndex, ItemList list) : base(list) { ItemIndex = itemIndex; } } public enum ItemListSelectMode : byte { None, Single, Multiple, Button, } public sealed class Item { public event Action? OnSelected; public event Action? OnDeselected; private bool _selected = false; private bool _disabled = false; public ItemList Owner { get; } public string? Text { get; set; } public string? TooltipText { get; set; } public Texture? Icon { get; set; } public UIBox2 IconRegion { get; set; } public Color IconModulate { get; set; } = Color.White; public float IconScale { get; set; } = 1; public bool Selectable { get; set; } = true; public bool TooltipEnabled { get; set; } = true; public UIBox2? Region { get; set; } public object? Metadata { get; set; } public bool Disabled { get => _disabled; set { _disabled = value; if (Selected) Selected = false; } } public bool Selected { get => _selected; set { if (!Selectable || _selected == value) return; _selected = value; if(_selected) OnSelected?.Invoke(this); else OnDeselected?.Invoke(this); } } public Vector2 IconSize { get { if (Icon == null) return Vector2.Zero; return IconRegion.Size != Vector2.Zero ? IconRegion.Size : Icon.Size; } } public Item(ItemList owner) { Owner = owner; } } } }