|
| 1 | +using System.Numerics; |
| 2 | +using Robust.Client.UserInterface.Controls; |
| 3 | + |
| 4 | +namespace Content.Client.UserInterface.Controls; |
| 5 | + |
| 6 | +// 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 |
| 7 | +// API stabilization and/or figuring out relation to GridContainer. |
| 8 | +// Grid layout is a complicated problem and I don't want to commit another half-baked thing into the engine. |
| 9 | +// It's probably sufficient for its use case (RichTextLabel tables for rules/guidebook). |
| 10 | +// Despite that, it's still better comment the shit half of you write on a regular basis. |
| 11 | +// |
| 12 | +// EMO: thank you PJB i was going to kill myself. |
| 13 | + |
| 14 | +/// <summary> |
| 15 | +/// Displays children in a tabular grid. Unlike <see cref="GridContainer"/>, |
| 16 | +/// properly handles layout constraints so putting word-wrapping <see cref="RichTextLabel"/> in it should work. |
| 17 | +/// </summary> |
| 18 | +/// <remarks> |
| 19 | +/// All children are automatically laid out in <see cref="Columns"/> columns. |
| 20 | +/// The first control is in the top left, laid out per row from there. |
| 21 | +/// </remarks> |
| 22 | +[Virtual] |
| 23 | +public class TableContainer : Container |
| 24 | +{ |
| 25 | + private int _columns = 1; |
| 26 | + |
| 27 | + /// <summary> |
| 28 | + /// The absolute minimum width a column can be forced to. |
| 29 | + /// </summary> |
| 30 | + /// <remarks> |
| 31 | + /// <para> |
| 32 | + /// If a column *asks* for less width than this (small contents), it can still be smaller. |
| 33 | + /// But if it asks for more it cannot go below this width. |
| 34 | + /// </para> |
| 35 | + /// </remarks> |
| 36 | + public float MinForcedColumnWidth { get; set; } = 50; |
| 37 | + |
| 38 | + // Scratch space used while calculating layout, cached to avoid regular allocations during layout pass. |
| 39 | + private ColumnData[] _columnDataCache = []; |
| 40 | + private RowData[] _rowDataCache = []; |
| 41 | + |
| 42 | + /// <summary> |
| 43 | + /// How many columns should be displayed. |
| 44 | + /// </summary> |
| 45 | + public int Columns |
| 46 | + { |
| 47 | + get => _columns; |
| 48 | + set |
| 49 | + { |
| 50 | + ArgumentOutOfRangeException.ThrowIfLessThan(value, 1, nameof(value)); |
| 51 | + |
| 52 | + _columns = value; |
| 53 | + } |
| 54 | + } |
| 55 | + |
| 56 | + protected override Vector2 MeasureOverride(Vector2 availableSize) |
| 57 | + { |
| 58 | + ResetCachedArrays(); |
| 59 | + |
| 60 | + // Do a first pass measuring all child controls as if they're given infinite space. |
| 61 | + // This gives us a maximum width the columns want, which we use to proportion them later. |
| 62 | + var columnIdx = 0; |
| 63 | + foreach (var child in Children) |
| 64 | + { |
| 65 | + ref var column = ref _columnDataCache[columnIdx]; |
| 66 | + |
| 67 | + child.Measure(new Vector2(float.PositiveInfinity, float.PositiveInfinity)); |
| 68 | + column.MaxWidth = Math.Max(column.MaxWidth, child.DesiredSize.X); |
| 69 | + |
| 70 | + columnIdx += 1; |
| 71 | + if (columnIdx == _columns) |
| 72 | + columnIdx = 0; |
| 73 | + } |
| 74 | + |
| 75 | + // Calculate Slack and MinWidth for all columns. Also calculate sums for all columns. |
| 76 | + var totalMinWidth = 0f; |
| 77 | + var totalMaxWidth = 0f; |
| 78 | + var totalSlack = 0f; |
| 79 | + |
| 80 | + for (var c = 0; c < _columns; c++) |
| 81 | + { |
| 82 | + ref var column = ref _columnDataCache[c]; |
| 83 | + column.MinWidth = Math.Min(column.MaxWidth, MinForcedColumnWidth); |
| 84 | + column.Slack = column.MaxWidth - column.MinWidth; |
| 85 | + |
| 86 | + totalMinWidth += column.MinWidth; |
| 87 | + totalMaxWidth += column.MaxWidth; |
| 88 | + totalSlack += column.Slack; |
| 89 | + } |
| 90 | + |
| 91 | + if (totalMaxWidth <= availableSize.X) |
| 92 | + { |
| 93 | + // We want less horizontal space than we're given. Huh, that's convenient. |
| 94 | + // Just set assigned width to be however much they asked for. |
| 95 | + // We could probably skip the second measure pass in this scenario, |
| 96 | + // but that's just an optimization, so I don't care right now. |
| 97 | + // |
| 98 | + // There's probably a very clever way to make this behavior work with the else block of logic, |
| 99 | + // just by fiddling with the math. |
| 100 | + // I'm dumb, it's 4:30 AM. Yeah, I *started* at 2 AM. |
| 101 | + for (var c = 0; c < _columns; c++) |
| 102 | + { |
| 103 | + ref var column = ref _columnDataCache[c]; |
| 104 | + |
| 105 | + column.AssignedWidth = column.MaxWidth; |
| 106 | + } |
| 107 | + } |
| 108 | + else |
| 109 | + { |
| 110 | + // We don't have enough horizontal space, |
| 111 | + // at least without causing *some* sort of word wrapping (assuming text contents). |
| 112 | + // |
| 113 | + // Assign horizontal space proportional to the wanted maximum size of the columns. |
| 114 | + var assignableWidth = Math.Max(0, availableSize.X - totalMinWidth); |
| 115 | + for (var c = 0; c < _columns; c++) |
| 116 | + { |
| 117 | + ref var column = ref _columnDataCache[c]; |
| 118 | + |
| 119 | + var slackRatio = column.Slack / totalSlack; |
| 120 | + column.AssignedWidth = column.MinWidth + slackRatio * assignableWidth; |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + // Go over controls for a second measuring pass, this time giving them their assigned measure width. |
| 125 | + // This will give us a height to slot into per-row data. |
| 126 | + // We still measure assuming infinite vertical space. |
| 127 | + // This control can't properly handle being constrained on the Y axis. |
| 128 | + columnIdx = 0; |
| 129 | + var rowIdx = 0; |
| 130 | + foreach (var child in Children) |
| 131 | + { |
| 132 | + ref var column = ref _columnDataCache[columnIdx]; |
| 133 | + ref var row = ref _rowDataCache[rowIdx]; |
| 134 | + |
| 135 | + child.Measure(new Vector2(column.AssignedWidth, float.PositiveInfinity)); |
| 136 | + row.MeasuredHeight = Math.Max(row.MeasuredHeight, child.DesiredSize.Y); |
| 137 | + |
| 138 | + columnIdx += 1; |
| 139 | + if (columnIdx == _columns) |
| 140 | + { |
| 141 | + columnIdx = 0; |
| 142 | + rowIdx += 1; |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + // Sum up height of all rows to get final measured table height. |
| 147 | + var totalHeight = 0f; |
| 148 | + for (var r = 0; r < _rowDataCache.Length; r++) |
| 149 | + { |
| 150 | + ref var row = ref _rowDataCache[r]; |
| 151 | + totalHeight += row.MeasuredHeight; |
| 152 | + } |
| 153 | + |
| 154 | + return new Vector2(Math.Min(availableSize.X, totalMaxWidth), totalHeight); |
| 155 | + } |
| 156 | + |
| 157 | + protected override Vector2 ArrangeOverride(Vector2 finalSize) |
| 158 | + { |
| 159 | + // TODO: Expand to fit given vertical space. |
| 160 | + |
| 161 | + // Calculate MinWidth and Slack sums again from column data. |
| 162 | + // We could've cached these from measure but whatever. |
| 163 | + var totalMinWidth = 0f; |
| 164 | + var totalSlack = 0f; |
| 165 | + |
| 166 | + for (var c = 0; c < _columns; c++) |
| 167 | + { |
| 168 | + ref var column = ref _columnDataCache[c]; |
| 169 | + totalMinWidth += column.MinWidth; |
| 170 | + totalSlack += column.Slack; |
| 171 | + } |
| 172 | + |
| 173 | + // Calculate new width based on final given size, also assign horizontal positions of all columns. |
| 174 | + var assignableWidth = Math.Max(0, finalSize.X - totalMinWidth); |
| 175 | + var xPos = 0f; |
| 176 | + for (var c = 0; c < _columns; c++) |
| 177 | + { |
| 178 | + ref var column = ref _columnDataCache[c]; |
| 179 | + |
| 180 | + var slackRatio = column.Slack / totalSlack; |
| 181 | + column.ArrangedWidth = column.MinWidth + slackRatio * assignableWidth; |
| 182 | + column.ArrangedX = xPos; |
| 183 | + |
| 184 | + xPos += column.ArrangedWidth; |
| 185 | + } |
| 186 | + |
| 187 | + // Do actual arrangement row-by-row. |
| 188 | + var arrangeY = 0f; |
| 189 | + for (var r = 0; r < _rowDataCache.Length; r++) |
| 190 | + { |
| 191 | + ref var row = ref _rowDataCache[r]; |
| 192 | + |
| 193 | + for (var c = 0; c < _columns; c++) |
| 194 | + { |
| 195 | + ref var column = ref _columnDataCache[c]; |
| 196 | + var index = c + r * _columns; |
| 197 | + |
| 198 | + if (index >= ChildCount) // Quit early if we don't actually fill out the row. |
| 199 | + break; |
| 200 | + var child = GetChild(c + r * _columns); |
| 201 | + |
| 202 | + child.Arrange(UIBox2.FromDimensions(column.ArrangedX, arrangeY, column.ArrangedWidth, row.MeasuredHeight)); |
| 203 | + } |
| 204 | + |
| 205 | + arrangeY += row.MeasuredHeight; |
| 206 | + } |
| 207 | + |
| 208 | + return finalSize with { Y = arrangeY }; |
| 209 | + } |
| 210 | + |
| 211 | + /// <summary> |
| 212 | + /// Ensure cached array space is allocated to correct size and is reset to a clean slate. |
| 213 | + /// </summary> |
| 214 | + private void ResetCachedArrays() |
| 215 | + { |
| 216 | + // 1-argument Array.Clear() is not currently available in sandbox (added in .NET 6). |
| 217 | + |
| 218 | + if (_columnDataCache.Length != _columns) |
| 219 | + _columnDataCache = new ColumnData[_columns]; |
| 220 | + |
| 221 | + Array.Clear(_columnDataCache, 0, _columnDataCache.Length); |
| 222 | + |
| 223 | + var rowCount = ChildCount / _columns; |
| 224 | + if (ChildCount % _columns != 0) |
| 225 | + rowCount += 1; |
| 226 | + |
| 227 | + if (rowCount != _rowDataCache.Length) |
| 228 | + _rowDataCache = new RowData[rowCount]; |
| 229 | + |
| 230 | + Array.Clear(_rowDataCache, 0, _rowDataCache.Length); |
| 231 | + } |
| 232 | + |
| 233 | + /// <summary> |
| 234 | + /// Per-column data used during layout. |
| 235 | + /// </summary> |
| 236 | + private struct ColumnData |
| 237 | + { |
| 238 | + // Measure data. |
| 239 | + |
| 240 | + /// <summary> |
| 241 | + /// The maximum width any control in this column wants, if given infinite space. |
| 242 | + /// Maximum of all controls on the column. |
| 243 | + /// </summary> |
| 244 | + public float MaxWidth; |
| 245 | + |
| 246 | + /// <summary> |
| 247 | + /// The minimum width this column may be given. |
| 248 | + /// This is either <see cref="MaxWidth"/> or <see cref="TableContainer.MinForcedColumnWidth"/>. |
| 249 | + /// </summary> |
| 250 | + public float MinWidth; |
| 251 | + |
| 252 | + /// <summary> |
| 253 | + /// Difference between max and min width; how much this column can expand from its minimum. |
| 254 | + /// </summary> |
| 255 | + public float Slack; |
| 256 | + |
| 257 | + /// <summary> |
| 258 | + /// How much horizontal space this column was assigned at measure time. |
| 259 | + /// </summary> |
| 260 | + public float AssignedWidth; |
| 261 | + |
| 262 | + // Arrange data. |
| 263 | + |
| 264 | + /// <summary> |
| 265 | + /// How much horizontal space this column was assigned at arrange time. |
| 266 | + /// </summary> |
| 267 | + public float ArrangedWidth; |
| 268 | + |
| 269 | + /// <summary> |
| 270 | + /// The horizontal position this column was assigned at arrange time. |
| 271 | + /// </summary> |
| 272 | + public float ArrangedX; |
| 273 | + } |
| 274 | + |
| 275 | + private struct RowData |
| 276 | + { |
| 277 | + // Measure data. |
| 278 | + |
| 279 | + /// <summary> |
| 280 | + /// How much height the tallest control on this row was measured at, |
| 281 | + /// measuring for infinite vertical space but assigned column width. |
| 282 | + /// </summary> |
| 283 | + public float MeasuredHeight; |
| 284 | + } |
| 285 | +} |
0 commit comments