diff --git a/Content.Client/Administration/UI/BwoinkWindow.xaml.cs b/Content.Client/Administration/UI/BwoinkWindow.xaml.cs index a514cb9521..101b3b38d3 100644 --- a/Content.Client/Administration/UI/BwoinkWindow.xaml.cs +++ b/Content.Client/Administration/UI/BwoinkWindow.xaml.cs @@ -1,9 +1,9 @@ #nullable enable -using System.Linq; using System.Text; using System.Threading; using Content.Client.Administration.Managers; using Content.Client.Administration.Systems; +using Content.Client.Administration.UI.CustomControls; using Content.Client.Administration.UI.Tabs.AdminTab; using Content.Client.Stylesheets; using Content.Shared.Administration; @@ -14,6 +14,7 @@ using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.CustomControls; using Robust.Client.UserInterface.XAML; using Robust.Shared.Network; +using Robust.Shared.Utility; using Timer = Robust.Shared.Timing.Timer; namespace Content.Client.Administration.UI @@ -48,13 +49,29 @@ namespace Content.Client.Administration.UI Title = $"{sel.CharacterName} / {sel.Username}"; } - foreach (var li in ChannelSelector.PlayerItemList) - li.Text = FormatTabTitle(li); + ChannelSelector.PlayerListContainer.DirtyList(); }; - ChannelSelector.DecoratePlayer += (PlayerInfo pl, ItemList.Item li) => + ChannelSelector.OverrideText += (info, text) => { - li.Text = FormatTabTitle(li, pl); + var sb = new StringBuilder(); + sb.Append(info.Connected ? '●' : '○'); + sb.Append(' '); + if (_bwoinkSystem.TryGetChannel(info.SessionId, out var panel) && panel.Unread > 0) + { + if (panel.Unread < 11) + sb.Append(new Rune('➀' + (panel.Unread-1))); + else + sb.Append(new Rune(0x2639)); // ☹ + sb.Append(' '); + } + + if (info.Antag) + sb.Append(new Rune(0x1F5E1)); // 🗡 + + sb.AppendFormat("\"{0}\"", text); + + return sb.ToString(); }; ChannelSelector.Comparison = (a, b) => @@ -121,18 +138,16 @@ namespace Content.Client.Administration.UI public void OnBwoink(NetUserId channel) { - ChannelSelector.RefreshDecorators(); - ChannelSelector.Sort(); + ChannelSelector.PopulateList(); } public void SelectChannel(NetUserId channel) { - var pi = ChannelSelector - .PlayerItemList - .FirstOrDefault(i => ((PlayerInfo) i.Metadata!).SessionId == channel); - - if (pi is not null) - pi.Selected = true; + if (!ChannelSelector.PlayerInfo.TryFirstOrDefault( + i => i.SessionId == channel, out var info)) + return; + ChannelSelector.PopulateList(); + ChannelSelector.PlayerListContainer.Select(new PlayerListData(info)); } private void FixButtons() diff --git a/Content.Client/Administration/UI/CustomControls/PlayerListControl.xaml b/Content.Client/Administration/UI/CustomControls/PlayerListControl.xaml index 0fa10d7baf..5d630425ab 100644 --- a/Content.Client/Administration/UI/CustomControls/PlayerListControl.xaml +++ b/Content.Client/Administration/UI/CustomControls/PlayerListControl.xaml @@ -1,14 +1,18 @@  - + + + diff --git a/Content.Client/Administration/UI/CustomControls/PlayerListControl.xaml.cs b/Content.Client/Administration/UI/CustomControls/PlayerListControl.xaml.cs index f2579348e0..b760ced4c4 100644 --- a/Content.Client/Administration/UI/CustomControls/PlayerListControl.xaml.cs +++ b/Content.Client/Administration/UI/CustomControls/PlayerListControl.xaml.cs @@ -1,13 +1,14 @@ -using System; -using System.Collections.Generic; using System.Linq; using Content.Client.Administration.Systems; +using Content.Client.UserInterface.Controls; +using Content.Client.Verbs; using Content.Shared.Administration; +using Content.Shared.Input; using Robust.Client.AutoGenerated; +using Robust.Client.Graphics; using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.XAML; -using Robust.Shared.GameObjects; -using Robust.Shared.IoC; +using Robust.Shared.Input; namespace Content.Client.Administration.UI.CustomControls { @@ -15,81 +16,107 @@ namespace Content.Client.Administration.UI.CustomControls public sealed partial class PlayerListControl : BoxContainer { private readonly AdminSystem _adminSystem; + private readonly VerbSystem _verbSystem; + + private List _playerList = new(); + private readonly List _sortedPlayerList = new(); public event Action? OnSelectionChanged; + public IReadOnlyList PlayerInfo => _playerList; - public Action? DecoratePlayer; + public Func? OverrideText; public Comparison? Comparison; public PlayerListControl() { _adminSystem = EntitySystem.Get(); + _verbSystem = EntitySystem.Get(); IoCManager.InjectDependencies(this); RobustXamlLoader.Load(this); // Fill the Option data - PopulateList(); - PlayerItemList.OnItemSelected += PlayerItemListOnOnItemSelected; - PlayerItemList.OnItemDeselected += PlayerItemListOnOnItemDeselected; - FilterLineEdit.OnTextChanged += FilterLineEditOnOnTextEntered; + PlayerListContainer.ItemPressed += PlayerListItemPressed; + PlayerListContainer.GenerateItem += GenerateButton; + PopulateList(_adminSystem.PlayerList); + FilterLineEdit.OnTextChanged += _ => FilterList(); _adminSystem.PlayerListChanged += PopulateList; - + BackgroundPanel.PanelOverride = new StyleBoxFlat {BackgroundColor = new Color(32, 32, 40)}; } - private void FilterLineEditOnOnTextEntered(LineEdit.LineEditEventArgs obj) + private void PlayerListItemPressed(BaseButton.ButtonEventArgs args, ListData data) { - PopulateList(); - } - - private void PlayerItemListOnOnItemSelected(ItemList.ItemListSelectedEventArgs obj) - { - var selectedPlayer = (PlayerInfo) obj.ItemList[obj.ItemIndex].Metadata!; - OnSelectionChanged?.Invoke(selectedPlayer); - } - - private void PlayerItemListOnOnItemDeselected(ItemList.ItemListDeselectedEventArgs obj) - { - OnSelectionChanged?.Invoke(null); - } - - public void RefreshDecorators() - { - foreach (var item in PlayerItemList) + if (data is not PlayerListData {Info: var selectedPlayer}) + return; + if (args.Event.Function == EngineKeyFunctions.UIClick) { - DecoratePlayer?.Invoke((PlayerInfo) item.Metadata!, item); + OnSelectionChanged?.Invoke(selectedPlayer); + + // update label text. Only required if there is some override (e.g. unread bwoink count). + if (OverrideText != null && args.Button.Children.FirstOrDefault()?.Children?.FirstOrDefault() is Label label) + label.Text = GetText(selectedPlayer); + } + else if (args.Event.Function == ContentKeyFunctions.OpenContextMenu) + { + _verbSystem.VerbMenu.OpenVerbMenu(selectedPlayer.EntityUid); } } - public void Sort() + private void FilterList() { - if(Comparison != null) - PlayerItemList.Sort((a, b) => Comparison((PlayerInfo) a.Metadata!, (PlayerInfo) b.Metadata!)); - } - - private void PopulateList(IReadOnlyList _ = null!) - { - PlayerItemList.Clear(); - - foreach (var info in _adminSystem.PlayerList) + _sortedPlayerList.Clear(); + foreach (var info in _playerList) { var displayName = $"{info.CharacterName} ({info.Username})"; if (info.IdentityName != info.CharacterName) displayName += $" [{info.IdentityName}]"; - if (!string.IsNullOrEmpty(FilterLineEdit.Text) && - !displayName.ToLowerInvariant().Contains(FilterLineEdit.Text.Trim().ToLowerInvariant())) - { + if (!string.IsNullOrEmpty(FilterLineEdit.Text) + && !displayName.ToLowerInvariant().Contains(FilterLineEdit.Text.Trim().ToLowerInvariant())) continue; - } - - var item = new ItemList.Item(PlayerItemList) - { - Metadata = info, - Text = displayName - }; - DecoratePlayer?.Invoke(info, item); - PlayerItemList.Add(item); + _sortedPlayerList.Add(info); } - Sort(); + if (Comparison != null) + _sortedPlayerList.Sort((a, b) => Comparison(a, b)); + + PlayerListContainer.PopulateList(_sortedPlayerList.Select(info => new PlayerListData(info)).ToList()); + } + + public void PopulateList(IReadOnlyList? players = null) + { + players ??= _adminSystem.PlayerList; + + _playerList = players.ToList(); + FilterList(); + } + + private string GetText(PlayerInfo info) + { + var text = $"{info.CharacterName} ({info.Username})"; + if (OverrideText != null) + text = OverrideText.Invoke(info, text); + return text; + } + + private void GenerateButton(ListData data, ListContainerButton button) + { + if (data is not PlayerListData { Info: var info }) + return; + + button.AddChild(new BoxContainer + { + Orientation = LayoutOrientation.Vertical, + Children = + { + new Label + { + ClipText = true, + Text = GetText(info) + } + } + }); + button.EnableAllKeybinds = true; + button.AddStyleClass(ListContainer.StyleClassListContainerButton); } } + + public record PlayerListData(PlayerInfo Info) : ListData; } diff --git a/Content.Client/Storage/StorageBoundUserInterface.cs b/Content.Client/Storage/StorageBoundUserInterface.cs index 0c1c1c3cac..b28d86fbec 100644 --- a/Content.Client/Storage/StorageBoundUserInterface.cs +++ b/Content.Client/Storage/StorageBoundUserInterface.cs @@ -4,6 +4,8 @@ using Robust.Client.GameObjects; using Robust.Client.UserInterface.Controls; using Robust.Shared.Input; using Content.Client.Items.Managers; +using Content.Client.UserInterface.Controls; +using JetBrains.Annotations; using static Content.Shared.Storage.SharedStorageComponent; namespace Content.Client.Storage @@ -39,8 +41,10 @@ namespace Content.Client.Storage } } - public void InteractWithItem(BaseButton.ButtonEventArgs args, EntityUid entity) + public void InteractWithItem(BaseButton.ButtonEventArgs args, ListData cData) { + if (cData is not EntityListData {Uid: var entity}) + return; if (args.Event.Function == EngineKeyFunctions.UIClick) { SendMessage(new StorageInteractWithItemEvent(entity)); diff --git a/Content.Client/Storage/UI/StorageWindow.cs b/Content.Client/Storage/UI/StorageWindow.cs index 75b7ea634a..ebda2ebed8 100644 --- a/Content.Client/Storage/UI/StorageWindow.cs +++ b/Content.Client/Storage/UI/StorageWindow.cs @@ -3,6 +3,7 @@ using Robust.Client.Graphics; using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.CustomControls; using Content.Client.Items.Components; +using Content.Client.Stylesheets; using Content.Client.UserInterface.Controls; using Content.Shared.Item; using Robust.Client.UserInterface; @@ -20,7 +21,7 @@ namespace Content.Client.Storage.UI private readonly Label _information; public readonly ContainerButton StorageContainerButton; - public readonly EntityListDisplay EntityList; + public readonly ListContainer EntityList; private readonly StyleBoxFlat _hoveredBox = new() { BackgroundColor = Color.Black.WithAlpha(0.35f) }; private readonly StyleBoxFlat _unHoveredBox = new() { BackgroundColor = Color.Black.WithAlpha(0.0f) }; @@ -62,7 +63,7 @@ namespace Content.Client.Storage.UI vBox.AddChild(_information); - EntityList = new EntityListDisplay + EntityList = new ListContainer { Name = "EntityListContainer", }; @@ -85,7 +86,8 @@ namespace Content.Client.Storage.UI /// public void BuildEntityList(StorageBoundUserInterfaceState state) { - EntityList.PopulateList(state.StoredEntities); + var list = state.StoredEntities.ConvertAll(uid => new EntityListData(uid)); + EntityList.PopulateList(list); //Sets information about entire storage container current capacity if (state.StorageCapacityMax != 0) @@ -102,9 +104,10 @@ namespace Content.Client.Storage.UI /// /// Button created for each entity that represents that item in the storage UI, with a texture, and name and size label /// - public void GenerateButton(EntityUid entity, EntityContainerButton button) + public void GenerateButton(ListData data, ListContainerButton button) { - if (!_entityManager.EntityExists(entity)) + if (data is not EntityListData {Uid: var entity} + || !_entityManager.EntityExists(entity)) return; _entityManager.TryGetComponent(entity, out ISpriteComponent? sprite); @@ -137,6 +140,7 @@ namespace Content.Client.Storage.UI } } }); + button.StyleClasses.Add(StyleNano.StyleClassStorageButton); button.EnableAllKeybinds = true; } } diff --git a/Content.Client/Stylesheets/StyleNano.cs b/Content.Client/Stylesheets/StyleNano.cs index ee05023023..e6a66d611d 100644 --- a/Content.Client/Stylesheets/StyleNano.cs +++ b/Content.Client/Stylesheets/StyleNano.cs @@ -405,6 +405,13 @@ namespace Content.Client.Stylesheets itemListItemBackgroundTransparent.SetContentMarginOverride(StyleBox.Margin.Vertical, 2); itemListItemBackgroundTransparent.SetContentMarginOverride(StyleBox.Margin.Horizontal, 4); + var squareTex = resCache.GetTexture("/Textures/Interface/Nano/square.png"); + var listContainerButton = new StyleBoxTexture + { + Texture = squareTex, + ContentMarginLeftOverride = 10 + }; + // NanoHeading var nanoHeadingTex = resCache.GetTexture("/Textures/Interface/Nano/nanoheading.svg.96dpi.png"); var nanoHeadingBox = new StyleBoxTexture @@ -716,25 +723,45 @@ namespace Content.Client.Stylesheets .Prop(TextureRect.StylePropertyTexture, directionIconHereTex), // Thin buttons (No padding nor vertical margin) - Element().Class(StyleClassStorageButton) + Element().Class(StyleClassStorageButton) .Prop(ContainerButton.StylePropertyStyleBox, buttonStorage), - Element().Class(StyleClassStorageButton) + Element().Class(StyleClassStorageButton) .Pseudo(ContainerButton.StylePseudoClassNormal) .Prop(Control.StylePropertyModulateSelf, ButtonColorDefault), - Element().Class(StyleClassStorageButton) + Element().Class(StyleClassStorageButton) .Pseudo(ContainerButton.StylePseudoClassHover) .Prop(Control.StylePropertyModulateSelf, ButtonColorHovered), - Element().Class(StyleClassStorageButton) + Element().Class(StyleClassStorageButton) .Pseudo(ContainerButton.StylePseudoClassPressed) .Prop(Control.StylePropertyModulateSelf, ButtonColorPressed), - Element().Class(StyleClassStorageButton) + Element().Class(StyleClassStorageButton) .Pseudo(ContainerButton.StylePseudoClassDisabled) .Prop(Control.StylePropertyModulateSelf, ButtonColorDisabled), + // ListContainer + Element().Class(ListContainer.StyleClassListContainerButton) + .Prop(ContainerButton.StylePropertyStyleBox, listContainerButton), + + Element().Class(ListContainer.StyleClassListContainerButton) + .Pseudo(ContainerButton.StylePseudoClassNormal) + .Prop(Control.StylePropertyModulateSelf, new Color(55, 55, 68)), + + Element().Class(ListContainer.StyleClassListContainerButton) + .Pseudo(ContainerButton.StylePseudoClassHover) + .Prop(Control.StylePropertyModulateSelf, new Color(75, 75, 86)), + + Element().Class(ListContainer.StyleClassListContainerButton) + .Pseudo(ContainerButton.StylePseudoClassPressed) + .Prop(Control.StylePropertyModulateSelf, new Color(75, 75, 86)), + + Element().Class(ListContainer.StyleClassListContainerButton) + .Pseudo(ContainerButton.StylePseudoClassDisabled) + .Prop(Control.StylePropertyModulateSelf, new Color(10, 10, 12)), + // action slot hotbar buttons new StyleRule(new SelectorElement(typeof(ActionSlot), null, null, new[] {ContainerButton.StylePseudoClassNormal}), new[] { diff --git a/Content.Client/UserInterface/Controls/EntityListDisplay.cs b/Content.Client/UserInterface/Controls/EntityListDisplay.cs deleted file mode 100644 index 05ce8df312..0000000000 --- a/Content.Client/UserInterface/Controls/EntityListDisplay.cs +++ /dev/null @@ -1,292 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Content.Client.Stylesheets; -using JetBrains.Annotations; -using Robust.Client.UserInterface; -using Robust.Client.UserInterface.Controls; -using Robust.Shared.GameObjects; -using Robust.Shared.Maths; - -namespace Content.Client.UserInterface.Controls -{ - public sealed class EntityListDisplay : Control - { - public const string StylePropertySeparation = "separation"; - - public int? SeparationOverride { get; set; } - public Action? GenerateItem; - public Action? ItemPressed; - - private const int DefaultSeparation = 3; - - private readonly VScrollBar _vScrollBar; - - private List? _entityUids; - private int _count = 0; - private float _itemHeight = 0; - private float _totalHeight = 0; - private int _topIndex = 0; - private int _bottomIndex = 0; - private bool _updateChildren = false; - private bool _suppressScrollValueChanged; - - public int ScrollSpeedY { get; set; } = 50; - - private int ActualSeparation - { - get - { - if (TryGetStyleProperty(StylePropertySeparation, out int separation)) - { - return separation; - } - - return SeparationOverride ?? DefaultSeparation; - } - } - - public EntityListDisplay() - { - HorizontalExpand = true; - VerticalExpand = true; - RectClipContent = true; - MouseFilter = MouseFilterMode.Pass; - - _vScrollBar = new VScrollBar - { - HorizontalExpand = false, - HorizontalAlignment = HAlignment.Right - }; - AddChild(_vScrollBar); - _vScrollBar.OnValueChanged += ScrollValueChanged; - } - - public void PopulateList(List entities) - { - if (_count == 0 && entities.Count > 0) - { - EntityContainerButton control = new(entities[0]); - GenerateItem?.Invoke(entities[0], control); - control.Measure(Vector2.Infinity); - _itemHeight = control.DesiredSize.Y; - control.Dispose(); - } - _count = entities.Count; - _entityUids = entities; - _updateChildren = true; - InvalidateArrange(); - } - - private void OnItemPressed(BaseButton.ButtonEventArgs args) - { - if (args.Button is not EntityContainerButton button) - return; - ItemPressed?.Invoke(args, button.EntityUid); - } - - [Pure] - private Vector2 GetScrollValue() - { - var v = _vScrollBar.Value; - if (!_vScrollBar.Visible) - { - v = 0; - } - return new Vector2(0, v); - } - - protected override Vector2 ArrangeOverride(Vector2 finalSize) - { - var separation = (int) (ActualSeparation * UIScale); - - #region Scroll - var cHeight = _totalHeight; - var vBarSize = _vScrollBar.DesiredSize.X; - var (sWidth, sHeight) = finalSize; - - try - { - // Suppress events to avoid weird recursion. - _suppressScrollValueChanged = true; - - if (sHeight < cHeight) - sWidth -= vBarSize; - - if (sHeight < cHeight) - { - _vScrollBar.Visible = true; - _vScrollBar.Page = sHeight; - _vScrollBar.MaxValue = cHeight; - } - else - _vScrollBar.Visible = false; - } - finally - { - _suppressScrollValueChanged = false; - } - - if (_vScrollBar.Visible) - { - _vScrollBar.Arrange(UIBox2.FromDimensions(Vector2.Zero, finalSize)); - } - #endregion - - #region Rebuild Children - /* - * Example: - * - * var _itemHeight = 32; - * var separation = 3; - * 32 | 32 | Control.Size.Y 0 - * 35 | 3 | Padding - * 67 | 32 | Control.Size.Y 1 - * 70 | 3 | Padding - * 102 | 32 | Control.Size.Y 2 - * 105 | 3 | Padding - * 137 | 32 | Control.Size.Y 3 - * - * If viewport height is 60 - * visible should be 2 items (start = 0, end = 1) - * - * scroll.Y = 11 - * visible should be 3 items (start = 0, end = 2) - * - * start expected: 11 (item: 0) - * var start = (int) (scroll.Y - * - * if (scroll == 32) then { start = 1 } - * var start = (int) (scroll.Y + separation / (_itemHeight + separation)); - * var start = (int) (32 + 3 / (32 + 3)); - * var start = (int) (35 / 35); - * var start = (int) (1); - * - * scroll = 0, height = 36 - * if (scroll + height == 36) then { end = 2 } - * var end = (int) Math.Ceiling(scroll.Y + height / (_itemHeight + separation)); - * var end = (int) Math.Ceiling(0 + 36 / (32 + 3)); - * var end = (int) Math.Ceiling(36 / 35); - * var end = (int) Math.Ceiling(1.02857); - * var end = (int) 2; - * - */ - var scroll = GetScrollValue(); - var oldTopIndex = _topIndex; - _topIndex = (int) ((scroll.Y + separation) / (_itemHeight + separation)); - if (_topIndex != oldTopIndex) - _updateChildren = true; - - var oldBottomIndex = _bottomIndex; - _bottomIndex = (int) Math.Ceiling((scroll.Y + Height) / (_itemHeight + separation)); - _bottomIndex = Math.Min(_bottomIndex, _count); - if (_bottomIndex != oldBottomIndex) - _updateChildren = true; - - // When scrolling only rebuild visible list when a new item should be visible - if (_updateChildren) - { - _updateChildren = false; - - foreach (var child in Children.ToArray()) - { - if (child == _vScrollBar) - continue; - RemoveChild(child); - } - - if (_entityUids != null) - { - for (var i = _topIndex; i < _bottomIndex; i++) - { - var entity = _entityUids[i]; - - var button = new EntityContainerButton(entity); - button.OnPressed += OnItemPressed; - - GenerateItem?.Invoke(entity, button); - AddChild(button); - } - } - - _vScrollBar.SetPositionLast(); - } - #endregion - - #region Layout Children - // Use pixel position - var pixelWidth = (int)(sWidth * UIScale); - - var offset = (int) -((scroll.Y - _topIndex * (_itemHeight + separation)) * UIScale); - var first = true; - foreach (var child in Children) - { - if (child == _vScrollBar) - continue; - if (!first) - offset += separation; - first = false; - - var size = child.DesiredPixelSize.Y; - var targetBox = new UIBox2i(0, offset, pixelWidth, offset + size); - child.ArrangePixel(targetBox); - - offset += size; - } - #endregion - - return finalSize; - } - - protected override Vector2 MeasureOverride(Vector2 availableSize) - { - _vScrollBar.Measure(availableSize); - availableSize.X -= _vScrollBar.DesiredSize.X; - - var constraint = new Vector2(availableSize.X, float.PositiveInfinity); - - var childSize = Vector2.Zero; - foreach (var child in Children) - { - child.Measure(constraint); - if (child == _vScrollBar) - continue; - childSize = Vector2.ComponentMax(childSize, child.DesiredSize); - } - - _totalHeight = childSize.Y * _count + ActualSeparation * (_count - 1); - - return new Vector2(childSize.X, 0f); - } - - private void ScrollValueChanged(Robust.Client.UserInterface.Controls.Range _) - { - if (_suppressScrollValueChanged) - { - return; - } - - InvalidateArrange(); - } - - protected override void MouseWheel(GUIMouseWheelEventArgs args) - { - base.MouseWheel(args); - - _vScrollBar.ValueTarget -= args.Delta.Y * ScrollSpeedY; - - args.Handle(); - } - } - - public sealed class EntityContainerButton : ContainerButton - { - public EntityUid EntityUid; - - public EntityContainerButton(EntityUid entityUid) - { - EntityUid = entityUid; - AddStyleClass(StyleNano.StyleClassStorageButton); - } - } -} diff --git a/Content.Client/UserInterface/Controls/ListContainer.cs b/Content.Client/UserInterface/Controls/ListContainer.cs new file mode 100644 index 0000000000..263b9447a2 --- /dev/null +++ b/Content.Client/UserInterface/Controls/ListContainer.cs @@ -0,0 +1,367 @@ +using System.Linq; +using JetBrains.Annotations; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Shared.Input; +using Robust.Shared.Map; + +namespace Content.Client.UserInterface.Controls; + +public sealed class ListContainer : Control +{ + public const string StylePropertySeparation = "separation"; + public const string StyleClassListContainerButton = "list-container-button"; + + public int? SeparationOverride { get; set; } + + public bool Group + { + get => _buttonGroup != null; + set => _buttonGroup = value ? new ButtonGroup() : null; + } + public bool Toggle { get; set; } + public Action? GenerateItem; + public Action? ItemPressed; + public IReadOnlyList Data => _data; + + private const int DefaultSeparation = 3; + + private readonly VScrollBar _vScrollBar; + private readonly Dictionary _buttons = new(); + + private List _data = new(); + private ListData? _selected; + private float _itemHeight = 0; + private float _totalHeight = 0; + private int _topIndex = 0; + private int _bottomIndex = 0; + private bool _updateChildren = false; + private bool _suppressScrollValueChanged; + private ButtonGroup? _buttonGroup; + + public int ScrollSpeedY { get; set; } = 50; + + private int ActualSeparation + { + get + { + if (TryGetStyleProperty(StylePropertySeparation, out int separation)) + { + return separation; + } + + return SeparationOverride ?? DefaultSeparation; + } + } + + public ListContainer() + { + HorizontalExpand = true; + VerticalExpand = true; + RectClipContent = true; + MouseFilter = MouseFilterMode.Pass; + + _vScrollBar = new VScrollBar + { + HorizontalExpand = false, + HorizontalAlignment = HAlignment.Right + }; + AddChild(_vScrollBar); + _vScrollBar.OnValueChanged += ScrollValueChanged; + } + + public void PopulateList(IReadOnlyList data) + { + if (_itemHeight == 0 || _data is {Count: 0} && data.Count > 0) + { + ListContainerButton control = new(data[0]); + GenerateItem?.Invoke(data[0], control); + control.Measure(Vector2.Infinity); + _itemHeight = control.DesiredSize.Y; + control.Dispose(); + } + + // Ensure buttons are re-generated. + foreach (var button in _buttons.Values) + { + button.Dispose(); + } + _buttons.Clear(); + + _data = data.ToList(); + _updateChildren = true; + InvalidateArrange(); + } + + public void DirtyList() + { + _updateChildren = true; + InvalidateArrange(); + } + + #region Selection + + public void Select(ListData data) + { + if (!_data.Contains(data)) + return; + if (_buttons.TryGetValue(data, out var button) && Toggle) + button.Pressed = true; + _selected = data; + button ??= new ListContainerButton(data); + OnItemPressed(new BaseButton.ButtonEventArgs(button, + new GUIBoundKeyEventArgs(EngineKeyFunctions.UIClick, BoundKeyState.Up, + new ScreenCoordinates(0, 0, WindowId.Main), true, Vector2.Zero, Vector2.Zero))); + } + + /* + * Need to implement selecting the first item in code. + * Need to implement updating one entry without having to repopulate + */ + #endregion + + private void OnItemPressed(BaseButton.ButtonEventArgs args) + { + if (args.Button is not ListContainerButton button) + return; + _selected = button.Data; + ItemPressed?.Invoke(args, button.Data); + } + + [Pure] + private Vector2 GetScrollValue() + { + var v = _vScrollBar.Value; + if (!_vScrollBar.Visible) + { + v = 0; + } + return new Vector2(0, v); + } + + protected override Vector2 ArrangeOverride(Vector2 finalSize) + { + #region Scroll + var cHeight = _totalHeight; + var vBarSize = _vScrollBar.DesiredSize.X; + var (finalWidth, finalHeight) = finalSize; + + try + { + // Suppress events to avoid weird recursion. + _suppressScrollValueChanged = true; + + if (finalHeight < cHeight) + finalWidth -= vBarSize; + + if (finalHeight < cHeight) + { + _vScrollBar.Visible = true; + _vScrollBar.Page = finalHeight; + _vScrollBar.MaxValue = cHeight; + } + else + _vScrollBar.Visible = false; + } + finally + { + _suppressScrollValueChanged = false; + } + + if (_vScrollBar.Visible) + { + _vScrollBar.Arrange(UIBox2.FromDimensions(Vector2.Zero, finalSize)); + } + #endregion + + #region Rebuild Children + /* + * Example: + * + * var _itemHeight = 32; + * var separation = 3; + * 32 | 32 | Control.Size.Y 0 + * 35 | 3 | Padding + * 67 | 32 | Control.Size.Y 1 + * 70 | 3 | Padding + * 102 | 32 | Control.Size.Y 2 + * 105 | 3 | Padding + * 137 | 32 | Control.Size.Y 3 + * + * If viewport height is 60 + * visible should be 2 items (start = 0, end = 1) + * + * scroll.Y = 11 + * visible should be 3 items (start = 0, end = 2) + * + * start expected: 11 (item: 0) + * var start = (int) (scroll.Y + * + * if (scroll == 32) then { start = 1 } + * var start = (int) (scroll.Y + separation / (_itemHeight + separation)); + * var start = (int) (32 + 3 / (32 + 3)); + * var start = (int) (35 / 35); + * var start = (int) (1); + * + * scroll = 0, height = 36 + * if (scroll + height == 36) then { end = 2 } + * var end = (int) Math.Ceiling(scroll.Y + height / (_itemHeight + separation)); + * var end = (int) Math.Ceiling(0 + 36 / (32 + 3)); + * var end = (int) Math.Ceiling(36 / 35); + * var end = (int) Math.Ceiling(1.02857); + * var end = (int) 2; + * + */ + var scroll = GetScrollValue(); + var oldTopIndex = _topIndex; + _topIndex = (int) ((scroll.Y + ActualSeparation) / (_itemHeight + ActualSeparation)); + if (_topIndex != oldTopIndex) + _updateChildren = true; + + var oldBottomIndex = _bottomIndex; + _bottomIndex = (int) Math.Ceiling((scroll.Y + finalHeight) / (_itemHeight + ActualSeparation)); + _bottomIndex = Math.Min(_bottomIndex, _data.Count); + if (_bottomIndex != oldBottomIndex) + _updateChildren = true; + + // When scrolling only rebuild visible list when a new item should be visible + if (_updateChildren) + { + _updateChildren = false; + + var toRemove = new Dictionary(_buttons); + foreach (var child in Children.ToArray()) + { + if (child == _vScrollBar) + continue; + RemoveChild(child); + } + + if (_data.Count > 0) + { + for (var i = _topIndex; i < _bottomIndex; i++) + { + var data = _data[i]; + + if (_buttons.TryGetValue(data, out var button)) + toRemove.Remove(data); + else + { + button = new ListContainerButton(data); + button.OnPressed += OnItemPressed; + button.ToggleMode = Toggle; + button.Group = _buttonGroup; + + GenerateItem?.Invoke(data, button); + _buttons.Add(data, button); + + if (Toggle && data == _selected) + button.Pressed = true; + } + AddChild(button); + button.Measure(finalSize); + } + } + + foreach (var (data, button) in toRemove) + { + _buttons.Remove(data); + button.Dispose(); + } + + _vScrollBar.SetPositionLast(); + } + #endregion + + #region Layout Children + // Use pixel position + var pixelWidth = (int)(finalWidth * UIScale); + var pixelSeparation = (int) (ActualSeparation * UIScale); + + var pixelOffset = (int) -((scroll.Y - _topIndex * (_itemHeight + ActualSeparation)) * UIScale); + var first = true; + foreach (var child in Children) + { + if (child == _vScrollBar) + continue; + if (!first) + pixelOffset += pixelSeparation; + first = false; + + var pixelSize = child.DesiredPixelSize.Y; + var targetBox = new UIBox2i(0, pixelOffset, pixelWidth, pixelOffset + pixelSize); + child.ArrangePixel(targetBox); + + pixelOffset += pixelSize; + } + #endregion + + return finalSize; + } + + protected override Vector2 MeasureOverride(Vector2 availableSize) + { + _vScrollBar.Measure(availableSize); + availableSize.X -= _vScrollBar.DesiredSize.X; + + var constraint = new Vector2(availableSize.X, float.PositiveInfinity); + + var childSize = Vector2.Zero; + foreach (var child in Children) + { + child.Measure(constraint); + if (child == _vScrollBar) + continue; + childSize = Vector2.ComponentMax(childSize, child.DesiredSize); + } + + if (_itemHeight == 0 && childSize.Y != 0) + _itemHeight = childSize.Y; + + _totalHeight = _itemHeight * _data.Count + ActualSeparation * (_data.Count - 1); + + return new Vector2(childSize.X, 0); + } + + private void ScrollValueChanged(Robust.Client.UserInterface.Controls.Range _) + { + if (_suppressScrollValueChanged) + { + return; + } + + InvalidateArrange(); + } + + protected override void MouseWheel(GUIMouseWheelEventArgs args) + { + base.MouseWheel(args); + + _vScrollBar.ValueTarget -= args.Delta.Y * ScrollSpeedY; + + args.Handle(); + } +} + +public sealed class ListContainerButton : ContainerButton +{ + public readonly ListData Data; + // public PanelContainer Background; + + public ListContainerButton(ListData data) + { + Data = data; + // AddChild(Background = new PanelContainer + // { + // HorizontalExpand = true, + // VerticalExpand = true, + // PanelOverride = new StyleBoxFlat {BackgroundColor = new Color(55, 55, 68)} + // }); + } +} + +#region Data +public abstract record ListData; + +public record EntityListData(EntityUid Uid) : ListData; +#endregion diff --git a/Content.Tests/Client/UserInterface/Controls/ListContainerTest.cs b/Content.Tests/Client/UserInterface/Controls/ListContainerTest.cs new file mode 100644 index 0000000000..b61e9da270 --- /dev/null +++ b/Content.Tests/Client/UserInterface/Controls/ListContainerTest.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Content.Client.UserInterface.Controls; +using NUnit.Framework; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Shared.IoC; +using Robust.Shared.Maths; +using Robust.UnitTesting; + +namespace Content.Tests.Client.UserInterface.Controls; + +[TestFixture] +[TestOf(typeof(ListContainer))] +public sealed class ListContainerTest : RobustUnitTest +{ + public override UnitTestProject Project => UnitTestProject.Client; + + private record TestListData(int Id) : ListData; + + [OneTimeSetUp] + public void Setup() + { + IoCManager.Resolve().InitializeTesting(); + } + + [Test] + public void TestLayoutBasic() + { + var root = new Control { MinSize = (50, 60) }; + var listContainer = new ListContainer { SeparationOverride = 3 }; + root.AddChild(listContainer); + listContainer.GenerateItem += (_, button) => { + button.AddChild(new Control { MinSize = (10, 10) }); + }; + + var list = new List {new(0), new(1)}; + listContainer.PopulateList(list); + root.Arrange(new UIBox2(0, 0, 50, 60)); + + Assert.That(listContainer.ChildCount, Is.EqualTo(3)); + var children = listContainer.Children.ToList(); + Assert.That(children[0].Height, Is.EqualTo(10)); + Assert.That(children[1].Height, Is.EqualTo(10)); + + Assert.That(children[0].Position.Y, Is.EqualTo(0)); + Assert.That(children[1].Position.Y, Is.EqualTo(13)); // Item height + separation + } + + [Test] + public void TestOnlyVisibleItemsAreAdded() + { + /* + * 6 items * 10 height + 5 separation * 3 height = 75 + * One items should be off the render + * 0 13 26 39 52 65 | 75 height + */ + var root = new Control { MinSize = (50, 60) }; + var listContainer = new ListContainer { SeparationOverride = 3 }; + root.AddChild(listContainer); + listContainer.GenerateItem += (_, button) => { + button.AddChild(new Control { MinSize = (10, 10) }); + }; + + var list = new List {new(0), new(1), new(2), new(3), new(4), new(5)}; + listContainer.PopulateList(list); + root.Arrange(new UIBox2(0, 0, 50, 60)); + + // 6 ControlData + Assert.That(listContainer.Data.Count, Is.EqualTo(6)); + // 5 Buttons, 1 Scrollbar + Assert.That(listContainer.ChildCount, Is.EqualTo(6)); + + var children = listContainer.Children.ToList(); + foreach (var child in children) + { + if (child is not ListContainerButton) + continue; + Assert.That(child.Height, Is.EqualTo(10)); + } + + Assert.That(children[0].Position.Y, Is.EqualTo(0)); + Assert.That(children[1].Position.Y, Is.EqualTo(13)); + Assert.That(children[2].Position.Y, Is.EqualTo(26)); + Assert.That(children[3].Position.Y, Is.EqualTo(39)); + Assert.That(children[4].Position.Y, Is.EqualTo(52)); + } + + [Test] + public void TestNextItemIsVisibleWhenScrolled() + { + /* + * 6 items * 10 height + 5 separation * 3 height = 75 + * One items should be off the render + * 0 13 26 39 52 65 | 75 height + */ + var root = new Control { MinSize = (50, 60) }; + var listContainer = new ListContainer { SeparationOverride = 3 }; + root.AddChild(listContainer); + listContainer.GenerateItem += (_, button) => { + button.AddChild(new Control { MinSize = (10, 10) }); + }; + + var list = new List {new(0), new(1), new(2), new(3), new(4), new(5)}; + listContainer.PopulateList(list); + root.Arrange(new UIBox2(0, 0, 50, 60)); + + var scrollbar = (ScrollBar) listContainer.Children.Last(c => c is ScrollBar); + + // Test that 6th button is not visible when scrolled + scrollbar.Value = 5; + listContainer.Arrange(root.SizeBox); + var children = listContainer.Children.ToList(); + // 5 Buttons, 1 Scrollbar + Assert.That(listContainer.ChildCount, Is.EqualTo(6)); + Assert.That(children[0].Position.Y, Is.EqualTo(-5)); + Assert.That(children[1].Position.Y, Is.EqualTo(8)); + Assert.That(children[2].Position.Y, Is.EqualTo(21)); + Assert.That(children[3].Position.Y, Is.EqualTo(34)); + Assert.That(children[4].Position.Y, Is.EqualTo(47)); + + // Test that 6th button is visible when scrolled + scrollbar.Value = 6; + listContainer.Arrange(root.SizeBox); + children = listContainer.Children.ToList(); + // 6 Buttons, 1 Scrollbar + Assert.That(listContainer.ChildCount, Is.EqualTo(7)); + Assert.That(Math.Abs(scrollbar.Value - 6), Is.LessThan(0.01f)); + Assert.That(children[0].Position.Y, Is.EqualTo(-6)); + Assert.That(children[1].Position.Y, Is.EqualTo(7)); + Assert.That(children[2].Position.Y, Is.EqualTo(20)); + Assert.That(children[3].Position.Y, Is.EqualTo(33)); + Assert.That(children[4].Position.Y, Is.EqualTo(46)); + Assert.That(children[5].Position.Y, Is.EqualTo(59)); + } + + [Test] + public void TestPreviousItemIsVisibleWhenScrolled() + { + /* + * 6 items * 10 height + 5 separation * 3 height = 75 + * One items should be off the render + * 0 13 26 39 52 65 | 75 height + */ + var root = new Control { MinSize = (50, 60) }; + var listContainer = new ListContainer { SeparationOverride = 3 }; + root.AddChild(listContainer); + listContainer.GenerateItem += (_, button) => { + button.AddChild(new Control { MinSize = (10, 10) }); + }; + + var list = new List {new(0), new(1), new(2), new(3), new(4), new(5)}; + listContainer.PopulateList(list); + root.Arrange(new UIBox2(0, 0, 50, 60)); + + var scrollbar = (ScrollBar) listContainer.Children.Last(c => c is ScrollBar); + + var scrollValue = 9; + + // Test that 6th button is not visible when scrolled + scrollbar.Value = scrollValue; + listContainer.Arrange(root.SizeBox); + var children = listContainer.Children.ToList(); + // 6 Buttons, 1 Scrollbar + Assert.That(listContainer.ChildCount, Is.EqualTo(7)); + Assert.That(children[0].Position.Y, Is.EqualTo(-9)); + Assert.That(children[1].Position.Y, Is.EqualTo(4)); + Assert.That(children[2].Position.Y, Is.EqualTo(17)); + Assert.That(children[3].Position.Y, Is.EqualTo(30)); + Assert.That(children[4].Position.Y, Is.EqualTo(43)); + Assert.That(children[5].Position.Y, Is.EqualTo(56)); + + // Test that 6th button is visible when scrolled + scrollValue = 10; + scrollbar.Value = scrollValue; + listContainer.Arrange(root.SizeBox); + children = listContainer.Children.ToList(); + // 5 Buttons, 1 Scrollbar + Assert.That(listContainer.ChildCount, Is.EqualTo(6)); + Assert.That(Math.Abs(scrollbar.Value - scrollValue), Is.LessThan(0.01f)); + Assert.That(children[0].Position.Y, Is.EqualTo(3)); + Assert.That(children[1].Position.Y, Is.EqualTo(16)); + Assert.That(children[2].Position.Y, Is.EqualTo(29)); + Assert.That(children[3].Position.Y, Is.EqualTo(42)); + Assert.That(children[4].Position.Y, Is.EqualTo(55)); + } + + /// + /// Test that the ListContainer doesn't push other Controls + /// + [Test] + public void TestDoesNotExpandWhenChildrenAreAdded() + { + var height = 60; + var root = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Vertical, + MinSize = (50, height) + }; + var listContainer = new ListContainer + { + SeparationOverride = 0, + GenerateItem = (_, button) => { button.AddChild(new Control {MinSize = (10, 10)}); } + }; + root.AddChild(listContainer); + var button = new ContainerButton + { + MinSize = (10, 10) + }; + root.AddChild(button); + + var list = new List {new(0), new(1), new(2), new(3), new(4), new(5)}; + listContainer.PopulateList(list); + root.Arrange(new UIBox2(0, 0, 50, height)); + + var children = listContainer.Children.ToList(); + // 6 Buttons, 1 Scrollbar + Assert.That(listContainer.ChildCount, Is.EqualTo(6)); + Assert.That(children[0].Position.Y, Is.EqualTo(0)); + Assert.That(children[1].Position.Y, Is.EqualTo(10)); + Assert.That(children[2].Position.Y, Is.EqualTo(20)); + Assert.That(children[3].Position.Y, Is.EqualTo(30)); + Assert.That(children[4].Position.Y, Is.EqualTo(40)); + Assert.That(button.Position.Y, Is.EqualTo(50)); + } + + [Test] + public void TestSelectedItemStillSelectedWhenScrolling() + { + var height = 10; + var root = new Control { MinSize = (50, height) }; + var listContainer = new ListContainer { SeparationOverride = 0, Toggle = true }; + root.AddChild(listContainer); + listContainer.GenerateItem += (_, button) => { + button.AddChild(new Control { MinSize = (10, 10) }); + }; + + var list = new List {new(0), new(1), new(2), new(3), new(4), new(5)}; + listContainer.PopulateList(list); + root.Arrange(new UIBox2(0, 0, 50, height)); + + var scrollbar = (ScrollBar) listContainer.Children.Last(c => c is ScrollBar); + + var children = listContainer.Children.ToList(); + if (children[0] is not ListContainerButton oldButton) + throw new Exception("First child of ListContainer is not a button"); + + listContainer.Select(oldButton.Data); + + // Test that the button is selected even when scrolled away and scrolled back. + scrollbar.Value = 11; + listContainer.Arrange(root.SizeBox); + Assert.That(oldButton.Disposed); + scrollbar.Value = 0; + listContainer.Arrange(root.SizeBox); + children = listContainer.Children.ToList(); + if (children[0] is not ListContainerButton newButton) + throw new Exception("First child of ListContainer is not a button"); + Assert.That(newButton.Pressed); + Assert.That(newButton.Disposed == false); + } +}