Files
TheriapolisV3/Theriapolis.Game/CodexUI/Core/CodexLayout.cs
T
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

291 lines
11 KiB
C#

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace Theriapolis.Game.CodexUI.Core;
public enum HAlign { Left, Center, Right, Stretch }
public enum VAlign { Top, Middle, Bottom, Stretch }
/// <summary>
/// Container widget that stacks its children vertically with <see cref="Spacing"/>
/// between them. Children are laid out top-to-bottom; their horizontal alignment
/// is governed by <see cref="HAlignChildren"/> (defaults to <see cref="HAlign.Stretch"/>).
/// </summary>
public sealed class Column : CodexWidget
{
public System.Collections.Generic.List<CodexWidget> Children { get; } = new();
public int Spacing { get; set; } = CodexDensity.RowGap;
public Thickness Padding { get; set; }
public HAlign HAlignChildren { get; set; } = HAlign.Stretch;
public Column Add(CodexWidget c) { c.Parent = this; Children.Add(c); return this; }
protected override Point MeasureCore(Point available)
{
int innerW = available.X - Padding.HorizontalSum();
int innerH = available.Y - Padding.VerticalSum();
int totalH = 0, maxW = 0;
for (int i = 0; i < Children.Count; i++)
{
if (!Children[i].Visible) continue;
var s = Children[i].Measure(new Point(innerW, innerH));
totalH += s.Y;
if (i < Children.Count - 1) totalH += Spacing;
if (s.X > maxW) maxW = s.X;
}
return new Point(maxW + Padding.HorizontalSum(), totalH + Padding.VerticalSum());
}
protected override void ArrangeCore(Rectangle bounds)
{
int x = bounds.X + Padding.Left;
int y = bounds.Y + Padding.Top;
int innerW = bounds.Width - Padding.HorizontalSum();
foreach (var c in Children)
{
if (!c.Visible) continue;
int cw = HAlignChildren == HAlign.Stretch ? innerW : System.Math.Min(c.DesiredSize.X, innerW);
int cx = HAlignChildren switch
{
HAlign.Center => x + (innerW - cw) / 2,
HAlign.Right => x + innerW - cw,
_ => x,
};
c.Arrange(new Rectangle(cx, y, cw, c.DesiredSize.Y));
y += c.DesiredSize.Y + Spacing;
}
}
public override void Update(GameTime gt, CodexInput input)
{
foreach (var c in Children) if (c.Visible) c.Update(gt, input);
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
foreach (var c in Children) if (c.Visible) c.Draw(sb, gt);
}
}
/// <summary>
/// Container widget that stacks its children horizontally. Mirrors <see cref="Column"/>
/// along the perpendicular axis; vertical alignment via <see cref="VAlignChildren"/>.
/// </summary>
public sealed class Row : CodexWidget
{
public System.Collections.Generic.List<CodexWidget> Children { get; } = new();
public int Spacing { get; set; } = CodexDensity.ColGap;
public Thickness Padding { get; set; }
public VAlign VAlignChildren { get; set; } = VAlign.Top;
public Row Add(CodexWidget c) { c.Parent = this; Children.Add(c); return this; }
protected override Point MeasureCore(Point available)
{
int innerW = available.X - Padding.HorizontalSum();
int innerH = available.Y - Padding.VerticalSum();
int totalW = 0, maxH = 0;
for (int i = 0; i < Children.Count; i++)
{
if (!Children[i].Visible) continue;
var s = Children[i].Measure(new Point(innerW, innerH));
totalW += s.X;
if (i < Children.Count - 1) totalW += Spacing;
if (s.Y > maxH) maxH = s.Y;
}
return new Point(totalW + Padding.HorizontalSum(), maxH + Padding.VerticalSum());
}
protected override void ArrangeCore(Rectangle bounds)
{
int x = bounds.X + Padding.Left;
int y = bounds.Y + Padding.Top;
int innerH = bounds.Height - Padding.VerticalSum();
foreach (var c in Children)
{
if (!c.Visible) continue;
int cy = VAlignChildren switch
{
VAlign.Middle => y + (innerH - c.DesiredSize.Y) / 2,
VAlign.Bottom => y + innerH - c.DesiredSize.Y,
VAlign.Stretch => y,
_ => y,
};
int ch = VAlignChildren == VAlign.Stretch ? innerH : c.DesiredSize.Y;
c.Arrange(new Rectangle(x, cy, c.DesiredSize.X, ch));
x += c.DesiredSize.X + Spacing;
}
}
public override void Update(GameTime gt, CodexInput input)
{
foreach (var c in Children) if (c.Visible) c.Update(gt, input);
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
foreach (var c in Children) if (c.Visible) c.Draw(sb, gt);
}
}
/// <summary>
/// Wrap-flow layout: lays children left-to-right, breaking to a new row when
/// the running width would exceed the container. Used for chip rows and the
/// review screen's starting-kit grid.
/// </summary>
public sealed class WrapRow : CodexWidget
{
public System.Collections.Generic.List<CodexWidget> Children { get; } = new();
public int HSpacing { get; set; } = CodexDensity.ColGap;
public int VSpacing { get; set; } = CodexDensity.RowGap;
public WrapRow Add(CodexWidget c) { c.Parent = this; Children.Add(c); return this; }
protected override Point MeasureCore(Point available)
{
int innerW = available.X;
int x = 0, y = 0, rowMaxH = 0, totalW = 0;
foreach (var c in Children)
{
if (!c.Visible) continue;
var s = c.Measure(new Point(innerW, available.Y));
if (x > 0 && x + s.X > innerW) { x = 0; y += rowMaxH + VSpacing; rowMaxH = 0; }
x += s.X + HSpacing;
if (s.Y > rowMaxH) rowMaxH = s.Y;
if (x > totalW) totalW = x;
}
y += rowMaxH;
return new Point(System.Math.Min(totalW, innerW), y);
}
protected override void ArrangeCore(Rectangle bounds)
{
int x = bounds.X, y = bounds.Y, rowMaxH = 0;
foreach (var c in Children)
{
if (!c.Visible) continue;
if (x > bounds.X && x + c.DesiredSize.X > bounds.X + bounds.Width)
{
x = bounds.X;
y += rowMaxH + VSpacing;
rowMaxH = 0;
}
c.Arrange(new Rectangle(x, y, c.DesiredSize.X, c.DesiredSize.Y));
x += c.DesiredSize.X + HSpacing;
if (c.DesiredSize.Y > rowMaxH) rowMaxH = c.DesiredSize.Y;
}
}
public override void Update(GameTime gt, CodexInput input)
{
foreach (var c in Children) if (c.Visible) c.Update(gt, input);
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
foreach (var c in Children) if (c.Visible) c.Draw(sb, gt);
}
}
/// <summary>
/// Fixed-column grid: lays children left-to-right then wraps. Each cell gets
/// (innerWidth - colGap*(columns-1)) / columns of horizontal space. Used for
/// the card grid on Clade / Species / Class / Background steps.
/// </summary>
public sealed class Grid : CodexWidget
{
public System.Collections.Generic.List<CodexWidget> Children { get; } = new();
public int Columns { get; set; } = 2;
public int HSpacing { get; set; } = CodexDensity.CardGap;
public int VSpacing { get; set; } = CodexDensity.CardGap;
public Grid Add(CodexWidget c) { c.Parent = this; Children.Add(c); return this; }
protected override Point MeasureCore(Point available)
{
int cols = System.Math.Max(1, Columns);
int cellW = (available.X - HSpacing * (cols - 1)) / cols;
int rowH = 0, totalH = 0, col = 0;
foreach (var c in Children)
{
if (!c.Visible) continue;
var s = c.Measure(new Point(cellW, available.Y));
if (s.Y > rowH) rowH = s.Y;
col++;
if (col >= cols) { totalH += rowH + VSpacing; rowH = 0; col = 0; }
}
if (col > 0) totalH += rowH;
else if (totalH >= VSpacing) totalH -= VSpacing;
return new Point(available.X, totalH);
}
protected override void ArrangeCore(Rectangle bounds)
{
int cols = System.Math.Max(1, Columns);
int cellW = (bounds.Width - HSpacing * (cols - 1)) / cols;
int x = bounds.X, y = bounds.Y, col = 0, rowH = 0;
foreach (var c in Children)
{
if (!c.Visible) continue;
int ch = c.DesiredSize.Y;
c.Arrange(new Rectangle(x, y, cellW, ch));
if (ch > rowH) rowH = ch;
col++;
if (col >= cols) { y += rowH + VSpacing; x = bounds.X; col = 0; rowH = 0; }
else { x += cellW + HSpacing; }
}
}
public override void Update(GameTime gt, CodexInput input)
{
foreach (var c in Children) if (c.Visible) c.Update(gt, input);
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
foreach (var c in Children) if (c.Visible) c.Draw(sb, gt);
}
}
/// <summary>
/// Single-child decorator that adds breathing room. Combine with any widget
/// that doesn't expose its own padding field.
/// </summary>
public sealed class Padding : CodexWidget
{
public CodexWidget? Child { get; set; }
public Thickness Inset { get; set; }
public Padding(CodexWidget child, Thickness inset) { Child = child; if (child is not null) child.Parent = this; Inset = inset; }
protected override Point MeasureCore(Point available)
{
if (Child is null) return new Point(Inset.HorizontalSum(), Inset.VerticalSum());
var inner = new Point(available.X - Inset.HorizontalSum(), available.Y - Inset.VerticalSum());
var s = Child.Measure(inner);
return new Point(s.X + Inset.HorizontalSum(), s.Y + Inset.VerticalSum());
}
protected override void ArrangeCore(Rectangle bounds)
{
Child?.Arrange(new Rectangle(
bounds.X + Inset.Left,
bounds.Y + Inset.Top,
bounds.Width - Inset.HorizontalSum(),
bounds.Height - Inset.VerticalSum()));
}
public override void Update(GameTime gt, CodexInput input) => Child?.Update(gt, input);
public override void Draw(SpriteBatch sb, GameTime gt) => Child?.Draw(sb, gt);
}
/// <summary>Box-model insets: independent left/top/right/bottom in pixels.</summary>
public readonly struct Thickness
{
public readonly int Left, Top, Right, Bottom;
public Thickness(int uniform) : this(uniform, uniform, uniform, uniform) { }
public Thickness(int l, int t, int r, int b) { Left = l; Top = t; Right = r; Bottom = b; }
public int HorizontalSum() => Left + Right;
public int VerticalSum() => Top + Bottom;
}