feat(scoreboard): unit 3 — edge coupling and line fitting, both pure
ResolveEdges: the strip and the social bar are a coupled pair that own opposite edges whenever both are visible; the social bar keeps its choice and the scoreboard is pushed to the other edge. Hide either and the survivor keeps its own preference — so hiding the scoreboard frees the social bar, which is what 'resolved at render time' has to mean. FitLine: takes MEASURED tile widths (the renderer measures with FormattedText; a guessed character width would make the fit lie for a long handle), keeps full size when it fits, shrinks above the 14px legibility floor, and only then wraps — never scrolls, because a reader cannot wait for the fact to come round.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>Which edge a full-width bar occupies.</summary>
|
||||
public enum BarEdge
|
||||
{
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// <summary>The scoreboard's placement and line-fitting arithmetic, kept pure so both are testable
|
||||
/// without a renderer.
|
||||
///
|
||||
/// <para>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.</para></summary>
|
||||
public static class ScoreboardLayout
|
||||
{
|
||||
public const double FloorFontSize = 14;
|
||||
public const double MaxFontSize = 24;
|
||||
|
||||
/// <summary>Result of fitting the enabled tiles into one strip.</summary>
|
||||
public readonly record struct Fit(double FontSize, int LineCount, bool Shrunk)
|
||||
{
|
||||
public bool Wrapped => LineCount > 1;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Fits measured tile widths (at <paramref name="requestedSize"/>) 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.</summary>
|
||||
public static Fit FitLine(IReadOnlyList<double> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<double>(), 40, 1920).LineCount);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user