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);
+ }
+}