From 21d0b1fd558f417c25e0587cf8d0186ec83a294e Mon Sep 17 00:00:00 2001 From: Nemanja <98561806+EmoGarbage404@users.noreply.github.com> Date: Sat, 1 Jun 2024 23:58:33 -0400 Subject: [PATCH] Guidebook Tables (#28484) * PJB's cool table control (it probably doesn't work) * ok wait wrong file * Guidebook Tables --- Content.Client/Guidebook/Richtext/Box.cs | 3 + Content.Client/Guidebook/Richtext/ColorBox.cs | 49 +++ Content.Client/Guidebook/Richtext/Table.cs | 27 ++ .../UserInterface/Controls/TableContainer.cs | 285 ++++++++++++++++++ 4 files changed, 364 insertions(+) create mode 100644 Content.Client/Guidebook/Richtext/ColorBox.cs create mode 100644 Content.Client/Guidebook/Richtext/Table.cs create mode 100644 Content.Client/UserInterface/Controls/TableContainer.cs diff --git a/Content.Client/Guidebook/Richtext/Box.cs b/Content.Client/Guidebook/Richtext/Box.cs index ecf6cb21f70a..6e18ad9c5754 100644 --- a/Content.Client/Guidebook/Richtext/Box.cs +++ b/Content.Client/Guidebook/Richtext/Box.cs @@ -11,6 +11,9 @@ public bool TryParseTag(Dictionary 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(orientation); else diff --git a/Content.Client/Guidebook/Richtext/ColorBox.cs b/Content.Client/Guidebook/Richtext/ColorBox.cs new file mode 100644 index 000000000000..84de300d6e01 --- /dev/null +++ b/Content.Client/Guidebook/Richtext/ColorBox.cs @@ -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 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(halign); + else + HorizontalAlignment = HAlignment.Stretch; + + if (args.TryGetValue("VerticalAlignment", out var valign)) + VerticalAlignment = Enum.Parse(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; + } +} diff --git a/Content.Client/Guidebook/Richtext/Table.cs b/Content.Client/Guidebook/Richtext/Table.cs new file mode 100644 index 000000000000..b6923c3698ea --- /dev/null +++ b/Content.Client/Guidebook/Richtext/Table.cs @@ -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 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; + } +} diff --git a/Content.Client/UserInterface/Controls/TableContainer.cs b/Content.Client/UserInterface/Controls/TableContainer.cs new file mode 100644 index 000000000000..3e8d4760015a --- /dev/null +++ b/Content.Client/UserInterface/Controls/TableContainer.cs @@ -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. + +/// +/// Displays children in a tabular grid. Unlike , +/// properly handles layout constraints so putting word-wrapping in it should work. +/// +/// +/// All children are automatically laid out in columns. +/// The first control is in the top left, laid out per row from there. +/// +[Virtual] +public class TableContainer : Container +{ + private int _columns = 1; + + /// + /// The absolute minimum width a column can be forced to. + /// + /// + /// + /// 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. + /// + /// + 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 = []; + + /// + /// How many columns should be displayed. + /// + 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 }; + } + + /// + /// Ensure cached array space is allocated to correct size and is reset to a clean slate. + /// + 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); + } + + /// + /// Per-column data used during layout. + /// + private struct ColumnData + { + // Measure data. + + /// + /// The maximum width any control in this column wants, if given infinite space. + /// Maximum of all controls on the column. + /// + public float MaxWidth; + + /// + /// The minimum width this column may be given. + /// This is either or . + /// + public float MinWidth; + + /// + /// Difference between max and min width; how much this column can expand from its minimum. + /// + public float Slack; + + /// + /// How much horizontal space this column was assigned at measure time. + /// + public float AssignedWidth; + + // Arrange data. + + /// + /// How much horizontal space this column was assigned at arrange time. + /// + public float ArrangedWidth; + + /// + /// The horizontal position this column was assigned at arrange time. + /// + public float ArrangedX; + } + + private struct RowData + { + // Measure data. + + /// + /// How much height the tallest control on this row was measured at, + /// measuring for infinite vertical space but assigned column width. + /// + public float MeasuredHeight; + } +}