using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Numerics;
using Robust.Client.Graphics;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
// ReSharper disable CompareOfFloatsByEqualityOperator
namespace Content.Client.UserInterface.Controls;
///
/// A horizontal bar consisting of several colored segments side-by-side. This is used (at time of writing) for the UI
/// of gas analyzers, mops and cryo pods. Entries are animated by default.
///
///
/// To supply data to the chart, first call to get rid of old entries and then call
/// for each item to be added. Note that the order of calls matters.
///
public sealed class SegmentedBarChart : Control
{
private sealed class Entry
{
public float WidthFraction; // This entry's width as a fraction of the chart's total width (between 0 and 1)
public float TargetAmount;
public string Uid; // This UID is used to track entries between frames, for animation.
public string? Tooltip;
public Color Color;
public Label Label;
public Entry(string uid, Label label)
{
Uid = uid;
Label = label;
}
}
public const string StyleClassClassicSplitBar = "ClassicSplitBar";
public const string StylePropertyAnimated = "animated";
public const string StylePropertyShowRuler = "showRuler";
public const string StylePropertyShowBackground = "showBackground";
public const string StylePropertyNotchColor = "notchColor";
public const string StylePropertyBackgroundColor = "backgroundColor";
public const string StylePropertyGap = "gap";
public const string StylePropertyMinEntryWidth = "minEntryWidth";
public const string StylePropertySmallNotchHeight = "smallNotchHeight";
public const string StylePropertyMediumNotchHeight = "mediumNotchHeight";
public const string StylePropertyBigNotchHeight = "bigNotchHeight";
public const string StylePropertyMediumNotchInterval = "mediumNotchInterval";
public const string StylePropertyBigNotchInterval = "bigNotchInterval";
public const string StylePropertyMinSmallNotchScreenDistance = "minSmallNotchScreenDistance";
///
/// When Gap is greater than zero, all segments are separated by empty space. Gap is measured in UI units.
/// This is incompatible with ShowRuler. If ShowRuler is enabled, Gap is ignored.
///
public float? Gap { get; set; } = null;
///
/// The minimum width of a segment in UI units.
///
public float? MinEntryWidth { get; set; } = null;
///
/// How many units of "Amount" fit into this chart. For example, when Capacity is 50, an entry with an amount of 5
/// will take up 10% of the chart. If this is -1, the capacity is flexible and equal to the sum of the entries.
///
public float Capacity { get; set; } = -1;
///
/// Whether this chart is animated.
///
public bool? Animated { get; set; } = null;
///
/// Whether the ruler is drawn. Check the cryo pod UI to see what the ruler looks like.
/// The ruler can be further configured through the various "Notch" properties.
///
public bool? ShowRuler { get; set; } = null;
///
/// Whether a background will be drawn behind the chart. The background is a simple rectangle and does not have
/// gaps when Gap is non-zero. The color of the background is determined by the style property "backgroundColor".
///
public bool? ShowBackground { get; set; } = null;
// Every `Notch` variable is related to the ruler.
public int? MediumNotchInterval { get; set; } = null;
public int? BigNotchInterval { get; set; } = null;
///
/// For the cryo pod UI, when we have a very large beaker (e.g. a bluespace beaker) we might need to increase the
/// distance between notches to prevent all the notches from turning into a solid rectangle. When the distance
/// between notches is less than MinSmallNotchScreenDistance in UI units, the distance is increased by a factor of
/// ten (repeated as often as necessary).
///
public int? MinSmallNotchScreenDistance { get; set; } = null;
public float? SmallNotchHeight { get; set; } = null;
public float? MediumNotchHeight { get; set; } = null;
public float? BigNotchHeight { get; set; } = null;
// Most properties can either be provided by the stylesheet or overriden through a property.
// These helper computed properties make the bulk of the code a little less ugly.
private Color _backgroundColor => StylePropertyDefault(StylePropertyBackgroundColor, new Color(0.1f, 0.1f, 0.1f));
private Color _notchColor => StylePropertyDefault(StylePropertyNotchColor, Color.White.WithAlpha(0.25f));
private float _gap => Gap ?? StylePropertyDefault(StylePropertyGap, 0);
private float _minEntryWidth => MinEntryWidth ?? StylePropertyDefault(StylePropertyMinEntryWidth, 0);
private bool _animated => Animated ?? StylePropertyDefault(StylePropertyAnimated, false);
private bool _showRuler => ShowRuler ?? StylePropertyDefault(StylePropertyShowRuler, false);
private bool _showBackground => ShowBackground ?? StylePropertyDefault(StylePropertyShowBackground, false);
private float _mediumNotchInterval => MediumNotchInterval ?? StylePropertyDefault(StylePropertyMediumNotchInterval, 5);
private float _bigNotchInterval => BigNotchInterval ?? StylePropertyDefault(StylePropertyBigNotchInterval, 10);
private float _minSmallNotchScreenDistance => MinSmallNotchScreenDistance ?? StylePropertyDefault(StylePropertyMinSmallNotchScreenDistance, 2);
private float _smallNotchHeight => SmallNotchHeight ?? StylePropertyDefault(StylePropertySmallNotchHeight, 0.1f);
private float _mediumNotchHeight => MediumNotchHeight ?? StylePropertyDefault(StylePropertyMediumNotchHeight, 0.25f);
private float _bigNotchHeight => BigNotchHeight ?? StylePropertyDefault(StylePropertyBigNotchHeight, 1f);
// We don't animate new entries until this control has had at least one update where its width was non-zero.
private bool _hasHadNonZeroWidth = false;
// This is used to keep the segments of the chart in the same order as the SetEntry calls.
// For example: In update 1 we might get cryox, alox, bic (in that order), and in update 2 we get alox, cryox, bic.
// To keep the order of the entries the same as the order of the SetEntry calls, we let the old cryox entry
// disappear and create a new cryox entry behind the alox entry.
private int _nextUpdateableEntry = 0;
private readonly List _entries = new();
public SegmentedBarChart()
{
MouseFilter = MouseFilterMode.Pass;
TooltipSupplier = SupplyTooltip;
}
protected override void Draw(DrawingHandleScreen handle)
{
if (_showBackground)
handle.DrawRect(PixelSizeBox, _backgroundColor);
// Draw the entry backgrounds
foreach (var (entry, xMinUI, xMaxUI) in EntryRanges(Width))
{
var xMin = UIScale * xMinUI;
var xMax = UIScale * xMaxUI;
if (xMin != xMax)
handle.DrawRect(new(xMin, 0, xMax, PixelHeight), entry.Color);
}
// Draw the ruler
if (_showRuler)
{
// These computed properties are used in the loop. Compute them only once.
var bigNotchInterval = _bigNotchInterval;
var mediumNotchInterval = _mediumNotchInterval;
var bigNotchHeight = _bigNotchHeight;
var mediumNotchHeight = _mediumNotchHeight;
var smallNotchHeight = _smallNotchHeight;
var notchColor = _notchColor;
var capacity = GetCapacity();
var unitWidth = PixelWidth / capacity;
// This math ensures the distance between notches is not less than `MinSmallNotchScreenDistance`.
// We make sure that `unitsPerNotch` is always a power of ten (normally 1, 10 or 100).
var maxNotches = Width / _minSmallNotchScreenDistance;
var exp = MathF.Floor(MathF.Log10(maxNotches / capacity));
var unitsPerNotch = 1f / MathF.Min(1, MathF.Pow(10, exp));
var notchCount = (int)MathF.Floor(capacity / unitsPerNotch);
var notchDistance = unitWidth * unitsPerNotch;
for (int i = 0; i <= notchCount; i++)
{
var x = i * notchDistance;
var height = (i % bigNotchInterval == 0 ? bigNotchHeight :
i % mediumNotchInterval == 0 ? mediumNotchHeight :
smallNotchHeight) * PixelHeight;
var start = new Vector2(x, PixelHeight);
var end = new Vector2(x, PixelHeight - height);
handle.DrawLine(start, end, notchColor);
}
}
}
protected override Vector2 ArrangeOverride(Vector2 finalSize)
{
// Some features (Gap, MinEntryWidth) depend on the Control's Width. Once the Width is set and before the
// first draw, make sure that the entries get an opportunity to update their width properly.
if (!_hasHadNonZeroWidth && finalSize.X > 0)
UpdateEntries(finalSize.X, 0);
foreach (var (entry, xMin, xMax) in EntryRanges(finalSize.X))
{
entry.Label.Arrange(new((int)xMin, 0, (int)xMax, (int)finalSize.Y));
}
return finalSize;
}
protected override void FrameUpdate(FrameEventArgs args)
{
UpdateEntries(Width, args.DeltaSeconds);
}
protected override void MouseMove(GUIMouseMoveEventArgs args)
{
HideTooltip();
}
///
/// Makes the current entries disappear (usually in an animated way, so it takes a while for them to be gone).
/// An entry won't disappear if a SetEntry call (after the Clear) gives the entry a non-zero `amount`.
///
public void Clear()
{
foreach (var entry in _entries)
{
entry.TargetAmount = 0;
}
_nextUpdateableEntry = 0;
}
///
/// Either adds a new entry to the chart if the UID doesn't appear yet, or updates the amount of an existing entry.
/// Entries are drawn in the order of the SetEntry calls, with the first entry on the left and the last on the
/// right.
///
public void SetEntry(
string uid,
float amount,
Color color,
string? text = null,
Color? textColor = null,
string? tooltip = null)
{
// If we can find an old entry we're allowed to update, update that one.
if (TryFindUpdateableEntry(uid, out var index))
{
_entries[index].TargetAmount = amount;
_entries[index].Color = color;
_entries[index].Tooltip = tooltip;
_entries[index].Label.Text = text;
_nextUpdateableEntry = index + 1;
return;
}
// Otherwise create a new entry.
if (amount <= 0)
return;
// If no text color is provided, use either white or black depending on how dark the background is.
textColor ??= (color.R + color.G + color.B < 1.5f ? Color.White : Color.Black);
var childLabel = new Label
{
Text = text,
ClipText = true,
FontColorOverride = textColor,
Margin = new Thickness(4, 0, 0, 0)
};
AddChild(childLabel);
_entries.Insert(
_nextUpdateableEntry,
new Entry(uid, childLabel)
{
WidthFraction = 0,
TargetAmount = amount,
Tooltip = tooltip,
Color = color,
Label = childLabel
}
);
_nextUpdateableEntry += 1;
}
private Control? SupplyTooltip(Control sender)
{
var globalMousePos = UserInterfaceManager.MousePositionScaled.Position;
var mousePos = globalMousePos - GlobalPosition;
if (!TryFindEntry(mousePos.X, Width, out var entry) || entry.Tooltip == null)
return null;
var msg = new FormattedMessage();
msg.AddText(entry.Tooltip);
var tooltip = new Tooltip();
tooltip.SetMessage(msg);
return tooltip;
}
private bool TryFindUpdateableEntry(string uid, out int index)
{
for (int i = _nextUpdateableEntry; i < _entries.Count; i++)
{
if (_entries[i].Uid == uid)
{
index = i;
return true;
}
}
index = -1;
return false;
}
private IEnumerable<(Entry, float xMin, float xMax)> EntryRanges(float chartWidth)
{
var xStart = 0f;
var gapWidth = (_entries.Count > 1
? GetTotalGapsWidthFraction(chartWidth) * chartWidth / (_entries.Count - 1)
: 0);
foreach (var entry in _entries)
{
var entryWidth = entry.WidthFraction * chartWidth;
var xEnd = MathF.Min(xStart + entryWidth, chartWidth);
yield return (entry, xStart, xEnd);
xStart = MathF.Min(xEnd + gapWidth, chartWidth);
}
}
private bool TryFindEntry(float x, float chartWidth, [NotNullWhen(true)] out Entry? entry)
{
foreach (var (currentEntry, xMin, xMax) in EntryRanges(chartWidth))
{
if (x < xMin)
break;
if (x > xMax)
continue;
entry = currentEntry;
return true;
}
entry = null;
return false;
}
private void UpdateEntries(float chartWidth, float deltaSeconds)
{
// Tween the amounts to their target amounts.
const float tweenInverseHalfLife = 8; // Half life of tween is 1/n
var hasChanged = false;
var animated = _animated;
// This next series of calculations is somewhat complicated. We're trying to calculate the desired width for
// each entry, but there's a couple of complicating factors: Gap, MinEntryWidth and constant/flexible capacities
//
// The calculations are done in width fractions (i.e. a percentage of this Control's width) because it works
// better for animations, especially when swapping out a 50u beaker for a 100u beaker.
//
// In the calculation the width of an entry is split into two parts:
// The minWidthFraction is based on MinEntryWidth and is the same for all entries,
// and the remainder of the space is divided into flexibleWidthFractions (or left as empty space).
// Normally the great majority of space is taken up by flexibleWidthFractions.
// The amount of entries we want to have after animations are complete (some entries may disappear).
var targetEntryCount = 0;
foreach (var entry in _entries)
{
if (entry.TargetAmount > 0)
targetEntryCount += 1;
}
var isCapacityFlexible = (Capacity <= 0);
var totalAmount = GetCapacity();
// The width available for entries.
var totalEntriesWidthFraction = 1 - GetTotalGapsWidthFraction(chartWidth);
// The min width for all entries can't be wider than the available space per entry.
var spacePerEntry = totalEntriesWidthFraction / MathF.Max(1, targetEntryCount);
// Minimum width of an entry.
var minWidthFraction = MathF.Min(spacePerEntry, _minEntryWidth / MathF.Max(1, chartWidth));
// The amount of units that `minWidthFraction` covers.
var minWidthAmount = minWidthFraction * totalAmount;
// The width that can still be divided among flexible width fractions.
var remainingWidthFraction = totalEntriesWidthFraction - minWidthFraction * targetEntryCount;
// The amount of units that can be divided among flexible width fractions.
var remainingAmount =
(isCapacityFlexible
? _entries.Aggregate(0f, (sum, entry) => sum + MathF.Max(0, entry.TargetAmount - minWidthAmount))
: totalAmount - minWidthAmount);
foreach (var entry in _entries)
{
// Calculate the target width for this entry.
var targetWidthFraction = 0f;
if (entry.TargetAmount != 0)
{
var flexibleAmount = MathF.Max(0, entry.TargetAmount - minWidthAmount);
var flexibleWidthFraction =
(remainingAmount != 0
? (flexibleAmount / remainingAmount) * remainingWidthFraction
: 0);
targetWidthFraction = minWidthFraction + flexibleWidthFraction;
}
if (entry.WidthFraction == targetWidthFraction)
continue;
// Move the entry's width towards its target width.
hasChanged = true;
if (animated && _hasHadNonZeroWidth)
{
// Tween with lerp abuse interpolation
entry.WidthFraction = MathHelper.Lerp(
entry.WidthFraction,
targetWidthFraction,
MathHelper.Clamp01(tweenInverseHalfLife * deltaSeconds)
);
if (MathF.Abs(entry.WidthFraction - targetWidthFraction) < 0.0001f)
entry.WidthFraction = targetWidthFraction;
}
else
{
// Don't animate, just snap straight to the target.
entry.WidthFraction = targetWidthFraction;
}
}
_hasHadNonZeroWidth |= (chartWidth > 0);
if (!hasChanged)
return;
InvalidateArrange();
// Remove old entries whose animations have finished.
foreach (var entry in _entries)
{
if (entry.WidthFraction == 0 && entry.TargetAmount == 0)
RemoveChild(entry.Label);
}
_entries.RemoveAll(entry => entry.WidthFraction == 0 && entry.TargetAmount == 0);
}
private float GetCapacity()
{
// Constant capacity.
if (Capacity > 0)
return Capacity;
// Flexible capacity.
var amountSum = _entries.Aggregate(0f, (sum, entry) => sum + entry.TargetAmount);
return MathF.Max(0.001f, amountSum); // Make sure it's not zero (it's often used as denominator)
}
private float GetTotalGapsWidthFraction(float chartWidth)
{
if (_showRuler)
return 0; // ShowRuler is incompatible with Gap.
var gapsWidth = (_entries.Count - 1) * _gap;
var gapsFraction = gapsWidth / MathF.Max(chartWidth, 1f);
// We limit the gaps to cover max 25% of the chart, to make sure there's always space for entries no matter
// how many entries you add.
return MathF.Min(gapsFraction, 0.25f);
}
}