diff --git a/Models/ScoreboardLayout.cs b/Models/ScoreboardLayout.cs new file mode 100644 index 0000000..368eefa --- /dev/null +++ b/Models/ScoreboardLayout.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ytLive.Models; + +/// Which edge a full-width bar occupies. +public enum BarEdge +{ + Top, + Bottom, +} + +/// The scoreboard's placement and line-fitting arithmetic, kept pure so both are testable +/// without a renderer. +/// +/// Two rules from the creator (2026-10-30): +/// the strip and the social bar are a COUPLED pair that own opposite edges, resolved at render time +/// from what is actually visible; and a strip that does not fit SHRINKS to a floor and then wraps to +/// a second line — it never scrolls, because a creator reading the screen cannot wait for the fact +/// to come round. +public static class ScoreboardLayout +{ + public const double FloorFontSize = 14; + public const double MaxFontSize = 24; + + /// Result of fitting the enabled tiles into one strip. + public readonly record struct Fit(double FontSize, int LineCount, bool Shrunk) + { + public bool Wrapped => LineCount > 1; + } + + /// Resolves the edge pair. When both bars are visible they take opposite edges; the + /// social bar keeps its own choice and the scoreboard is pushed to the other one. When either is + /// hidden the survivor keeps its preferred edge — so hiding the scoreboard frees the social bar + /// to sit anywhere, exactly as the creator asked. + public static (BarEdge Social, BarEdge Scoreboard) ResolveEdges( + bool socialVisible, BarEdge socialPreferred, + bool scoreboardVisible, BarEdge scoreboardPreferred) + { + if (socialVisible && scoreboardVisible) + return (socialPreferred, Opposite(socialPreferred)); + + return (socialPreferred, scoreboardPreferred); + } + + public static BarEdge Opposite(BarEdge edge) => edge == BarEdge.Top ? BarEdge.Bottom : BarEdge.Top; + + /// Fits measured tile widths (at ) into the available + /// width: full size if they fit, otherwise shrink, and only if the shrink would drop below the + /// legibility floor does it wrap. Widths are passed in because the renderer MEASURES them — + /// guessing a character width would make the fit lie for a wide glyph or a long handle. + public static Fit FitLine(IReadOnlyList tileWidths, double separatorWidth, + double availableWidth, double requestedSize = MaxFontSize, double floorSize = FloorFontSize) + { + var count = tileWidths?.Count ?? 0; + if (count == 0 || availableWidth <= 0) + return new Fit(requestedSize, 1, false); + + var total = tileWidths!.Sum() + separatorWidth * (count - 1); + if (total <= availableWidth) + return new Fit(requestedSize, 1, false); + + var scaled = requestedSize * (availableWidth / total); + if (scaled >= floorSize) + return new Fit(scaled, 1, true); + + // Below the floor: keep the floor and wrap. Greedy packing, measured widths scaled to the + // floor size so the line breaks match what is actually drawn. + var factor = floorSize / requestedSize; + var lines = 1; + var lineWidth = 0.0; + foreach (var width in tileWidths) + { + var w = width * factor; + var needed = lineWidth == 0 ? w : lineWidth + separatorWidth + w; + if (needed > availableWidth && lineWidth > 0) + { + lines++; + lineWidth = w; + } + else + { + lineWidth = needed; + } + } + return new Fit(floorSize, lines, true); + } +} diff --git a/ytLive.Tests/ScoreboardLayoutTests.cs b/ytLive.Tests/ScoreboardLayoutTests.cs new file mode 100644 index 0000000..8e99e2a --- /dev/null +++ b/ytLive.Tests/ScoreboardLayoutTests.cs @@ -0,0 +1,50 @@ +using Xunit; +using ytLive.Models; + +namespace ytLive.Tests; + +/// Two creator rulings live here: the strip and the social bar own OPPOSITE edges (coupled, +/// resolved from what is visible), and a strip that does not fit shrinks to a legibility floor and +/// then wraps — it never scrolls. +public class ScoreboardLayoutTests +{ + [Fact] + public void Bars_never_share_an_edge_and_an_overlong_strip_shrinks_then_wraps() + { + // Both visible: opposite edges, whatever the scoreboard asked for. + Assert.Equal((BarEdge.Top, BarEdge.Bottom), + ScoreboardLayout.ResolveEdges(true, BarEdge.Top, true, BarEdge.Top)); + Assert.Equal((BarEdge.Bottom, BarEdge.Top), + ScoreboardLayout.ResolveEdges(true, BarEdge.Bottom, true, BarEdge.Bottom)); + + // Social bar hidden: the scoreboard keeps its own preference. + Assert.Equal((BarEdge.Top, BarEdge.Bottom), + ScoreboardLayout.ResolveEdges(false, BarEdge.Top, true, BarEdge.Bottom)); + + // Scoreboard hidden: the social bar is free. + Assert.Equal((BarEdge.Bottom, BarEdge.Top), + ScoreboardLayout.ResolveEdges(true, BarEdge.Bottom, false, BarEdge.Top)); + + // Fits at full size. + var fit = ScoreboardLayout.FitLine(new[] { 200.0, 200.0, 200.0 }, 40, 1920); + Assert.Equal(ScoreboardLayout.MaxFontSize, fit.FontSize); + Assert.Equal(1, fit.LineCount); + Assert.False(fit.Shrunk); + + // Too wide for full size, but a shrink keeps it on one line. + fit = ScoreboardLayout.FitLine(new[] { 400.0, 400.0, 400.0 }, 40, 900); + Assert.True(fit.FontSize < ScoreboardLayout.MaxFontSize); + Assert.True(fit.FontSize >= ScoreboardLayout.FloorFontSize); + Assert.Equal(1, fit.LineCount); + Assert.True(fit.Shrunk); + + // Far too wide even at the floor: two lines, never a scroll. + fit = ScoreboardLayout.FitLine(new[] { 600.0, 600.0, 600.0, 600.0 }, 40, 700); + Assert.Equal(ScoreboardLayout.FloorFontSize, fit.FontSize); + Assert.True(fit.LineCount > 1); + Assert.True(fit.Wrapped); + + // Nothing enabled: no crash, no lines invented. + Assert.Equal(1, ScoreboardLayout.FitLine(System.Array.Empty(), 40, 1920).LineCount); + } +}