Skip to content

Commit

Permalink
[Port] Port Guidebook Tables From Wizden PR space-wizards#28484 (spac…
Browse files Browse the repository at this point in the history
…e-wizards#1427)

# Description
This ports the Guidebook Tables to allow \<Table\> and \<ColorBox\>
embeds in the Guidebook.
This just adds extra XML tags to use in rich-text.

---

# TODO
- [x] Cherry-Pick the PR.
- [x] Tested to make sure it works. It does actively work.

---

# Media
<details><summary><h3>Guidebook Screenshot</h3></summary>
<p>

![image](https://github.com/user-attachments/assets/289e4c72-dcef-4489-b89e-5a2d6367124f)
</p>
</details>

NOTE: This screenshot was taken in the dev-environment. I just
copy-pasted my SOP for Alert Levels to check it, since it uses both the
\<Table\> and \<ColorBox\> identifiers.

---

# Changelog
:cl:
- add: Added <Table> and <ColorBox> identifiers. Go wild in SOP!

Co-authored-by: Nemanja <98561806+EmoGarbage404@users.noreply.github.com>
  • Loading branch information
CerberusWolfie and EmoGarbage404 authored Jan 5, 2025
1 parent b8074ea commit 75bb8d3
Show file tree
Hide file tree
Showing 4 changed files with 364 additions and 0 deletions.
3 changes: 3 additions & 0 deletions Content.Client/Guidebook/Richtext/Box.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ public bool TryParseTag(Dictionary<string, string> args, [NotNullWhen(true)] out
HorizontalExpand = true;
control = this;

if (args.TryGetValue("Margin", out var margin))
Margin = new Thickness(float.Parse(margin));

if (args.TryGetValue("Orientation", out var orientation))
Orientation = Enum.Parse<LayoutOrientation>(orientation);
else
Expand Down
49 changes: 49 additions & 0 deletions Content.Client/Guidebook/Richtext/ColorBox.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Diagnostics.CodeAnalysis;
using JetBrains.Annotations;
using Robust.Client.Graphics;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;

namespace Content.Client.Guidebook.Richtext;

[UsedImplicitly]
public sealed class ColorBox : PanelContainer, IDocumentTag
{
public bool TryParseTag(Dictionary<string, string> args, [NotNullWhen(true)] out Control? control)
{
HorizontalExpand = true;
VerticalExpand = true;
control = this;

if (args.TryGetValue("Margin", out var margin))
Margin = new Thickness(float.Parse(margin));

if (args.TryGetValue("HorizontalAlignment", out var halign))
HorizontalAlignment = Enum.Parse<HAlignment>(halign);
else
HorizontalAlignment = HAlignment.Stretch;

if (args.TryGetValue("VerticalAlignment", out var valign))
VerticalAlignment = Enum.Parse<VAlignment>(valign);
else
VerticalAlignment = VAlignment.Stretch;

var styleBox = new StyleBoxFlat();
if (args.TryGetValue("Color", out var color))
styleBox.BackgroundColor = Color.FromHex(color);

if (args.TryGetValue("OutlineThickness", out var outlineThickness))
styleBox.BorderThickness = new Thickness(float.Parse(outlineThickness));
else
styleBox.BorderThickness = new Thickness(1);

if (args.TryGetValue("OutlineColor", out var outlineColor))
styleBox.BorderColor = Color.FromHex(outlineColor);
else
styleBox.BorderColor = Color.White;

PanelOverride = styleBox;

return true;
}
}
27 changes: 27 additions & 0 deletions Content.Client/Guidebook/Richtext/Table.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Diagnostics.CodeAnalysis;
using Content.Client.UserInterface.Controls;
using JetBrains.Annotations;
using Robust.Client.UserInterface;

namespace Content.Client.Guidebook.Richtext;

[UsedImplicitly]
public sealed class Table : TableContainer, IDocumentTag
{
public bool TryParseTag(Dictionary<string, string> args, [NotNullWhen(true)] out Control? control)
{
HorizontalExpand = true;
control = this;

if (!args.TryGetValue("Columns", out var columns) || !int.TryParse(columns, out var columnsCount))
{
Logger.Error("Guidebook tag \"Table\" does not specify required property \"Columns.\"");
control = null;
return false;
}

Columns = columnsCount;

return true;
}
}
285 changes: 285 additions & 0 deletions Content.Client/UserInterface/Controls/TableContainer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
using System.Numerics;
using Robust.Client.UserInterface.Controls;

namespace Content.Client.UserInterface.Controls;

// This control is not part of engine because I quickly wrote it in 2 hours at 2 AM and don't want to deal with
// API stabilization and/or figuring out relation to GridContainer.
// Grid layout is a complicated problem and I don't want to commit another half-baked thing into the engine.
// It's probably sufficient for its use case (RichTextLabel tables for rules/guidebook).
// Despite that, it's still better comment the shit half of you write on a regular basis.
//
// EMO: thank you PJB i was going to kill myself.

/// <summary>
/// Displays children in a tabular grid. Unlike <see cref="GridContainer"/>,
/// properly handles layout constraints so putting word-wrapping <see cref="RichTextLabel"/> in it should work.
/// </summary>
/// <remarks>
/// All children are automatically laid out in <see cref="Columns"/> columns.
/// The first control is in the top left, laid out per row from there.
/// </remarks>
[Virtual]
public class TableContainer : Container
{
private int _columns = 1;

/// <summary>
/// The absolute minimum width a column can be forced to.
/// </summary>
/// <remarks>
/// <para>
/// If a column *asks* for less width than this (small contents), it can still be smaller.
/// But if it asks for more it cannot go below this width.
/// </para>
/// </remarks>
public float MinForcedColumnWidth { get; set; } = 50;

// Scratch space used while calculating layout, cached to avoid regular allocations during layout pass.
private ColumnData[] _columnDataCache = [];
private RowData[] _rowDataCache = [];

/// <summary>
/// How many columns should be displayed.
/// </summary>
public int Columns
{
get => _columns;
set
{
ArgumentOutOfRangeException.ThrowIfLessThan(value, 1, nameof(value));

_columns = value;
}
}

protected override Vector2 MeasureOverride(Vector2 availableSize)
{
ResetCachedArrays();

// Do a first pass measuring all child controls as if they're given infinite space.
// This gives us a maximum width the columns want, which we use to proportion them later.
var columnIdx = 0;
foreach (var child in Children)
{
ref var column = ref _columnDataCache[columnIdx];

child.Measure(new Vector2(float.PositiveInfinity, float.PositiveInfinity));
column.MaxWidth = Math.Max(column.MaxWidth, child.DesiredSize.X);

columnIdx += 1;
if (columnIdx == _columns)
columnIdx = 0;
}

// Calculate Slack and MinWidth for all columns. Also calculate sums for all columns.
var totalMinWidth = 0f;
var totalMaxWidth = 0f;
var totalSlack = 0f;

for (var c = 0; c < _columns; c++)
{
ref var column = ref _columnDataCache[c];
column.MinWidth = Math.Min(column.MaxWidth, MinForcedColumnWidth);
column.Slack = column.MaxWidth - column.MinWidth;

totalMinWidth += column.MinWidth;
totalMaxWidth += column.MaxWidth;
totalSlack += column.Slack;
}

if (totalMaxWidth <= availableSize.X)
{
// We want less horizontal space than we're given. Huh, that's convenient.
// Just set assigned width to be however much they asked for.
// We could probably skip the second measure pass in this scenario,
// but that's just an optimization, so I don't care right now.
//
// There's probably a very clever way to make this behavior work with the else block of logic,
// just by fiddling with the math.
// I'm dumb, it's 4:30 AM. Yeah, I *started* at 2 AM.
for (var c = 0; c < _columns; c++)
{
ref var column = ref _columnDataCache[c];

column.AssignedWidth = column.MaxWidth;
}
}
else
{
// We don't have enough horizontal space,
// at least without causing *some* sort of word wrapping (assuming text contents).
//
// Assign horizontal space proportional to the wanted maximum size of the columns.
var assignableWidth = Math.Max(0, availableSize.X - totalMinWidth);
for (var c = 0; c < _columns; c++)
{
ref var column = ref _columnDataCache[c];

var slackRatio = column.Slack / totalSlack;
column.AssignedWidth = column.MinWidth + slackRatio * assignableWidth;
}
}

// Go over controls for a second measuring pass, this time giving them their assigned measure width.
// This will give us a height to slot into per-row data.
// We still measure assuming infinite vertical space.
// This control can't properly handle being constrained on the Y axis.
columnIdx = 0;
var rowIdx = 0;
foreach (var child in Children)
{
ref var column = ref _columnDataCache[columnIdx];
ref var row = ref _rowDataCache[rowIdx];

child.Measure(new Vector2(column.AssignedWidth, float.PositiveInfinity));
row.MeasuredHeight = Math.Max(row.MeasuredHeight, child.DesiredSize.Y);

columnIdx += 1;
if (columnIdx == _columns)
{
columnIdx = 0;
rowIdx += 1;
}
}

// Sum up height of all rows to get final measured table height.
var totalHeight = 0f;
for (var r = 0; r < _rowDataCache.Length; r++)
{
ref var row = ref _rowDataCache[r];
totalHeight += row.MeasuredHeight;
}

return new Vector2(Math.Min(availableSize.X, totalMaxWidth), totalHeight);
}

protected override Vector2 ArrangeOverride(Vector2 finalSize)
{
// TODO: Expand to fit given vertical space.

// Calculate MinWidth and Slack sums again from column data.
// We could've cached these from measure but whatever.
var totalMinWidth = 0f;
var totalSlack = 0f;

for (var c = 0; c < _columns; c++)
{
ref var column = ref _columnDataCache[c];
totalMinWidth += column.MinWidth;
totalSlack += column.Slack;
}

// Calculate new width based on final given size, also assign horizontal positions of all columns.
var assignableWidth = Math.Max(0, finalSize.X - totalMinWidth);
var xPos = 0f;
for (var c = 0; c < _columns; c++)
{
ref var column = ref _columnDataCache[c];

var slackRatio = column.Slack / totalSlack;
column.ArrangedWidth = column.MinWidth + slackRatio * assignableWidth;
column.ArrangedX = xPos;

xPos += column.ArrangedWidth;
}

// Do actual arrangement row-by-row.
var arrangeY = 0f;
for (var r = 0; r < _rowDataCache.Length; r++)
{
ref var row = ref _rowDataCache[r];

for (var c = 0; c < _columns; c++)
{
ref var column = ref _columnDataCache[c];
var index = c + r * _columns;

if (index >= ChildCount) // Quit early if we don't actually fill out the row.
break;
var child = GetChild(c + r * _columns);

child.Arrange(UIBox2.FromDimensions(column.ArrangedX, arrangeY, column.ArrangedWidth, row.MeasuredHeight));
}

arrangeY += row.MeasuredHeight;
}

return finalSize with { Y = arrangeY };
}

/// <summary>
/// Ensure cached array space is allocated to correct size and is reset to a clean slate.
/// </summary>
private void ResetCachedArrays()
{
// 1-argument Array.Clear() is not currently available in sandbox (added in .NET 6).

if (_columnDataCache.Length != _columns)
_columnDataCache = new ColumnData[_columns];

Array.Clear(_columnDataCache, 0, _columnDataCache.Length);

var rowCount = ChildCount / _columns;
if (ChildCount % _columns != 0)
rowCount += 1;

if (rowCount != _rowDataCache.Length)
_rowDataCache = new RowData[rowCount];

Array.Clear(_rowDataCache, 0, _rowDataCache.Length);
}

/// <summary>
/// Per-column data used during layout.
/// </summary>
private struct ColumnData
{
// Measure data.

/// <summary>
/// The maximum width any control in this column wants, if given infinite space.
/// Maximum of all controls on the column.
/// </summary>
public float MaxWidth;

/// <summary>
/// The minimum width this column may be given.
/// This is either <see cref="MaxWidth"/> or <see cref="TableContainer.MinForcedColumnWidth"/>.
/// </summary>
public float MinWidth;

/// <summary>
/// Difference between max and min width; how much this column can expand from its minimum.
/// </summary>
public float Slack;

/// <summary>
/// How much horizontal space this column was assigned at measure time.
/// </summary>
public float AssignedWidth;

// Arrange data.

/// <summary>
/// How much horizontal space this column was assigned at arrange time.
/// </summary>
public float ArrangedWidth;

/// <summary>
/// The horizontal position this column was assigned at arrange time.
/// </summary>
public float ArrangedX;
}

private struct RowData
{
// Measure data.

/// <summary>
/// How much height the tallest control on this row was measured at,
/// measuring for infinite vertical space but assigned column width.
/// </summary>
public float MeasuredHeight;
}
}

0 comments on commit 75bb8d3

Please sign in to comment.