Nord's surface,
#3b4252, as a truecolour background.
+ private const string NordStripe = "\u001b[48;2;59;66;82m";
+
+ private static Table Rows(params string[] names) =>
+ new([new TableColumn(P("Name")), new TableColumn(P("Note"))], [.. names.Select(name => (ImmutableArray
)[P(name).ToBlock(), P("x").ToBlock()])]);
+
+ [Test]
+ public async Task AStripedTable_LaysEverySecondRowOnTheSurface()
+ {
+ var theme = ThemePalette.Preset("nord")!.ToLayoutTheme();
+ var lines = BlockLayout.Build((Rows("one", "two", "three", "four") with { Striped = true }).Themed(theme), 20)
+ .Render(MarkupFormat.Ansi, Registry).Split('\n');
+
+ // The headings and the rule, then a line a row.
+ await Assert.That(lines[2]).DoesNotContain("48;2;");
+ await Assert.That(lines[3]).Contains(NordStripe + "two ");
+ await Assert.That(lines[4]).DoesNotContain("48;2;");
+ await Assert.That(lines[5]).Contains(NordStripe + "four");
+ }
+
+ [Test]
+ public async Task AStripe_RunsTheWholeWidthOfEveryLineOfItsRow()
+ {
+ var theme = new LayoutTheme { StripeColor = new AnsiMarkup(new AnsiStyle { Background = new AnsiColor.Rgb(1, 2, 3) }) };
+ var table = new Table([new TableColumn(P("A")), new TableColumn(P("B"))],
+ [[P("a").ToBlock(), P("b").ToBlock()], [P("c").ToBlock(), P("long words wrap here").ToBlock()]])
+ { Striped = true };
+ var text = BlockLayout.Build(table.Themed(theme), 14);
+ var lines = text.Render(MarkupFormat.Ansi, Registry).Split('\n');
+ var plain = text.ToPlainText().Split('\n');
+
+ await Assert.That(lines.Length).IsGreaterThan(4);
+ foreach (var line in lines.Skip(3)) await Assert.That(line).StartsWith("\u001b[48;2;1;2;3m");
+ foreach (var line in plain.Skip(3)) await Assert.That(line.Length).IsEqualTo(14);
+ }
+
+ [Test]
+ public async Task StripedFields_StripeEachSecondField()
+ {
+ var theme = ThemePalette.Preset("nord")!.ToLayoutTheme();
+ var fields = new Fields([new Field(P("Name"), P("Ann").ToBlock()), new Field(P("Race"), P("Elf").ToBlock()), new Field(P("Rank"), P("3").ToBlock())]) { Striped = true };
+ var lines = BlockLayout.Build(fields.Themed(theme), 20).Render(MarkupFormat.Ansi, Registry).Split('\n');
+
+ await Assert.That(lines[0]).DoesNotContain("48;2;");
+ await Assert.That(lines[1]).Contains("48;2;59;66;82mRace");
+ await Assert.That(lines[1]).Contains(NordStripe + " Elf");
+ await Assert.That(lines[2]).DoesNotContain("48;2;");
+ }
+
+ [Test]
+ public async Task AStripe_LeavesTheTextAndACellsOwnBackgroundAlone()
+ {
+ var theme = ThemePalette.Preset("nord")!.ToLayoutTheme();
+ var own = MarkupText.Wrap(new AnsiMarkup(new AnsiStyle { Background = new AnsiColor.Rgb(200, 0, 0) }), "two");
+ var table = new Table([new TableColumn(P("Name"))], [[P("one").ToBlock()], [own.ToBlock()]]);
+ var plain = BlockLayout.Build(table, 20).ToPlainText();
+ var striped = BlockLayout.Build((table with { Striped = true }).Themed(theme), 20);
+
+ await Assert.That(striped.ToPlainText()).IsEqualTo(plain);
+ await Assert.That(striped.Render(MarkupFormat.Ansi, Registry)).Contains("48;2;200;0;0mtwo");
+ }
+
+ [Test]
+ public async Task Striped_WithNoStripeColour_ColoursNothing()
+ {
+ var table = Rows("one", "two");
+
+ await Assert.That(BlockLayout.Build(table with { Striped = true }, 20).Render(MarkupFormat.Ansi, Registry))
+ .IsEqualTo(BlockLayout.Build(table, 20).Render(MarkupFormat.Ansi, Registry));
+ }
+
+ [Test]
+ public async Task Stripes_SurviveTheSerializerAndRelayout()
+ {
+ var theme = ThemePalette.Preset("nord")!.ToLayoutTheme();
+ var built = BlockLayout.Build((Rows("one", "two") with { Striped = true }).ThemedUnder(theme), 20, fluid: true);
+ var read = MarkupTextSerializer.Deserialize(MarkupTextSerializer.Serialize(built, Registry), Registry);
+ var relaid = BlockLayout.Relayout(read, 30, LayoutContext.Default);
+
+ await Assert.That(relaid.Render(MarkupFormat.Ansi, Registry)).Contains(NordStripe + "two");
+ }
+
+ [Test]
+ public async Task Html_StripesWithAClassAndTheSurfaceAsAProperty()
+ {
+ var theme = ThemePalette.Preset("nord")!.ToLayoutTheme();
+ var html = BlockLayout.Build((Rows("one", "two") with { Striped = true }).Themed(theme), 20).Render(MarkupFormat.Html, Registry);
+ var fields = BlockLayout.Build(new Fields([new Field(P("A"), P("b").ToBlock())]) { Striped = true }, 20).Render(MarkupFormat.Html, Registry);
+
+ await Assert.That(html).Contains("--ms-stripe:#3b4252;");
+ await Assert.That(html).Contains("");
+ await Assert.That(fields).Contains("");
+ await Assert.That(LayoutCss.Fixed).Contains("var(--ms-stripe, var(--ms-stripe-default, rgba(127, 127, 127, 0.12)))");
+ }
+
+ [Test]
+ public async Task TheSurface_IsABackgroundNearTheBackground()
+ {
+ var generated = ThemePalette.Generate(Hex("#7aa2f7"));
+ var light = ThemePalette.Generate(Hex("#7aa2f7"), mode: ThemeMode.Light);
+
+ await Assert.That(ThemePalette.Preset("nord")![ThemeRole.Surface]!.Value.Rgb).IsEqualTo(Hex("#3b4252"));
+ await Assert.That(ColorMath.Contrast(generated[ThemeRole.Surface]!.Value.Resolved, generated.BackgroundColor)).IsBetween(1.1, 1.6);
+ await Assert.That(ColorMath.Contrast(light[ThemeRole.Surface]!.Value.Resolved, light.BackgroundColor)).IsBetween(1.1, 1.6);
+ await Assert.That(ColorMath.Contrast(generated[ThemeRole.Foreground]!.Value.Resolved, generated[ThemeRole.Surface]!.Value.Resolved)).IsGreaterThanOrEqualTo(4.5);
+ await Assert.That(ThemePalette.Terminal[ThemeRole.Surface]).IsNull();
+ await Assert.That(generated.ToLayoutTheme().StripeColor).IsEqualTo(new AnsiMarkup(new AnsiStyle { Background = new AnsiColor.Rgb(generated[ThemeRole.Surface]!.Value.Rgb!.Value.R, generated[ThemeRole.Surface]!.Value.Rgb!.Value.G, generated[ThemeRole.Surface]!.Value.Rgb!.Value.B) }));
+ }
+
+ [Test]
+ public async Task EachGenre_HasALookAndColoursThatStandOut()
+ {
+ await Assert.That(ThemePalette.Genres.Select(genre => genre.Name))
+ .IsEquivalentTo(["adult", "fantasy", "historical", "horror", "modern", "mystery", "romance", "science-fiction", "spiritual"]);
+ foreach (var genre in ThemePalette.Genres)
+ {
+ await Assert.That(genre.Look).IsNotNull();
+ await Assert.That(genre.Check()).IsEmpty();
+ await Assert.That(ThemePalette.Preset(genre.Name)).IsEqualTo(genre);
+ }
+ }
+
+ [Test]
+ public async Task AGenre_DrawsWithItsOwnShapes()
+ {
+ var theme = ThemePalette.Preset("fantasy")!.ToLayoutTheme();
+ var sheet = new Stack([new Bullets([P("Sword").ToBlock()]), new Gauge(3, 6) { BarWidth = 6, Show = GaugeShow.None }]).Bordered(P("Kit"));
+ var text = BlockLayout.Build(sheet.Themed(theme), 20).ToPlainText();
+ var ascii = BlockLayout.Build(sheet.Themed(theme), 20, context: new LayoutContext { AsciiOnly = true }).ToPlainText();
+
+ await Assert.That(text).StartsWith("╔════╡ ❖ Kit ❖ ╞═══╗");
+ await Assert.That(text).Contains("❧ Sword");
+ await Assert.That(text).Contains("╞███░░░╡");
+ await Assert.That(ascii).StartsWith("+======< Kit >=====+");
+ await Assert.That(ascii).Contains("* Sword");
+ await Assert.That(ascii).Contains("+###---+");
+ }
+
+ [Test]
+ public async Task ALook_IsReadAndWrittenAsJson()
+ {
+ ThemePalette.TryParse("""{"preset":"fantasy","look":{"bullet":"+","border":"rounded"}}""", out var changed, out _);
+ ThemePalette.TryParse("""{"preset":"fantasy","look":null}""", out var plain, out _);
+ ThemePalette.TryParse(ThemePalette.Preset("horror")!.ToJson(), out var read, out _);
+
+ await Assert.That(changed!.Look!.Bullet).IsEqualTo("+");
+ await Assert.That(changed.Look.Border).IsEqualTo("rounded");
+ await Assert.That(changed.Look.TitleOpen).IsEqualTo("╡ ❖ ");
+ await Assert.That(plain!.Look).IsNull();
+ await Assert.That(read).IsEqualTo(ThemePalette.Preset("horror"));
+ }
+
+ [Test]
+ [Arguments("""{"look":{"border":"wavy"}}""", "border is one of none, ascii, mush, single, double, heavy, rounded")]
+ [Arguments("""{"look":{"gauge":["[","#"]}}""", "gauge is [open, filled, empty, close]")]
+ [Arguments("""{"look":{"sparkle":"*"}}""", "a look has no 'sparkle'")]
+ public async Task ABadLook_SaysWhy(string json, string error)
+ {
+ await Assert.That(ThemePalette.TryParse(json, out _, out var message)).IsFalse();
+ await Assert.That(message).IsEqualTo(error);
+ }
+
+ [Test]
+ public async Task AGeneratedTheme_IsMadeAgainForALightBackground()
+ {
+ var fantasy = ThemePalette.Preset("fantasy")!;
+ var light = fantasy.InMode(ThemeMode.Light);
+ ThemePalette.TryParse("""{"preset":"fantasy","mode":"light"}""", out var read, out _);
+
+ await Assert.That(light.Mode).IsEqualTo(ThemeMode.Light);
+ await Assert.That(light.Look).IsEqualTo(fantasy.Look);
+ await Assert.That(light.Name).IsEqualTo("fantasy");
+ await Assert.That(light.Check()).IsEmpty();
+ await Assert.That(ColorMath.Luminance(light.BackgroundColor)).IsGreaterThan(0.8);
+ await Assert.That(read).IsEqualTo(light);
+ await Assert.That(ThemePalette.Preset("nord")!.InMode(ThemeMode.Light)[ThemeRole.Primary]).IsEqualTo(ThemePalette.Preset("nord")![ThemeRole.Primary]);
+ }
+
+ [Test]
+ public async Task AGeneratedTheme_KeepsItsSeedThroughJson()
+ {
+ var palette = ThemePalette.Generate(Hex("#d08770"), ThemeHarmony.Split, ThemeMode.Dark, 0.5);
+ ThemePalette.TryParse(palette.ToJson(), out var read, out _);
+
+ await Assert.That(read).IsEqualTo(palette);
+ await Assert.That(read!.InMode(ThemeMode.Light)).IsEqualTo(ThemePalette.Generate(Hex("#d08770"), ThemeHarmony.Split, ThemeMode.Light, 0.5));
+ }
+}
diff --git a/MarkupString/ColorGradient.cs b/MarkupString/ColorGradient.cs
index 703c28a..d895ba4 100644
--- a/MarkupString/ColorGradient.cs
+++ b/MarkupString/ColorGradient.cs
@@ -19,6 +19,9 @@ public interface IColorMarkup : IMarkup
/// The foreground this layer sets, or when it sets none or only the terminal knows it.
RgbColor? Foreground { get; }
+ /// The background this layer sets, or when it sets none or only the terminal knows it.
+ RgbColor? Background => null;
+
/// The same layer with its foreground replaced.
IColorMarkup WithForeground(RgbColor color);
}
@@ -300,14 +303,14 @@ private static double Hue(double from, double to, double t)
/// Below this chroma a colour is grey, and its hue means nothing.
private const double Achromatic = 0.0004;
- private static (double L, double C, double H) Polar((double L, double A, double B) lab)
+ internal static (double L, double C, double H) Polar((double L, double A, double B) lab)
{
var chroma = Math.Sqrt(lab.A * lab.A + lab.B * lab.B);
var hue = chroma < Achromatic ? double.NaN : Math.Atan2(lab.B, lab.A) * 180 / Math.PI;
return (lab.L, chroma, hue < 0 ? hue + 360 : hue);
}
- private static double ToLinear(byte channel)
+ internal static double ToLinear(byte channel)
{
var c = channel / 255.0;
return c <= 0.04045 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4);
@@ -316,7 +319,7 @@ private static double ToLinear(byte channel)
private static double FromLinear(double c) =>
c <= 0.0031308 ? 12.92 * c : 1.055 * Math.Pow(c, 1 / 2.4) - 0.055;
- private static (double L, double A, double B) ToOklab(RgbColor color)
+ internal static (double L, double A, double B) ToOklab(RgbColor color)
{
double r = ToLinear(color.R), g = ToLinear(color.G), b = ToLinear(color.B);
var l = Math.Cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
@@ -343,7 +346,7 @@ private static (double R, double G, double B) LinearRgb(double lightness, double
/// An Oklab colour in sRGB. One outside sRGB loses chroma, keeping its lightness and hue, until it
/// fits, the way CSS maps a colour into gamut, rather than having its channels clipped.
///
- private static RgbColor FromOklab(double lightness, double a, double b)
+ internal static RgbColor FromOklab(double lightness, double a, double b)
{
lightness = Math.Clamp(lightness, 0, 1);
static bool Fits((double R, double G, double B) c) =>
diff --git a/MarkupString/ColorMath.cs b/MarkupString/ColorMath.cs
new file mode 100644
index 0000000..836d291
--- /dev/null
+++ b/MarkupString/ColorMath.cs
@@ -0,0 +1,148 @@
+using System.Globalization;
+
+namespace MarkupString;
+
+/// A colour as Oklab's lightness, chroma and hue: how light it looks, how vivid, and which way round the wheel.
+/// Lightness, 0 (black) to 1 (white).
+/// Chroma, 0 (grey) up to about 0.37 for the most vivid sRGB colours.
+/// Hue in degrees, 0 to 360; for a grey, which has none.
+public readonly record struct OklchColor(double L, double C, double H);
+
+///
+/// What a theme is made with: Oklab's lightness, chroma and hue, WCAG contrast, and the standard
+/// terminal colour nearest in kind.
+///
+public static class ColorMath
+{
+ /// in OKLCH.
+ public static OklchColor ToOklch(RgbColor color)
+ {
+ var (l, c, h) = ColorGradient.Polar(ColorGradient.ToOklab(color));
+ return new OklchColor(l, c, h);
+ }
+
+ ///
+ /// in sRGB. One outside sRGB loses chroma, keeping its lightness and hue, until
+ /// it fits. A hue of is grey.
+ ///
+ public static RgbColor FromOklch(OklchColor color)
+ {
+ if (double.IsNaN(color.H) || color.C <= 0) return ColorGradient.FromOklab(color.L, 0, 0);
+ var hue = color.H * Math.PI / 180;
+ return ColorGradient.FromOklab(color.L, color.C * Math.Cos(hue), color.C * Math.Sin(hue));
+ }
+
+ /// WCAG 2's relative luminance: 0 for black, 1 for white.
+ public static double Luminance(RgbColor color) =>
+ 0.2126 * ColorGradient.ToLinear(color.R) + 0.7152 * ColorGradient.ToLinear(color.G) + 0.0722 * ColorGradient.ToLinear(color.B);
+
+ ///
+ /// WCAG 2's contrast ratio between two colours, 1 (the same) to 21 (black on white). Text wants 4.5,
+ /// lines and other parts that are not text 3 (WCAG 1.4.3 and 1.4.11).
+ ///
+ public static double Contrast(RgbColor first, RgbColor second)
+ {
+ var a = Luminance(first);
+ var b = Luminance(second);
+ return (Math.Max(a, b) + 0.05) / (Math.Min(a, b) + 0.05);
+ }
+
+ ///
+ /// made lighter or darker, keeping its hue, until its contrast with
+ /// reaches : lighter on a dark background, darker
+ /// on a light one, and no further than it must. As far as it goes when the ratio cannot be reached.
+ ///
+ public static RgbColor WithContrast(RgbColor color, RgbColor background, double ratio)
+ {
+ if (Contrast(color, background) >= ratio) return color;
+ var oklch = ToOklch(color);
+ var lighter = Luminance(background) < 0.18;
+ var (low, high) = lighter ? (oklch.L, 1.0) : (0.0, oklch.L);
+ RgbColor At(double lightness) => FromOklch(oklch with { L = lightness });
+ if (Contrast(At(lighter ? high : low), background) < ratio) return At(lighter ? high : low);
+ for (var i = 0; i < 30; i++)
+ {
+ var mid = (low + high) / 2;
+ var enough = Contrast(At(mid), background) >= ratio;
+ if (enough == lighter) high = mid;
+ else low = mid;
+ }
+ return At(lighter ? high : low);
+ }
+
+ /// turned round the OKLCH hue wheel; a grey is unchanged.
+ public static RgbColor Rotate(RgbColor color, double degrees)
+ {
+ var oklch = ToOklch(color);
+ return double.IsNaN(oklch.H) ? color : FromOklch(oklch with { H = Wrap(oklch.H + degrees) });
+ }
+
+ /// A hue in 0 to 360.
+ internal static double Wrap(double hue)
+ {
+ hue %= 360;
+ return hue < 0 ? hue + 360 : hue;
+ }
+
+ /// The signed shortest turn from hue to hue , -180 to 180.
+ internal static double Turn(double from, double to)
+ {
+ var delta = Wrap(to - from);
+ return delta > 180 ? delta - 360 : delta;
+ }
+
+ /// Reads #rgb or #rrggbb, the # optional.
+ public static bool TryParseHex(string? text, out RgbColor color)
+ {
+ color = default;
+ if (string.IsNullOrWhiteSpace(text)) return false;
+ var hex = text.Trim().TrimStart('#');
+ if (hex.Length == 3) hex = string.Concat(hex.Select(c => new string(c, 2)));
+ if (hex.Length != 6 || !uint.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value)) return false;
+ color = new RgbColor((byte)(value >> 16), (byte)(value >> 8), (byte)value);
+ return true;
+ }
+
+ ///
+ /// The standard terminal colour (0-7 normal, 8-15 bright) of the same kind as :
+ /// for a grey or a faint tint, black, dark grey, light grey or white by lightness; otherwise the nearest of red,
+ /// yellow, green, cyan, blue and magenta by hue, bright when the colour is light. A sixteen-colour
+ /// client shows a pastel blue as blue this way, where the nearest colour by RGB is often grey.
+ ///
+ public static int StandardSlot(RgbColor color)
+ {
+ var (l, c, h) = ToOklch(color);
+ if (c < 0.065 || double.IsNaN(h))
+ return l switch { < 0.3 => 0, < 0.6 => 8, < 0.85 => 7, _ => 15 };
+ var slot = 0;
+ var nearest = double.MaxValue;
+ foreach (var (anchor, index) in HueAnchors)
+ {
+ var distance = Math.Abs(Turn(h, anchor));
+ if (distance < nearest) (nearest, slot) = (distance, index);
+ }
+ return l > 0.7 ? slot + 8 : slot;
+ }
+
+ ///
+ /// Where each standard colour sits on the OKLCH wheel. Yellow is pulled toward orange, as the
+ /// VGA "yellow" a MU* client shows is brown.
+ ///
+ private static readonly (double Hue, int Slot)[] HueAnchors = [(25, 1), (80, 3), (145, 2), (195, 6), (260, 4), (330, 5)];
+
+ /// The usual VGA value of standard colour (0-15), as most MU* clients draw it.
+ public static RgbColor StandardColor(int slot)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(slot);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(slot, 15);
+ return Vga[slot];
+ }
+
+ private static readonly RgbColor[] Vga =
+ [
+ new(0, 0, 0), new(170, 0, 0), new(0, 170, 0), new(170, 85, 0),
+ new(0, 0, 170), new(170, 0, 170), new(0, 170, 170), new(170, 170, 170),
+ new(85, 85, 85), new(255, 85, 85), new(85, 255, 85), new(255, 255, 85),
+ new(85, 85, 255), new(255, 85, 255), new(85, 255, 255), new(255, 255, 255),
+ ];
+}
diff --git a/MarkupString/Layout/Blocks/Block.cs b/MarkupString/Layout/Blocks/Block.cs
index 4f6672c..6a7ce1b 100644
--- a/MarkupString/Layout/Blocks/Block.cs
+++ b/MarkupString/Layout/Blocks/Block.cs
@@ -123,14 +123,73 @@ public TreeGuide Guide(TreeGuide? guide)
guide ??= Theme.Guide ?? TreeGuide.Line;
return AsciiOnly ? guide.ToAscii() : guide;
}
+
+ ///
+ /// in the theme's colour for , or as it is when the theme
+ /// sets none. Colour the text sets itself wins.
+ ///
+ /// The part's colour: theme => theme.BorderColor.
+ /// The piece.
+ public MarkupText Paint(Func part, MarkupText text)
+ {
+ ArgumentNullException.ThrowIfNull(part);
+ ArgumentNullException.ThrowIfNull(text);
+ return part(Theme) is { } markup && text.Length > 0 ? MarkupText.Wrap(markup, text) : text;
+ }
+
+ ///
+ /// The lines from on, a row of a striped table or list, laid on the theme's
+ /// stripe colour when is odd (the second, the fourth, ...). Colour a cell sets
+ /// itself wins.
+ ///
+ internal void Stripe(IList lines, int first, int row)
+ {
+ if (row % 2 == 0 || Theme.StripeColor is not { } markup) return;
+ for (var i = first; i < lines.Count; i++)
+ if (lines[i].Length > 0) lines[i] = MarkupText.Wrap(markup, lines[i]);
+ }
+
+ /// with every piece in the theme's border colour.
+ internal BorderStyle Paint(BorderStyle style)
+ {
+ if (Theme.BorderColor is not { } markup) return style;
+ MarkupText Piece(MarkupText piece) => piece.Length > 0 ? MarkupText.Wrap(markup, piece) : piece;
+ return style with
+ {
+ TopLeft = Piece(style.TopLeft),
+ Top = Piece(style.Top),
+ TopRight = Piece(style.TopRight),
+ Left = Piece(style.Left),
+ Right = Piece(style.Right),
+ BottomLeft = Piece(style.BottomLeft),
+ Bottom = Piece(style.Bottom),
+ BottomRight = Piece(style.BottomRight),
+ TeeLeft = Piece(style.TeeLeft),
+ TeeRight = Piece(style.TeeRight),
+ TitleOpen = Piece(style.TitleOpen),
+ TitleClose = Piece(style.TitleClose),
+ };
+ }
+
+ /// with every piece in the theme's guide colour.
+ internal TreeGuide Paint(TreeGuide guide)
+ {
+ if (Theme.GuideColor is not { } markup) return guide;
+ MarkupText Piece(MarkupText piece) => piece.Length > 0 ? MarkupText.Wrap(markup, piece) : piece;
+ return guide with { Branch = Piece(guide.Branch), Last = Piece(guide.Last), Pipe = Piece(guide.Pipe), Blank = Piece(guide.Blank) };
+ }
}
///
-/// The look of a layout: its borders, tree guides, gauge pieces, bullet and field separator. Each
-/// is until set, and an unset one comes from the theme around it, and in the end
-/// from .
+/// The look of a layout: its borders, tree guides, gauge pieces, bullet and field separator, and the
+/// colour of each part. Each is until set, and an unset one comes from the theme
+/// around it, and in the end from , which colours nothing.
///
-/// Apply one to part of a tree with : sheet.Themed(new() { Border = BorderStyle.Heavy }).
+///
+/// Apply one to part of a tree with : sheet.Themed(new() { Border = BorderStyle.Heavy }).
+/// The colours are markup layers, so a part can be bold as well as coloured. A palette makes a whole
+/// set of them at once (). Colour a piece sets itself wins over its part's.
+///
public sealed record LayoutTheme
{
/// A theme that sets nothing, so everything comes from .
@@ -180,6 +239,48 @@ public sealed record LayoutTheme
/// The line under a table's headings, repeated; empty for none.
public MarkupText? HeaderRule { get; init; }
+ /// The colour of boxes, rules and the brackets round a gauge.
+ public IMarkup? BorderColor { get; init; }
+
+ /// The colour of a title set into a box's edge or a rule.
+ public IMarkup? TitleColor { get; init; }
+
+ /// The colour of a table's headings.
+ public IMarkup? HeadingColor { get; init; }
+
+ /// The colour of a field's label.
+ public IMarkup? LabelColor { get; init; }
+
+ /// The colour of the separator after a label, and between table columns.
+ public IMarkup? SeparatorColor { get; init; }
+
+ /// The colour of a list's markers.
+ public IMarkup? BulletColor { get; init; }
+
+ /// The colour of tree guide lines.
+ public IMarkup? GuideColor { get; init; }
+
+ /// The colour of the line under a table's headings.
+ public IMarkup? HeaderRuleColor { get; init; }
+
+ /// The colour of a gauge's filled part, when it has no gradient.
+ public IMarkup? GaugeFilledColor { get; init; }
+
+ /// The colour of a gauge's empty part.
+ public IMarkup? GaugeEmptyColor { get; init; }
+
+ ///
+ /// What is laid under every second row of a table or list that is striped
+ /// (, ): a background colour.
+ ///
+ public IMarkup? StripeColor { get; init; }
+
+ /// Whether it sets any colour.
+ public bool HasColor =>
+ BorderColor is not null || TitleColor is not null || HeadingColor is not null || LabelColor is not null
+ || SeparatorColor is not null || BulletColor is not null || GuideColor is not null || HeaderRuleColor is not null
+ || GaugeFilledColor is not null || GaugeEmptyColor is not null || StripeColor is not null;
+
/// This theme over : what this sets, and what it leaves unset from there.
public LayoutTheme Over(LayoutTheme below)
{
@@ -195,6 +296,17 @@ public LayoutTheme Over(LayoutTheme below)
Bullet = Bullet ?? below.Bullet,
FieldSeparator = FieldSeparator ?? below.FieldSeparator,
HeaderRule = HeaderRule ?? below.HeaderRule,
+ BorderColor = BorderColor ?? below.BorderColor,
+ TitleColor = TitleColor ?? below.TitleColor,
+ HeadingColor = HeadingColor ?? below.HeadingColor,
+ LabelColor = LabelColor ?? below.LabelColor,
+ SeparatorColor = SeparatorColor ?? below.SeparatorColor,
+ BulletColor = BulletColor ?? below.BulletColor,
+ GuideColor = GuideColor ?? below.GuideColor,
+ HeaderRuleColor = HeaderRuleColor ?? below.HeaderRuleColor,
+ GaugeFilledColor = GaugeFilledColor ?? below.GaugeFilledColor,
+ GaugeEmptyColor = GaugeEmptyColor ?? below.GaugeEmptyColor,
+ StripeColor = StripeColor ?? below.StripeColor,
};
}
diff --git a/MarkupString/Layout/Blocks/BlockCodec.cs b/MarkupString/Layout/Blocks/BlockCodec.cs
index a446b19..1892600 100644
--- a/MarkupString/Layout/Blocks/BlockCodec.cs
+++ b/MarkupString/Layout/Blocks/BlockCodec.cs
@@ -229,6 +229,17 @@ public void Theme(string name, LayoutTheme theme)
Text("bu", theme.Bullet);
Text("fs", theme.FieldSeparator);
Text("hr", theme.HeaderRule);
+ if (theme.BorderColor is { } cb) Markup("cb", cb);
+ if (theme.TitleColor is { } ct) Markup("ct", ct);
+ if (theme.HeadingColor is { } ch) Markup("ch", ch);
+ if (theme.LabelColor is { } cl) Markup("cl", cl);
+ if (theme.SeparatorColor is { } cs) Markup("cs", cs);
+ if (theme.BulletColor is { } cu) Markup("cu", cu);
+ if (theme.GuideColor is { } cg) Markup("cg", cg);
+ if (theme.HeaderRuleColor is { } cr) Markup("cr", cr);
+ if (theme.GaugeFilledColor is { } cf) Markup("cf", cf);
+ if (theme.GaugeEmptyColor is { } ce) Markup("ce", ce);
+ if (theme.StripeColor is { } cz) Markup("cz", cz);
_json.WriteEndObject();
}
@@ -420,6 +431,17 @@ public LayoutTheme Theme(string name)
Bullet = inner.Text("bu"),
FieldSeparator = inner.Text("fs"),
HeaderRule = inner.Text("hr"),
+ BorderColor = inner.Markup("cb"),
+ TitleColor = inner.Markup("ct"),
+ HeadingColor = inner.Markup("ch"),
+ LabelColor = inner.Markup("cl"),
+ SeparatorColor = inner.Markup("cs"),
+ BulletColor = inner.Markup("cu"),
+ GuideColor = inner.Markup("cg"),
+ HeaderRuleColor = inner.Markup("cr"),
+ GaugeFilledColor = inner.Markup("cf"),
+ GaugeEmptyColor = inner.Markup("ce"),
+ StripeColor = inner.Markup("cz"),
};
}
diff --git a/MarkupString/Layout/Blocks/Bullets.cs b/MarkupString/Layout/Blocks/Bullets.cs
index 5da4243..a72fb03 100644
--- a/MarkupString/Layout/Blocks/Bullets.cs
+++ b/MarkupString/Layout/Blocks/Bullets.cs
@@ -25,7 +25,7 @@ public sealed record Bullets(ImmutableArray Items) : Block
public override void Draw(LayoutContext context, int width, IList lines)
{
if (Items.IsDefaultOrEmpty) return;
- var markers = Enumerable.Range(0, Items.Length).Select(i => MarkerAt(i, context)).ToArray();
+ var markers = Enumerable.Range(0, Items.Length).Select(i => context.Paint(theme => theme.BulletColor, MarkerAt(i, context))).ToArray();
var markerWidth = markers.Max(marker => marker.DisplayWidth);
var gutter = markerWidth + (markerWidth > 0 ? 1 : 2);
var hang = BlockText.Blank(gutter);
diff --git a/MarkupString/Layout/Blocks/Fields.cs b/MarkupString/Layout/Blocks/Fields.cs
index 9d707e8..d1a1329 100644
--- a/MarkupString/Layout/Blocks/Fields.cs
+++ b/MarkupString/Layout/Blocks/Fields.cs
@@ -32,6 +32,12 @@ public sealed record Fields(ImmutableArray Items) : Block
/// Cells between two columns.
public int Gap { get; init; } = 3;
+ ///
+ /// Whether every second field is laid on the theme's , across the
+ /// label and the value. Dealt into columns, each column is striped on its own.
+ ///
+ public bool Striped { get; init; }
+
/// The separator drawn under .
internal MarkupText SeparatorIn(LayoutContext context) => Separator ?? context.Theme.Piece(theme => theme.FieldSeparator);
@@ -60,18 +66,21 @@ public override void Draw(LayoutContext context, int width, IList li
return;
}
- var separator = SeparatorIn(context);
+ var separator = context.Paint(theme => theme.SeparatorColor, SeparatorIn(context));
var labelWidth = Math.Min(Items.Max(field => field.Label.DisplayWidth), Math.Max(1, width / 2));
var valueWidth = width - labelWidth - separator.DisplayWidth;
if (valueWidth < Math.Min(MinValue, width))
{
// Too narrow to sit side by side: each label on its own line, its value indented under it.
var indent = BlockText.Blank(Math.Min(2, width - 1));
- foreach (var field in Items)
+ for (var index = 0; index < Items.Length; index++)
{
+ var field = Items[index];
+ var first = lines.Count;
if (field.Label.Length > 0)
- lines.AddRange(MarkupText.Concat([field.Label, separator.Trim(TrimType.TrimEnd)]).FormatColumn(BlockText.Column(width, Alignment.Left)));
- foreach (var line in context.Lines(field.Value, width - indent.DisplayWidth)) lines.Add(MarkupText.Concat([indent, line]));
+ lines.AddRange(MarkupText.Concat([context.Paint(theme => theme.LabelColor, field.Label), separator.Trim(TrimType.TrimEnd)]).FormatColumn(BlockText.Column(width, Alignment.Left)));
+ foreach (var line in context.Lines(field.Value, width - indent.DisplayWidth)) lines.Add(Striped ? BlockText.Fit(MarkupText.Concat([indent, line]), width) : MarkupText.Concat([indent, line]));
+ if (Striped) context.Stripe(lines, first, index);
}
return;
}
@@ -83,18 +92,20 @@ public override void Draw(LayoutContext context, int width, IList li
var leader = Leader is { Length: > 0 } pattern ? pattern : null;
var labelColumn = labelWidth + separator.DisplayWidth;
var blankLabel = BlockText.Blank(labelColumn);
- foreach (var field in Items)
+ for (var index = 0; index < Items.Length; index++)
{
+ var field = Items[index];
+ var first = lines.Count;
var label = new List();
if (field.Label.Length > 0 && leader is not null)
{
- var rows = field.Label.FormatColumn(BlockText.Column(labelWidth, alignment) with { Fill = leader });
+ var rows = context.Paint(theme => theme.LabelColor, field.Label).FormatColumn(BlockText.Column(labelWidth, alignment) with { Fill = context.Paint(theme => theme.SeparatorColor, leader) });
for (var i = 0; i < rows.Length; i++)
label.Add(MarkupText.Concat([BlockText.Fit(rows[i], labelWidth), i == 0 ? separator : BlockText.Blank(separator.DisplayWidth)]));
}
else if (field.Label.Length > 0)
{
- var rows = MarkupText.Concat([field.Label, head]).FormatColumn(BlockText.Column(labelWidth + head.DisplayWidth, alignment));
+ var rows = MarkupText.Concat([context.Paint(theme => theme.LabelColor, field.Label), head]).FormatColumn(BlockText.Column(labelWidth + head.DisplayWidth, alignment));
foreach (var row in rows) label.Add(BlockText.Fit(row, labelColumn));
}
@@ -106,6 +117,7 @@ public override void Draw(LayoutContext context, int width, IList li
row < label.Count ? label[row] : blankLabel,
row < drawn.Count ? BlockText.Fit(drawn[row], valueWidth) : BlockText.Blank(valueWidth)]));
}
+ if (Striped) context.Stripe(lines, first, index);
}
}
diff --git a/MarkupString/Layout/Blocks/Frame.cs b/MarkupString/Layout/Blocks/Frame.cs
index 033417a..59355a8 100644
--- a/MarkupString/Layout/Blocks/Frame.cs
+++ b/MarkupString/Layout/Blocks/Frame.cs
@@ -13,8 +13,9 @@ public sealed record Rule(MarkupText? Title = null) : Block
///
public override void Draw(LayoutContext context, int width, IList lines)
{
- var style = context.Border(Border);
- lines.Add(BlockText.Edge(MarkupText.Empty, style.Top, MarkupText.Empty, Title, TitleAlignment, style, width));
+ var style = context.Paint(context.Border(Border));
+ var title = Title is null ? null : context.Paint(theme => theme.TitleColor, Title);
+ lines.Add(BlockText.Edge(MarkupText.Empty, style.Top, MarkupText.Empty, title, TitleAlignment, style, width));
}
///
@@ -46,13 +47,13 @@ public sealed record Frame(Block Body) : Block
///
public override void Draw(LayoutContext context, int width, IList lines)
{
- var style = context.Border(Border);
+ var style = context.Paint(context.Border(Border));
var none = style.Name == BorderStyle.None.Name;
var padding = BlockText.Blank(Padding);
var inner = Math.Max(1, width - style.Left.DisplayWidth - style.Right.DisplayWidth - padding.DisplayWidth * 2);
if (!none || Title is { Length: > 0 })
- lines.Add(BlockText.Edge(style.TopLeft, style.Top, style.TopRight, Title, TitleAlignment, style, width));
+ lines.Add(BlockText.Edge(style.TopLeft, style.Top, style.TopRight, Title is null ? null : context.Paint(theme => theme.TitleColor, Title), TitleAlignment, style, width));
var body = new List();
foreach (var child in Parts)
@@ -60,8 +61,9 @@ public override void Draw(LayoutContext context, int width, IList li
if (child is Rule rule)
{
if (none && rule.Title is not { Length: > 0 }) continue;
- var ruleStyle = context.Border(rule.Border ?? Border);
- lines.Add(BlockText.Edge(style.TeeLeft, ruleStyle.Top, style.TeeRight, rule.Title, rule.TitleAlignment, ruleStyle, width));
+ var ruleStyle = context.Paint(context.Border(rule.Border ?? Border));
+ var ruleTitle = rule.Title is null ? null : context.Paint(theme => theme.TitleColor, rule.Title);
+ lines.Add(BlockText.Edge(style.TeeLeft, ruleStyle.Top, style.TeeRight, ruleTitle, rule.TitleAlignment, ruleStyle, width));
continue;
}
diff --git a/MarkupString/Layout/Blocks/Gauge.cs b/MarkupString/Layout/Blocks/Gauge.cs
index 6b7a323..ab0569c 100644
--- a/MarkupString/Layout/Blocks/Gauge.cs
+++ b/MarkupString/Layout/Blocks/Gauge.cs
@@ -49,8 +49,8 @@ public sealed record Gauge(double Value, double Maximum) : Block
public override void Draw(LayoutContext context, int width, IList lines)
{
var theme = context.Theme;
- var open = context.Glyph(Open ?? theme.Piece(t => t.GaugeOpen), "[");
- var close = context.Glyph(Close ?? theme.Piece(t => t.GaugeClose), "]");
+ var open = context.Paint(t => t.BorderColor, context.Glyph(Open ?? theme.Piece(t => t.GaugeOpen), "["));
+ var close = context.Paint(t => t.BorderColor, context.Glyph(Close ?? theme.Piece(t => t.GaugeClose), "]"));
var filled = context.Glyph(Filled ?? theme.Piece(t => t.GaugeFilled), "#");
var empty = context.Glyph(Empty ?? theme.Piece(t => t.GaugeEmpty), "-");
var label = Label is { Length: > 0 } text ? MarkupText.Concat([text, MarkupText.Space]) : MarkupText.Empty;
@@ -63,7 +63,9 @@ public override void Draw(LayoutContext context, int width, IList li
var fill = BlockText.Run(filled, full);
if (Gradient is { IsEmpty: false } gradient)
fill = Shade == GaugeShade.Value ? gradient.Paint(fill, Ratio) : gradient.ShadeLines([fill], GradientFlow.Across, bar)[0];
- lines.Add(BlockText.Fit(MarkupText.Concat([label, open, fill, BlockText.Run(empty, bar - full), close, after]), width));
+ else fill = context.Paint(t => t.GaugeFilledColor, fill);
+ var rest = context.Paint(t => t.GaugeEmptyColor, BlockText.Run(empty, bar - full));
+ lines.Add(BlockText.Fit(MarkupText.Concat([label, open, fill, rest, close, after]), width));
}
///
diff --git a/MarkupString/Layout/Blocks/LayoutJson.cs b/MarkupString/Layout/Blocks/LayoutJson.cs
index a458874..68b338e 100644
--- a/MarkupString/Layout/Blocks/LayoutJson.cs
+++ b/MarkupString/Layout/Blocks/LayoutJson.cs
@@ -141,6 +141,7 @@ public static IMarkup Read(JsonElement element, MarkupRegistry? registry)
w.Text("ld", b.Leader);
w.Int("c", b.Columns, 1);
w.Int("g", b.Gap, 3);
+ w.Bool("sp", b.Striped);
},
r => new Fields(r.Array("f", fr => new Field(fr.Text("k") ?? MarkupText.Empty, fr.Block("v") ?? new Stack([]))))
{
@@ -149,6 +150,7 @@ public static IMarkup Read(JsonElement element, MarkupRegistry? registry)
Leader = r.Text("ld"),
Columns = r.Int("c", 1, 1, 64),
Gap = r.Int("g", 3, 0, 64),
+ Striped = r.Bool("sp"),
}),
BlockCodec.Create("tree",
(b, w) =>
@@ -217,6 +219,7 @@ public static IMarkup Read(JsonElement element, MarkupRegistry? registry)
w.Int("g", b.Gap, 2);
w.Text("s", b.Separator);
w.Text("hr", b.HeaderRule);
+ w.Bool("sp", b.Striped);
},
r => new Table(
r.Array("cols", cr => new TableColumn(cr.Text("h") ?? MarkupText.Empty)
@@ -232,6 +235,7 @@ public static IMarkup Read(JsonElement element, MarkupRegistry? registry)
Gap = r.Int("g", 2, 0, 64),
Separator = r.Text("s"),
HeaderRule = r.Text("hr"),
+ Striped = r.Bool("sp"),
}),
BlockCodec.Create("aligned",
(b, w) =>
@@ -260,8 +264,9 @@ public static IMarkup Read(JsonElement element, MarkupRegistry? registry)
{
w.Block("c", b.Content);
w.Theme("th", b.Theme);
+ w.Bool("fb", b.Fallback);
},
- r => new Themed(r.Block("c") ?? new Stack([]), r.Theme("th"))),
+ r => new Themed(r.Block("c") ?? new Stack([]), r.Theme("th")) { Fallback = r.Bool("fb") }),
];
private static readonly FrozenDictionary ByKind = BuiltIns.ToFrozenDictionary(codec => codec.Kind, StringComparer.Ordinal);
diff --git a/MarkupString/Layout/Blocks/Modifiers.cs b/MarkupString/Layout/Blocks/Modifiers.cs
index e6bf4c3..bc3385c 100644
--- a/MarkupString/Layout/Blocks/Modifiers.cs
+++ b/MarkupString/Layout/Blocks/Modifiers.cs
@@ -45,9 +45,22 @@ public override void Draw(LayoutContext context, int width, IList li
/// What it sets; what it leaves unset comes from around it.
public sealed record Themed(Block Content, LayoutTheme Theme) : Block
{
+ ///
+ /// Whether only fills in what the theme around it leaves unset, rather than
+ /// overriding it: a game's default look, under which the theme a reader draws it in still shows.
+ ///
+ public bool Fallback { get; init; }
+
+ /// The theme the content is drawn in, given the one it.
+ public LayoutTheme Within(LayoutTheme around)
+ {
+ ArgumentNullException.ThrowIfNull(around);
+ return Fallback ? around.Over(Theme) : Theme.Over(around);
+ }
+
///
public override void Draw(LayoutContext context, int width, IList lines) =>
- (context with { Theme = Theme.Over(context.Theme) }).Draw(Content, width, lines);
+ (context with { Theme = Within(context.Theme) }).Draw(Content, width, lines);
}
/// Builds blocks up: a frame round one, a size in a row, a colour, a look.
@@ -81,6 +94,9 @@ public static Shaded Shaded(this Block content, ColorGradient gradient, Gradient
/// in , over the theme around it.
public static Themed Themed(this Block content, LayoutTheme theme) => new(content, theme);
+ /// in wherever the theme around it sets nothing ().
+ public static Themed ThemedUnder(this Block content, LayoutTheme theme) => new(content, theme) { Fallback = true };
+
/// as a block, to build on.
public static TextBlock ToBlock(this MarkupText text) => new(text);
diff --git a/MarkupString/Layout/Blocks/Table.cs b/MarkupString/Layout/Blocks/Table.cs
index b569bc7..40d519b 100644
--- a/MarkupString/Layout/Blocks/Table.cs
+++ b/MarkupString/Layout/Blocks/Table.cs
@@ -20,6 +20,12 @@ public sealed record Table(ImmutableArray Columns, ImmutableArrayThe line under the headings, repeated; empty for none; unset, the theme's (-).
public MarkupText? HeaderRule { get; init; }
+ ///
+ /// Whether every second row is laid on the theme's , to help the
+ /// eye along a wide row. Not when the rows are drawn as cards.
+ ///
+ public bool Striped { get; init; }
+
private ImmutableArray> AllRows => Rows.IsDefault ? [] : Rows;
private static readonly Block Blank = new TextBlock(MarkupText.Empty);
@@ -32,7 +38,7 @@ public override void Draw(LayoutContext context, int width, IList li
{
if (Columns.IsDefaultOrEmpty) return;
// Measured as drawn: an ASCII reader's separator may be the wider " | " stand-in.
- var divider = Separator is { } drawn ? context.Glyph(drawn, " | ") : BlockText.Blank(Gap);
+ var divider = Separator is { } drawn ? context.Paint(theme => theme.SeparatorColor, context.Glyph(drawn, " | ")) : BlockText.Blank(Gap);
if (Widths(context, width, divider.DisplayWidth) is not { } widths)
{
DrawCards(context, width, lines);
@@ -44,13 +50,15 @@ public override void Draw(LayoutContext context, int width, IList li
MarkupText Join(IEnumerable cells) => BlockText.Fit(MarkupText.Join(divider, cells), width);
- lines.Add(Join(shown.Select(c => BlockText.Fit(Columns[c].Header.FormatColumn(BlockText.Column(widths[c], Columns[c].Alignment))[0], widths[c]))));
+ lines.Add(Join(shown.Select(c => BlockText.Fit(context.Paint(theme => theme.HeadingColor, Columns[c].Header).FormatColumn(BlockText.Column(widths[c], Columns[c].Alignment))[0], widths[c]))));
var rule = HeaderRule ?? context.Theme.Piece(theme => theme.HeaderRule);
- if (rule.Length > 0) lines.Add(BlockText.Fit(BlockText.Run(context.Glyph(rule, "-"), tableWidth), width));
+ if (rule.Length > 0) lines.Add(BlockText.Fit(context.Paint(theme => theme.HeaderRuleColor, BlockText.Run(context.Glyph(rule, "-"), tableWidth)), width));
var cellLines = new List[Columns.Length];
+ var index = 0;
foreach (var row in AllRows)
{
+ var first = lines.Count;
var height = 1;
foreach (var c in shown)
{
@@ -60,6 +68,8 @@ public override void Draw(LayoutContext context, int width, IList li
}
for (var line = 0; line < height; line++)
lines.Add(Join(shown.Select(c => line < cellLines[c].Count ? BlockText.Fit(cellLines[c][line], widths[c]) : BlockText.Blank(widths[c]))));
+ if (Striped) context.Stripe(lines, first, index);
+ index++;
}
}
diff --git a/MarkupString/Layout/Blocks/Tree.cs b/MarkupString/Layout/Blocks/Tree.cs
index dfca443..34b5995 100644
--- a/MarkupString/Layout/Blocks/Tree.cs
+++ b/MarkupString/Layout/Blocks/Tree.cs
@@ -16,7 +16,7 @@ public sealed record Tree(ImmutableArray Items) : Block
public override void Draw(LayoutContext context, int width, IList lines)
{
if (Items.IsDefaultOrEmpty) return;
- var guide = context.Guide(Guide);
+ var guide = context.Paint(context.Guide(Guide));
foreach (var item in Items) DrawItem(item, MarkupText.Empty, null, guide, context, width, lines);
}
diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt
index 2bed501..78426d9 100644
--- a/MarkupString/PublicAPI.Unshipped.txt
+++ b/MarkupString/PublicAPI.Unshipped.txt
@@ -752,3 +752,197 @@ virtual MarkupString.Layout.Block.PrintMembers(System.Text.StringBuilder! builde
static MarkupString.MarkupTextRenderer.RenderFragment(MarkupString.MarkupText! text, MarkupString.MarkupFormat! format, MarkupString.MarkupRegistry! registry, System.Buffers.IBufferWriter! output) -> void
~override MarkupString.RgbColor.Equals(object obj) -> bool
~override MarkupString.RgbColor.ToString() -> string
+MarkupString.ColorMath
+MarkupString.ContrastShortfall
+MarkupString.ContrastShortfall.ContrastShortfall() -> void
+MarkupString.ContrastShortfall.ContrastShortfall(MarkupString.ThemeRole Role, double Ratio, double Required) -> void
+MarkupString.ContrastShortfall.Deconstruct(out MarkupString.ThemeRole Role, out double Ratio, out double Required) -> void
+MarkupString.ContrastShortfall.Equals(MarkupString.ContrastShortfall other) -> bool
+MarkupString.ContrastShortfall.Ratio.get -> double
+MarkupString.ContrastShortfall.Ratio.init -> void
+MarkupString.ContrastShortfall.Required.get -> double
+MarkupString.ContrastShortfall.Required.init -> void
+MarkupString.ContrastShortfall.Role.get -> MarkupString.ThemeRole
+MarkupString.ContrastShortfall.Role.init -> void
+MarkupString.Layout.LayoutContext.Paint(System.Func! part, MarkupString.MarkupText! text) -> MarkupString.MarkupText!
+MarkupString.Layout.LayoutTheme.BorderColor.get -> MarkupString.IMarkup?
+MarkupString.Layout.LayoutTheme.BorderColor.init -> void
+MarkupString.Layout.LayoutTheme.BulletColor.get -> MarkupString.IMarkup?
+MarkupString.Layout.LayoutTheme.BulletColor.init -> void
+MarkupString.Layout.LayoutTheme.GaugeEmptyColor.get -> MarkupString.IMarkup?
+MarkupString.Layout.LayoutTheme.GaugeEmptyColor.init -> void
+MarkupString.Layout.LayoutTheme.GaugeFilledColor.get -> MarkupString.IMarkup?
+MarkupString.Layout.LayoutTheme.GaugeFilledColor.init -> void
+MarkupString.Layout.LayoutTheme.GuideColor.get -> MarkupString.IMarkup?
+MarkupString.Layout.LayoutTheme.GuideColor.init -> void
+MarkupString.Layout.LayoutTheme.HasColor.get -> bool
+MarkupString.Layout.LayoutTheme.HeaderRuleColor.get -> MarkupString.IMarkup?
+MarkupString.Layout.LayoutTheme.HeaderRuleColor.init -> void
+MarkupString.Layout.LayoutTheme.HeadingColor.get -> MarkupString.IMarkup?
+MarkupString.Layout.LayoutTheme.HeadingColor.init -> void
+MarkupString.Layout.LayoutTheme.LabelColor.get -> MarkupString.IMarkup?
+MarkupString.Layout.LayoutTheme.LabelColor.init -> void
+MarkupString.Layout.LayoutTheme.SeparatorColor.get -> MarkupString.IMarkup?
+MarkupString.Layout.LayoutTheme.SeparatorColor.init -> void
+MarkupString.Layout.LayoutTheme.TitleColor.get -> MarkupString.IMarkup?
+MarkupString.Layout.LayoutTheme.TitleColor.init -> void
+MarkupString.Layout.Themed.Fallback.get -> bool
+MarkupString.Layout.Themed.Fallback.init -> void
+MarkupString.Layout.Themed.Within(MarkupString.Layout.LayoutTheme! around) -> MarkupString.Layout.LayoutTheme!
+MarkupString.OklchColor
+MarkupString.OklchColor.C.get -> double
+MarkupString.OklchColor.C.init -> void
+MarkupString.OklchColor.Deconstruct(out double L, out double C, out double H) -> void
+MarkupString.OklchColor.Equals(MarkupString.OklchColor other) -> bool
+MarkupString.OklchColor.H.get -> double
+MarkupString.OklchColor.H.init -> void
+MarkupString.OklchColor.L.get -> double
+MarkupString.OklchColor.L.init -> void
+MarkupString.OklchColor.OklchColor() -> void
+MarkupString.OklchColor.OklchColor(double L, double C, double H) -> void
+MarkupString.ThemeColor
+MarkupString.ThemeColor.Deconstruct(out MarkupString.RgbColor? Rgb, out int? Slot) -> void
+MarkupString.ThemeColor.Equals(MarkupString.ThemeColor other) -> bool
+MarkupString.ThemeColor.Resolved.get -> MarkupString.RgbColor
+MarkupString.ThemeColor.Rgb.get -> MarkupString.RgbColor?
+MarkupString.ThemeColor.Rgb.init -> void
+MarkupString.ThemeColor.Slot.get -> int?
+MarkupString.ThemeColor.Slot.init -> void
+MarkupString.ThemeColor.ThemeColor() -> void
+MarkupString.ThemeColor.ThemeColor(MarkupString.RgbColor? Rgb, int? Slot) -> void
+MarkupString.ThemeHarmony
+MarkupString.ThemeHarmony.Analogous = 1 -> MarkupString.ThemeHarmony
+MarkupString.ThemeHarmony.Complementary = 2 -> MarkupString.ThemeHarmony
+MarkupString.ThemeHarmony.Monochrome = 0 -> MarkupString.ThemeHarmony
+MarkupString.ThemeHarmony.Split = 3 -> MarkupString.ThemeHarmony
+MarkupString.ThemeHarmony.Tetradic = 5 -> MarkupString.ThemeHarmony
+MarkupString.ThemeHarmony.Triadic = 4 -> MarkupString.ThemeHarmony
+MarkupString.ThemeMode
+MarkupString.ThemeMode.Dark = 0 -> MarkupString.ThemeMode
+MarkupString.ThemeMode.Light = 1 -> MarkupString.ThemeMode
+MarkupString.ThemePalette
+MarkupString.ThemePalette.$() -> MarkupString.ThemePalette!
+MarkupString.ThemePalette.BackgroundColor.get -> MarkupString.RgbColor
+MarkupString.ThemePalette.Check() -> System.Collections.Generic.IReadOnlyList!
+MarkupString.ThemePalette.Colors.get -> System.Collections.Immutable.ImmutableDictionary!
+MarkupString.ThemePalette.Colors.init -> void
+MarkupString.ThemePalette.Equals(MarkupString.ThemePalette? other) -> bool
+MarkupString.ThemePalette.Mode.get -> MarkupString.ThemeMode
+MarkupString.ThemePalette.Mode.init -> void
+MarkupString.ThemePalette.Name.get -> string!
+MarkupString.ThemePalette.Name.init -> void
+MarkupString.ThemePalette.ThemePalette() -> void
+MarkupString.ThemePalette.ToJson() -> string!
+MarkupString.ThemePalette.With(MarkupString.ThemeRole role, MarkupString.ThemeColor? color) -> MarkupString.ThemePalette!
+MarkupString.ThemePalette.this[MarkupString.ThemeRole role].get -> MarkupString.ThemeColor?
+MarkupString.ThemeRole
+MarkupString.ThemeRole.Background = 0 -> MarkupString.ThemeRole
+override MarkupString.ContrastShortfall.GetHashCode() -> int
+override MarkupString.OklchColor.GetHashCode() -> int
+override MarkupString.ThemeColor.GetHashCode() -> int
+override MarkupString.ThemePalette.Equals(object? obj) -> bool
+override MarkupString.ThemePalette.GetHashCode() -> int
+override MarkupString.ThemePalette.ToString() -> string!
+static MarkupString.ColorMath.Contrast(MarkupString.RgbColor first, MarkupString.RgbColor second) -> double
+static MarkupString.ColorMath.FromOklch(MarkupString.OklchColor color) -> MarkupString.RgbColor
+static MarkupString.ColorMath.Luminance(MarkupString.RgbColor color) -> double
+static MarkupString.ColorMath.Rotate(MarkupString.RgbColor color, double degrees) -> MarkupString.RgbColor
+static MarkupString.ColorMath.StandardColor(int slot) -> MarkupString.RgbColor
+static MarkupString.ColorMath.StandardSlot(MarkupString.RgbColor color) -> int
+static MarkupString.ColorMath.ToOklch(MarkupString.RgbColor color) -> MarkupString.OklchColor
+static MarkupString.ColorMath.TryParseHex(string? text, out MarkupString.RgbColor color) -> bool
+static MarkupString.ColorMath.WithContrast(MarkupString.RgbColor color, MarkupString.RgbColor background, double ratio) -> MarkupString.RgbColor
+static MarkupString.ContrastShortfall.operator !=(MarkupString.ContrastShortfall left, MarkupString.ContrastShortfall right) -> bool
+static MarkupString.ContrastShortfall.operator ==(MarkupString.ContrastShortfall left, MarkupString.ContrastShortfall right) -> bool
+static MarkupString.Layout.BlockExtensions.ThemedUnder(this MarkupString.Layout.Block! content, MarkupString.Layout.LayoutTheme! theme) -> MarkupString.Layout.Themed!
+static MarkupString.OklchColor.operator !=(MarkupString.OklchColor left, MarkupString.OklchColor right) -> bool
+static MarkupString.OklchColor.operator ==(MarkupString.OklchColor left, MarkupString.OklchColor right) -> bool
+static MarkupString.ThemeColor.Of(MarkupString.RgbColor rgb) -> MarkupString.ThemeColor
+static MarkupString.ThemeColor.Standard(int slot) -> MarkupString.ThemeColor
+static MarkupString.ThemeColor.operator !=(MarkupString.ThemeColor left, MarkupString.ThemeColor right) -> bool
+static MarkupString.ThemeColor.operator ==(MarkupString.ThemeColor left, MarkupString.ThemeColor right) -> bool
+static MarkupString.ThemePalette.FromBase16(string! name, System.Collections.Generic.IReadOnlyList! colors) -> MarkupString.ThemePalette!
+static MarkupString.ThemePalette.Generate(MarkupString.RgbColor seed, MarkupString.ThemeHarmony harmony = MarkupString.ThemeHarmony.Analogous, MarkupString.ThemeMode mode = MarkupString.ThemeMode.Dark, double contrast = 0) -> MarkupString.ThemePalette!
+static MarkupString.ThemePalette.Preset(string! name) -> MarkupString.ThemePalette?
+static MarkupString.ThemePalette.Presets.get -> System.Collections.Generic.IReadOnlyList!
+static MarkupString.ThemePalette.Required(MarkupString.ThemeRole role) -> double
+static MarkupString.ThemePalette.RoleName(MarkupString.ThemeRole role) -> string!
+static MarkupString.ThemePalette.Terminal.get -> MarkupString.ThemePalette!
+static MarkupString.ThemePalette.TryParse(string! json, out MarkupString.ThemePalette? palette, out string? error) -> bool
+static MarkupString.ThemePalette.TryParseRole(string! name, out MarkupString.ThemeRole role) -> bool
+static MarkupString.ThemePalette.TryRead(System.Text.Json.JsonElement element, out MarkupString.ThemePalette? palette, out string? error) -> bool
+static MarkupString.ThemePalette.operator !=(MarkupString.ThemePalette? left, MarkupString.ThemePalette? right) -> bool
+static MarkupString.ThemePalette.operator ==(MarkupString.ThemePalette? left, MarkupString.ThemePalette? right) -> bool
+~override MarkupString.ContrastShortfall.Equals(object obj) -> bool
+~override MarkupString.ContrastShortfall.ToString() -> string
+~override MarkupString.OklchColor.Equals(object obj) -> bool
+~override MarkupString.OklchColor.ToString() -> string
+~override MarkupString.ThemeColor.Equals(object obj) -> bool
+~override MarkupString.ThemeColor.ToString() -> string
+MarkupString.IColorMarkup.Background.get -> MarkupString.RgbColor?
+MarkupString.Layout.Fields.Striped.get -> bool
+MarkupString.Layout.Fields.Striped.init -> void
+MarkupString.Layout.LayoutTheme.StripeColor.get -> MarkupString.IMarkup?
+MarkupString.Layout.LayoutTheme.StripeColor.init -> void
+MarkupString.Layout.Table.Striped.get -> bool
+MarkupString.Layout.Table.Striped.init -> void
+MarkupString.ThemePaint
+MarkupString.ThemePaint.Background = 2 -> MarkupString.ThemePaint
+MarkupString.ThemePaint.Bold = 1 -> MarkupString.ThemePaint
+MarkupString.ThemePaint.Text = 0 -> MarkupString.ThemePaint
+MarkupString.ThemePalette.ToTheme(System.Func! paint) -> MarkupString.Layout.LayoutTheme!
+MarkupString.ThemeRole.Error = 9 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Foreground = 2 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Info = 10 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Muted = 6 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Primary = 3 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Secondary = 4 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Success = 7 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Surface = 1 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Tertiary = 5 -> MarkupString.ThemeRole
+MarkupString.ThemeRole.Warning = 8 -> MarkupString.ThemeRole
+MarkupString.ThemeLook
+MarkupString.ThemeLook.$() -> MarkupString.ThemeLook!
+MarkupString.ThemeLook.Border.get -> string?
+MarkupString.ThemeLook.Border.init -> void
+MarkupString.ThemeLook.Bullet.get -> string?
+MarkupString.ThemeLook.Bullet.init -> void
+MarkupString.ThemeLook.Equals(MarkupString.ThemeLook? other) -> bool
+MarkupString.ThemeLook.GaugeClose.get -> string?
+MarkupString.ThemeLook.GaugeClose.init -> void
+MarkupString.ThemeLook.GaugeEmpty.get -> string?
+MarkupString.ThemeLook.GaugeEmpty.init -> void
+MarkupString.ThemeLook.GaugeFilled.get -> string?
+MarkupString.ThemeLook.GaugeFilled.init -> void
+MarkupString.ThemeLook.GaugeOpen.get -> string?
+MarkupString.ThemeLook.GaugeOpen.init -> void
+MarkupString.ThemeLook.Guide.get -> string?
+MarkupString.ThemeLook.Guide.init -> void
+MarkupString.ThemeLook.Over(MarkupString.ThemeLook? below) -> MarkupString.ThemeLook!
+MarkupString.ThemeLook.Rule.get -> string?
+MarkupString.ThemeLook.Rule.init -> void
+MarkupString.ThemeLook.Separator.get -> string?
+MarkupString.ThemeLook.Separator.init -> void
+MarkupString.ThemeLook.ThemeLook() -> void
+MarkupString.ThemeLook.TitleClose.get -> string?
+MarkupString.ThemeLook.TitleClose.init -> void
+MarkupString.ThemeLook.TitleOpen.get -> string?
+MarkupString.ThemeLook.TitleOpen.init -> void
+MarkupString.ThemeLook.ToTheme() -> MarkupString.Layout.LayoutTheme!
+MarkupString.ThemeLook.Write(System.Text.Json.Utf8JsonWriter! json) -> void
+MarkupString.ThemePalette.Look.get -> MarkupString.ThemeLook?
+MarkupString.ThemePalette.Look.init -> void
+override MarkupString.ThemeLook.Equals(object? obj) -> bool
+override MarkupString.ThemeLook.GetHashCode() -> int
+override MarkupString.ThemeLook.ToString() -> string!
+static MarkupString.ThemeLook.TryRead(System.Text.Json.JsonElement element, out MarkupString.ThemeLook? look, out string? error) -> bool
+static MarkupString.ThemeLook.operator !=(MarkupString.ThemeLook? left, MarkupString.ThemeLook? right) -> bool
+static MarkupString.ThemeLook.operator ==(MarkupString.ThemeLook? left, MarkupString.ThemeLook? right) -> bool
+static MarkupString.ThemePalette.Genres.get -> System.Collections.Generic.IReadOnlyList!
+MarkupString.ThemePalette.Contrast.get -> double
+MarkupString.ThemePalette.Contrast.init -> void
+MarkupString.ThemePalette.Harmony.get -> MarkupString.ThemeHarmony
+MarkupString.ThemePalette.Harmony.init -> void
+MarkupString.ThemePalette.InMode(MarkupString.ThemeMode mode) -> MarkupString.ThemePalette!
+MarkupString.ThemePalette.Seed.get -> MarkupString.RgbColor?
+MarkupString.ThemePalette.Seed.init -> void
diff --git a/MarkupString/ThemeLook.cs b/MarkupString/ThemeLook.cs
new file mode 100644
index 0000000..bc37a62
--- /dev/null
+++ b/MarkupString/ThemeLook.cs
@@ -0,0 +1,196 @@
+using System.Text.Json;
+using MarkupString.Layout;
+
+namespace MarkupString;
+
+///
+/// The shapes a theme draws with, beside its colours: the border and the ornaments round a title, tree
+/// guides, the bullet, a gauge's pieces, the separator after a label and the rule under table headings.
+/// Each is until set, and an unset one is the layout's own. A reader whose client
+/// has only ASCII gets the ASCII form of a border or guide, and the usual ASCII piece for anything else.
+///
+public sealed record ThemeLook
+{
+ /// A border preset's name ().
+ public string? Border { get; init; }
+
+ /// Before a title set into a box's edge or a rule, joining it to the line: "╡ ❖ ".
+ public string? TitleOpen { get; init; }
+
+ /// After the title: " ❖ ╞".
+ public string? TitleClose { get; init; }
+
+ /// A tree guide preset's name ().
+ public string? Guide { get; init; }
+
+ /// A bulleted list's marker.
+ public string? Bullet { get; init; }
+
+ /// Before a gauge's bar.
+ public string? GaugeOpen { get; init; }
+
+ /// A gauge's filled part, repeated.
+ public string? GaugeFilled { get; init; }
+
+ /// A gauge's empty part, repeated.
+ public string? GaugeEmpty { get; init; }
+
+ /// After a gauge's bar.
+ public string? GaugeClose { get; init; }
+
+ /// Between a label and its value.
+ public string? Separator { get; init; }
+
+ /// The line under a table's headings, repeated.
+ public string? Rule { get; init; }
+
+ /// This look over : what this sets, and what it leaves unset from there.
+ public ThemeLook Over(ThemeLook? below) => below is null ? this : new()
+ {
+ Border = Border ?? below.Border,
+ TitleOpen = TitleOpen ?? below.TitleOpen,
+ TitleClose = TitleClose ?? below.TitleClose,
+ Guide = Guide ?? below.Guide,
+ Bullet = Bullet ?? below.Bullet,
+ GaugeOpen = GaugeOpen ?? below.GaugeOpen,
+ GaugeFilled = GaugeFilled ?? below.GaugeFilled,
+ GaugeEmpty = GaugeEmpty ?? below.GaugeEmpty,
+ GaugeClose = GaugeClose ?? below.GaugeClose,
+ Separator = Separator ?? below.Separator,
+ Rule = Rule ?? below.Rule,
+ };
+
+ /// The look as a layout theme's pieces, its colours unset.
+ public LayoutTheme ToTheme()
+ {
+ return new LayoutTheme
+ {
+ Border = Border is null && TitleOpen is null && TitleClose is null ? null : BorderOf(BorderStyle.Preset(Border ?? "single") ?? BorderStyle.Single),
+ Guide = Guide is null ? null : TreeGuide.Preset(Guide),
+ Bullet = Piece(Bullet),
+ GaugeOpen = Piece(GaugeOpen),
+ GaugeFilled = Piece(GaugeFilled),
+ GaugeEmpty = Piece(GaugeEmpty),
+ GaugeClose = Piece(GaugeClose),
+ FieldSeparator = Piece(Separator),
+ HeaderRule = Piece(Rule),
+ };
+ }
+
+ private BorderStyle BorderOf(BorderStyle preset) => preset with
+ {
+ TitleOpen = TitleOpen is null ? preset.TitleOpen : MarkupText.Plain(TitleOpen),
+ TitleClose = TitleClose is null ? preset.TitleClose : MarkupText.Plain(TitleClose),
+ };
+
+ private static MarkupText? Piece(string? text) => text is null ? null : MarkupText.Plain(text);
+
+ ///
+ /// Reads a look from JSON: border and guide as preset names, title as
+ /// [open, close], gauge as [open, filled, empty, close], and bullet,
+ /// separator and rule as text.
+ ///
+ public static bool TryRead(JsonElement element, out ThemeLook? look, out string? error)
+ {
+ look = null;
+ error = null;
+ if (element.ValueKind != JsonValueKind.Object)
+ {
+ error = "look is an object";
+ return false;
+ }
+
+ var result = new ThemeLook();
+ foreach (var property in element.EnumerateObject())
+ {
+ var value = property.Value;
+ switch (property.Name)
+ {
+ case "border":
+ if (value.ValueKind != JsonValueKind.String || BorderStyle.Preset(value.GetString()!) is null)
+ {
+ error = "border is one of " + string.Join(", ", BorderStyle.Presets.Select(preset => preset.Name));
+ return false;
+ }
+ result = result with { Border = value.GetString() };
+ break;
+ case "guide":
+ if (value.ValueKind != JsonValueKind.String || TreeGuide.Preset(value.GetString()!) is null)
+ {
+ error = "guide is one of " + string.Join(", ", TreeGuide.Presets.Select(preset => preset.Name));
+ return false;
+ }
+ result = result with { Guide = value.GetString() };
+ break;
+ case "title":
+ if (Texts(value, 2) is not { } title)
+ {
+ error = "title is [open, close]";
+ return false;
+ }
+ result = result with { TitleOpen = title[0], TitleClose = title[1] };
+ break;
+ case "gauge":
+ if (Texts(value, 4) is not { } gauge)
+ {
+ error = "gauge is [open, filled, empty, close]";
+ return false;
+ }
+ result = result with { GaugeOpen = gauge[0], GaugeFilled = gauge[1], GaugeEmpty = gauge[2], GaugeClose = gauge[3] };
+ break;
+ case "bullet" or "separator" or "rule":
+ if (value.ValueKind != JsonValueKind.String || (property.Name != "separator" && value.GetString()!.Length == 0))
+ {
+ error = $"{property.Name} is text";
+ return false;
+ }
+ result = property.Name switch
+ {
+ "bullet" => result with { Bullet = value.GetString() },
+ "separator" => result with { Separator = value.GetString() },
+ _ => result with { Rule = value.GetString() },
+ };
+ break;
+ default:
+ error = $"a look has no '{property.Name}'";
+ return false;
+ }
+ }
+ look = result;
+ return true;
+ }
+
+ /// as strings, none empty, or null.
+ private static string[]? Texts(JsonElement value, int count)
+ {
+ if (value.ValueKind != JsonValueKind.Array || value.GetArrayLength() != count) return null;
+ var texts = value.EnumerateArray().Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() : null).ToArray();
+ return texts.All(text => !string.IsNullOrEmpty(text)) ? [.. texts.Select(text => text!)] : null;
+ }
+
+ /// Writes the look as reads it.
+ public void Write(Utf8JsonWriter json)
+ {
+ ArgumentNullException.ThrowIfNull(json);
+ json.WriteStartObject();
+ if (Border is not null) json.WriteString("border", Border);
+ if (TitleOpen is not null && TitleClose is not null)
+ {
+ json.WriteStartArray("title");
+ json.WriteStringValue(TitleOpen);
+ json.WriteStringValue(TitleClose);
+ json.WriteEndArray();
+ }
+ if (Guide is not null) json.WriteString("guide", Guide);
+ if (Bullet is not null) json.WriteString("bullet", Bullet);
+ if (GaugeOpen is not null && GaugeFilled is not null && GaugeEmpty is not null && GaugeClose is not null)
+ {
+ json.WriteStartArray("gauge");
+ foreach (var piece in new[] { GaugeOpen, GaugeFilled, GaugeEmpty, GaugeClose }) json.WriteStringValue(piece);
+ json.WriteEndArray();
+ }
+ if (Separator is not null) json.WriteString("separator", Separator);
+ if (Rule is not null) json.WriteString("rule", Rule);
+ json.WriteEndObject();
+ }
+}
diff --git a/MarkupString/ThemePalette.cs b/MarkupString/ThemePalette.cs
new file mode 100644
index 0000000..a8be159
--- /dev/null
+++ b/MarkupString/ThemePalette.cs
@@ -0,0 +1,715 @@
+using System.Collections.Immutable;
+using System.Globalization;
+using System.Text.Json;
+using MarkupString.Layout;
+
+namespace MarkupString;
+
+///
+/// One colour of a : an exact colour, the standard terminal colour a
+/// sixteen-colour client is sent in its place, or both.
+///
+/// The colour, or for a standard colour alone, which the reader's own client decides the look of.
+/// The standard colour, 0-7 normal and 8-15 bright, or to have the client pick the nearest.
+public readonly record struct ThemeColor(RgbColor? Rgb, int? Slot)
+{
+ /// , with the standard colour of the same kind ().
+ public static ThemeColor Of(RgbColor rgb) => new(rgb, ColorMath.StandardSlot(rgb));
+
+ /// Standard colour alone: whatever the reader's client has made it.
+ public static ThemeColor Standard(int slot)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(slot);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(slot, 15);
+ return new(null, slot);
+ }
+
+ /// The colour to measure with: , or the slot's usual VGA value.
+ public RgbColor Resolved => Rgb ?? ColorMath.StandardColor(Slot ?? 7);
+}
+
+/// Whether a palette is made for a dark background or a light one.
+public enum ThemeMode
+{
+ /// Light colours on a dark background, as most MU* clients are.
+ Dark,
+
+ /// Dark colours on a light background.
+ Light,
+}
+
+/// What a colour in a is for.
+public enum ThemeRole
+{
+ /// What the others are measured against. Never painted in a terminal.
+ Background,
+
+ /// A background a little off , laid under every second row of a striped table or list.
+ Surface,
+
+ /// Body text, measured against the background.
+ Foreground,
+
+ /// The main accent: borders, headings, gauge bars.
+ Primary,
+
+ /// Titles and labels.
+ Secondary,
+
+ /// Bullets and highlights.
+ Tertiary,
+
+ /// Quiet lines: tree guides, separators, the rule under headings, a gauge's empty part.
+ Muted,
+
+ /// Things going well.
+ Success,
+
+ /// Things to watch.
+ Warning,
+
+ /// Things gone wrong.
+ Error,
+
+ /// Things to know.
+ Info,
+}
+
+/// How asks for a colour to be painted.
+public enum ThemePaint
+{
+ /// As the colour of text and lines.
+ Text,
+
+ /// As the colour of bold text.
+ Bold,
+
+ /// As the background behind text.
+ Background,
+}
+
+/// How picks the accents' hues from the seed's.
+public enum ThemeHarmony
+{
+ /// One hue throughout, the accents told apart by lightness and chroma.
+ Monochrome,
+
+ /// The seed's neighbours, 30 degrees either side.
+ Analogous,
+
+ /// The seed and the hue opposite it.
+ Complementary,
+
+ /// The seed and the two hues either side of its opposite, 150 and 210 degrees round.
+ Split,
+
+ /// Three hues evenly round the wheel.
+ Triadic,
+
+ /// Four hues evenly round the wheel; the fourth is the info colour.
+ Tetradic,
+}
+
+/// A role of a whose contrast with the background falls short ().
+/// The role.
+/// Its contrast with the background.
+/// What it should be: 4.5 for text, 3 for lines.
+public readonly record struct ContrastShortfall(ThemeRole Role, double Ratio, double Required);
+
+///
+/// The colours a theme is made of, by what they are for (), each with the
+/// standard colour a sixteen-colour client gets instead. turns it into the
+/// colours of a 's parts.
+///
+///
+/// Make one from a preset (), from a base16 scheme (), from
+/// one colour (), or from JSON ().
+/// A role left unset is not painted.
+///
+public sealed record ThemePalette
+{
+ /// A name to show for it.
+ public string Name { get; init; } = "custom";
+
+ /// Whether it is made for a dark background or a light one.
+ public ThemeMode Mode { get; init; } = ThemeMode.Dark;
+
+ ///
+ /// The colour it was generated from (), or for one that was
+ /// not. A generated palette is made again from it for the other mode ().
+ ///
+ public RgbColor? Seed { get; init; }
+
+ /// How its accents' hues were picked from .
+ public ThemeHarmony Harmony { get; init; } = ThemeHarmony.Analogous;
+
+ /// How far above the minimum contrast it was generated, 0 to 1.
+ public double Contrast { get; init; }
+
+ ///
+ /// This palette for : a generated one made again from its seed for that
+ /// background, keeping its name and look; any other marked with the mode and otherwise unchanged.
+ ///
+ public ThemePalette InMode(ThemeMode mode) =>
+ mode == Mode ? this
+ : Seed is { } seed ? Generate(seed, Harmony, mode, Contrast) with { Name = Name, Look = Look }
+ : this with { Mode = mode };
+
+ /// The shapes it draws with beside its colours, or for the layout's own.
+ public ThemeLook? Look { get; init; }
+
+ /// The colours set, by role.
+ public ImmutableDictionary Colors { get; init; } = ImmutableDictionary.Empty;
+
+ /// The colour for , or when it is unset.
+ public ThemeColor? this[ThemeRole role] => Colors.TryGetValue(role, out var color) ? color : null;
+
+ /// This palette with set to , or unset when it is null.
+ public ThemePalette With(ThemeRole role, ThemeColor? color) =>
+ this with { Colors = color is { } set ? Colors.SetItem(role, set) : Colors.Remove(role) };
+
+ /// The background contrast is measured against: the one set, or black or white by .
+ public RgbColor BackgroundColor =>
+ this[ThemeRole.Background]?.Resolved ?? (Mode == ThemeMode.Dark ? new RgbColor(0, 0, 0) : new RgbColor(255, 255, 255));
+
+ /// The contrast a role should have with the background: 3 for lines, 4.5 for text.
+ public static double Required(ThemeRole role) => role switch
+ {
+ ThemeRole.Background or ThemeRole.Surface => 1,
+ ThemeRole.Primary or ThemeRole.Muted => 3,
+ _ => 4.5,
+ };
+
+ ///
+ /// The roles set whose contrast with the background is under . A standard colour
+ /// alone, or a standard background, looks however the reader's client makes it, so it is not measured;
+ /// nor is the surface, which is a background too.
+ ///
+ public IReadOnlyList Check()
+ {
+ var background = BackgroundColor;
+ var shortfalls = new List();
+ if (this[ThemeRole.Background] is { Rgb: null }) return shortfalls;
+ foreach (var role in Enum.GetValues())
+ {
+ if (role is ThemeRole.Background or ThemeRole.Surface || this[role] is not { Rgb: not null } color) continue;
+ var ratio = ColorMath.Contrast(color.Resolved, background);
+ if (ratio < Required(role)) shortfalls.Add(new ContrastShortfall(role, ratio, Required(role)));
+ }
+ return shortfalls;
+ }
+
+ ///
+ /// The palette as the colours of a layout's parts: borders in the primary colour, titles in the
+ /// secondary one and bold, labels secondary, bullets tertiary, headings primary and bold, gauge bars
+ /// primary, guides, separators, the rule under headings and a gauge's empty part muted, and the
+ /// stripes of a striped table or list on the surface.
+ ///
+ /// Makes the markup a format draws a colour with, as text, bold text or a background: an ANSI colour, say.
+ public LayoutTheme ToTheme(Func paint)
+ {
+ ArgumentNullException.ThrowIfNull(paint);
+ IMarkup? Part(ThemeRole role, ThemePaint how = ThemePaint.Text) => this[role] is { } color ? paint(color, how) : null;
+ return (Look?.ToTheme() ?? new LayoutTheme()) with
+ {
+ BorderColor = Part(ThemeRole.Primary),
+ TitleColor = Part(ThemeRole.Secondary, ThemePaint.Bold),
+ HeadingColor = Part(ThemeRole.Primary, ThemePaint.Bold),
+ LabelColor = Part(ThemeRole.Secondary),
+ BulletColor = Part(ThemeRole.Tertiary),
+ GaugeFilledColor = Part(ThemeRole.Primary),
+ GuideColor = Part(ThemeRole.Muted),
+ SeparatorColor = Part(ThemeRole.Muted),
+ HeaderRuleColor = Part(ThemeRole.Muted),
+ GaugeEmptyColor = Part(ThemeRole.Muted),
+ StripeColor = Part(ThemeRole.Surface, ThemePaint.Background),
+ };
+ }
+
+ ///
+ /// Standard colours alone, so each reader sees the game in the colours their own client is set to:
+ /// cyan borders, bright white titles, yellow bullets, dark grey guides.
+ ///
+ public static ThemePalette Terminal { get; } = new()
+ {
+ Name = "terminal",
+ Colors = ImmutableDictionary.CreateRange(new Dictionary
+ {
+ [ThemeRole.Background] = ThemeColor.Standard(0),
+ [ThemeRole.Foreground] = ThemeColor.Standard(7),
+ [ThemeRole.Primary] = ThemeColor.Standard(6),
+ [ThemeRole.Secondary] = ThemeColor.Standard(15),
+ [ThemeRole.Tertiary] = ThemeColor.Standard(3),
+ [ThemeRole.Muted] = ThemeColor.Standard(8),
+ [ThemeRole.Success] = ThemeColor.Standard(2),
+ [ThemeRole.Warning] = ThemeColor.Standard(3),
+ [ThemeRole.Error] = ThemeColor.Standard(1),
+ [ThemeRole.Info] = ThemeColor.Standard(12),
+ }),
+ };
+
+ ///
+ /// A theme for each genre MSSP names: adult, fantasy, historical, horror, modern, mystery, romance,
+ /// science-fiction and spiritual. Each is made from a colour that suits it ()
+ /// and has its own border, title ornaments, bullet, guides and gauge ().
+ ///
+ public static IReadOnlyList Genres { get; } =
+ [
+ Genre("adult", "#b0306a", ThemeHarmony.Analogous, new ThemeLook
+ {
+ Border = "heavy", TitleOpen = "┫ ◈ ", TitleClose = " ◈ ┣", Guide = "heavy", Bullet = "◈",
+ GaugeOpen = "[", GaugeFilled = "◆", GaugeEmpty = "◇", GaugeClose = "]", Rule = "━",
+ }),
+ Genre("fantasy", "#c9a227", ThemeHarmony.Analogous, new ThemeLook
+ {
+ Border = "double", TitleOpen = "╡ ❖ ", TitleClose = " ❖ ╞", Guide = "double", Bullet = "❧",
+ GaugeOpen = "╞", GaugeFilled = "█", GaugeEmpty = "░", GaugeClose = "╡", Rule = "═",
+ }),
+ Genre("historical", "#a0784a", ThemeHarmony.Monochrome, new ThemeLook
+ {
+ Border = "double", TitleOpen = "╡ § ", TitleClose = " § ╞", Guide = "line", Bullet = "§",
+ Separator = " - ", Rule = "═",
+ }),
+ Genre("horror", "#b01e28", ThemeHarmony.Monochrome, new ThemeLook
+ {
+ Border = "heavy", TitleOpen = "┫ † ", TitleClose = " † ┣", Guide = "heavy", Bullet = "†",
+ GaugeOpen = "┫", GaugeFilled = "▓", GaugeEmpty = "░", GaugeClose = "┣", Rule = "━",
+ }),
+ Genre("modern", "#4a90d9", ThemeHarmony.Analogous, new ThemeLook
+ {
+ Border = "rounded", Guide = "rounded", Bullet = "•",
+ GaugeOpen = "▕", GaugeFilled = "█", GaugeEmpty = "░", GaugeClose = "▏", Rule = "─",
+ }),
+ Genre("mystery", "#6a4c9c", ThemeHarmony.Split, new ThemeLook
+ {
+ Border = "single", TitleOpen = "┤ ◆ ", TitleClose = " ◆ ├", Guide = "line", Bullet = "◇",
+ Separator = " .. ", Rule = "┄",
+ }),
+ Genre("romance", "#d6577c", ThemeHarmony.Analogous, new ThemeLook
+ {
+ Border = "rounded", TitleOpen = "┤ ♥ ", TitleClose = " ♥ ├", Guide = "rounded", Bullet = "♥",
+ GaugeOpen = "(", GaugeFilled = "♥", GaugeEmpty = "♡", GaugeClose = ")", Rule = "─",
+ }),
+ Genre("science-fiction", "#00c8ff", ThemeHarmony.Complementary, new ThemeLook
+ {
+ Border = "heavy", TitleOpen = "┫▐ ", TitleClose = " ▌┣", Guide = "heavy", Bullet = "▸",
+ GaugeOpen = "▕", GaugeFilled = "▰", GaugeEmpty = "▱", GaugeClose = "▏", Separator = " > ", Rule = "━",
+ }),
+ Genre("spiritual", "#9b7fd1", ThemeHarmony.Triadic, new ThemeLook
+ {
+ Border = "double", TitleOpen = "╡ ✧ ", TitleClose = " ✧ ╞", Guide = "rounded", Bullet = "✧",
+ GaugeOpen = "(", GaugeFilled = "●", GaugeEmpty = "○", GaugeClose = ")", Rule = "═",
+ }),
+ ];
+
+ private static ThemePalette Genre(string name, string seed, ThemeHarmony harmony, ThemeLook look) =>
+ Generate(ColorMath.TryParseHex(seed, out var rgb) ? rgb : default, harmony) with { Name = name, Look = look };
+
+ ///
+ /// Every preset: ; one for each MSSP genre, each with a look of its own as well as
+ /// colours (); and well-known schemes.
+ ///
+ public static IReadOnlyList Presets { get; } =
+ [
+ Terminal,
+ .. Genres,
+ Base16("catppuccin-mocha", "1e1e2e 181825 313244 45475a 585b70 cdd6f4 f5e0dc b4befe f38ba8 fab387 f9e2af a6e3a1 94e2d5 89b4fa cba6f7 f2cdcd"),
+ Base16("catppuccin-latte", "eff1f5 e6e9ef ccd0da bcc0cc acb0be 4c4f69 dc8a78 7287fd d20f39 fe640b df8e1d 40a02b 179299 1e66f5 8839ef dd7878"),
+ Base16("dracula", "282a36 21222c 44475a 6272a4 bfbfbf f8f8f2 f8f8f2 ffffff ff5555 ffb86c f1fa8c 50fa7b 8be9fd bd93f9 ff79c6 ff79c6"),
+ Base16("gruvbox-dark", "282828 3c3836 504945 665c54 bdae93 d5c4a1 ebdbb2 fbf1c7 fb4934 fe8019 fabd2f b8bb26 8ec07c 83a598 d3869b d65d0e"),
+ Base16("nord", "2e3440 3b4252 434c5e 4c566a d8dee9 e5e9f0 eceff4 8fbcbb bf616a d08770 ebcb8b a3be8c 88c0d0 81a1c1 b48ead 5e81ac"),
+ Base16("solarized-dark", "002b36 073642 586e75 657b83 839496 93a1a1 eee8d5 fdf6e3 dc322f cb4b16 b58900 859900 2aa198 268bd2 6c71c4 d33682"),
+ Base16("solarized-light", "fdf6e3 eee8d5 93a1a1 839496 657b83 586e75 073642 002b36 dc322f cb4b16 b58900 859900 2aa198 268bd2 6c71c4 d33682"),
+ Base16("tokyo-night", "1a1b26 16161e 2f3549 565f89 a9b1d6 c0caf5 cbccd1 d5d6db f7768e ff9e64 e0af68 9ece6a 7dcfff 7aa2f7 bb9af7 db4b4b"),
+ ];
+
+ /// The preset named , ignoring case, or null.
+ public static ThemePalette? Preset(string name)
+ {
+ foreach (var preset in Presets)
+ if (string.Equals(preset.Name, name, StringComparison.OrdinalIgnoreCase)) return preset;
+ return null;
+ }
+
+ private static ThemePalette Base16(string name, string colors) =>
+ FromBase16(name, [.. colors.Split(' ').Select(hex => ColorMath.TryParseHex(hex, out var rgb) ? rgb : default)]);
+
+ ///
+ /// A base16 scheme (base00 to base0F) as a palette, by base16's own guide: background
+ /// base00, foreground base05, muted base03, primary base0D (blue),
+ /// secondary base0E (magenta), tertiary base0C (cyan), success base0B, warning
+ /// base0A, error base08, info base0C. Each keeps the standard colour base16
+ /// gives its place in a terminal; dark or light by the background.
+ ///
+ /// There are not sixteen colours.
+ public static ThemePalette FromBase16(string name, IReadOnlyList colors)
+ {
+ ArgumentNullException.ThrowIfNull(colors);
+ if (colors.Count != 16) throw new ArgumentException("A base16 scheme has sixteen colours.", nameof(colors));
+ ThemeColor At(int index, int slot) => new(colors[index], slot);
+ return new ThemePalette
+ {
+ Name = name,
+ Mode = ColorMath.Luminance(colors[0]) < 0.18 ? ThemeMode.Dark : ThemeMode.Light,
+ Colors = ImmutableDictionary.CreateRange(new Dictionary
+ {
+ [ThemeRole.Background] = At(0x0, 0),
+ [ThemeRole.Surface] = At(0x1, ColorMath.Luminance(colors[0]) < 0.18 ? 8 : 7),
+ [ThemeRole.Foreground] = At(0x5, 7),
+ [ThemeRole.Muted] = At(0x3, 8),
+ [ThemeRole.Primary] = At(0xD, 4),
+ [ThemeRole.Secondary] = At(0xE, 5),
+ [ThemeRole.Tertiary] = At(0xC, 6),
+ [ThemeRole.Success] = At(0xB, 2),
+ [ThemeRole.Warning] = At(0xA, 3),
+ [ThemeRole.Error] = At(0x8, 1),
+ [ThemeRole.Info] = At(0xC, 6),
+ }),
+ };
+ }
+
+ ///
+ /// A palette made from one colour. The accents take their hues from 's as
+ /// says and its chroma, and each is made lighter or darker until it stands
+ /// out from the background enough: 3:1 for lines, 4.5:1 for text, both raised toward 7:1 by
+ /// . Success, warning, error and info keep green, amber, red and blue,
+ /// turned a little toward the seed. The background and foreground are near black and near white
+ /// tinted with the seed.
+ ///
+ /// The colour it is made from.
+ /// How the accents' hues are picked.
+ /// Whether it is for a dark background or a light one.
+ /// From 0 to 1: how much more than the minimum the colours stand out.
+ public static ThemePalette Generate(RgbColor seed, ThemeHarmony harmony = ThemeHarmony.Analogous, ThemeMode mode = ThemeMode.Dark, double contrast = 0)
+ {
+ var source = ColorMath.ToOklch(seed);
+ var hue = double.IsNaN(source.H) ? 250 : source.H;
+ var chroma = source.C < 0.02 ? 0 : Math.Clamp(source.C, 0.06, 0.17);
+ var dark = mode == ThemeMode.Dark;
+ var level = double.IsFinite(contrast) ? Math.Clamp(contrast, 0, 1) : 0;
+ var lines = 3 + 4 * level;
+ var text = 4.5 + 2.5 * level;
+
+ var background = ColorMath.FromOklch(new OklchColor(dark ? 0.18 : 0.98, Math.Min(chroma, 0.02), hue));
+ var surface = ColorMath.FromOklch(new OklchColor(dark ? 0.245 : 0.93, Math.Min(chroma, 0.025), hue));
+ var foreground = ColorMath.WithContrast(ColorMath.FromOklch(new OklchColor(dark ? 0.9 : 0.25, Math.Min(chroma, 0.015), hue)), background, Math.Max(text, 7));
+
+ ThemeColor Accent(double turn, double lightness, double share, double ratio) =>
+ ThemeColor.Of(ColorMath.WithContrast(
+ ColorMath.FromOklch(new OklchColor(dark ? lightness : 1 - lightness * 0.65, chroma * share, ColorMath.Wrap(hue + turn))),
+ background, ratio));
+
+ // The hues of the secondary, tertiary and info colours, and how much of the seed's chroma each keeps.
+ var (second, third, info) = harmony switch
+ {
+ ThemeHarmony.Monochrome => (0.0, 0.0, (double?)null),
+ ThemeHarmony.Complementary => (180.0, 30.0, (double?)null),
+ ThemeHarmony.Split => (150.0, 210.0, (double?)null),
+ ThemeHarmony.Triadic => (120.0, 240.0, (double?)null),
+ ThemeHarmony.Tetradic => (90.0, 180.0, (double?)270),
+ _ => (30.0, -30.0, (double?)null),
+ };
+ var mono = harmony == ThemeHarmony.Monochrome;
+
+ ThemeColor Status(double conventional)
+ {
+ // Turned toward the seed, but not so far that red stops being red.
+ var turned = chroma == 0 ? conventional : conventional + Math.Clamp(ColorMath.Turn(conventional, hue) * 0.8, -20, 20);
+ return ThemeColor.Of(ColorMath.WithContrast(
+ ColorMath.FromOklch(new OklchColor(dark ? 0.75 : 0.5, 0.14, ColorMath.Wrap(turned))), background, text));
+ }
+
+ return new ThemePalette
+ {
+ Name = "generated",
+ Seed = seed,
+ Harmony = harmony,
+ Contrast = level,
+ Mode = mode,
+ Colors = ImmutableDictionary.CreateRange(new Dictionary
+ {
+ [ThemeRole.Background] = new(background, dark ? 0 : 15),
+ [ThemeRole.Surface] = new(surface, dark ? 8 : 7),
+ [ThemeRole.Foreground] = new(foreground, dark ? 7 : 0),
+ [ThemeRole.Primary] = Accent(0, 0.72, 1, lines),
+ [ThemeRole.Secondary] = Accent(second, mono ? 0.86 : 0.8, mono ? 0.5 : 1, text),
+ [ThemeRole.Tertiary] = Accent(third, mono ? 0.64 : 0.76, mono ? 0.8 : 1, text),
+ [ThemeRole.Muted] = Accent(0, 0.5, chroma == 0 ? 0 : 0.15, lines),
+ [ThemeRole.Success] = Status(145),
+ [ThemeRole.Warning] = Status(80),
+ [ThemeRole.Error] = Status(27),
+ [ThemeRole.Info] = info is { } turn ? Accent(turn, 0.76, 1, text) : Status(245),
+ }),
+ };
+ }
+
+ /// The role named , ignoring case.
+ public static bool TryParseRole(string name, out ThemeRole role) =>
+ Enum.TryParse(name, ignoreCase: true, out role) && Enum.IsDefined(role) && !int.TryParse(name, out _);
+
+ ///
+ /// Reads a palette from JSON: a preset's name ("nord"), or an object with any of
+ /// preset (start from that one), base16 (sixteen colours), seed with
+ /// harmony and contrast (generate one), mode, name, and colors
+ /// setting roles: "#88c0d0", a standard colour 6, {"rgb":"#88c0d0","slot":6}, or
+ /// null to unset one.
+ ///
+ public static bool TryParse(string json, out ThemePalette? palette, out string? error)
+ {
+ palette = null;
+ try
+ {
+ using var document = JsonDocument.Parse(json);
+ return TryRead(document.RootElement, out palette, out error);
+ }
+ catch (JsonException)
+ {
+ // A bare word is a preset's name, as it would be in quotes.
+ if (Preset(json.Trim()) is { } preset)
+ {
+ palette = preset;
+ error = null;
+ return true;
+ }
+ error = "not JSON or a theme name";
+ return false;
+ }
+ }
+
+ /// Reads a palette from , as describes.
+ public static bool TryRead(JsonElement element, out ThemePalette? palette, out string? error)
+ {
+ palette = null;
+ error = null;
+ if (element.ValueKind == JsonValueKind.String)
+ {
+ palette = Preset(element.GetString() ?? string.Empty);
+ error = palette is null ? $"no theme named '{element.GetString()}'" : null;
+ return palette is not null;
+ }
+ if (element.ValueKind != JsonValueKind.Object)
+ {
+ error = "a theme is a name or an object";
+ return false;
+ }
+
+ var result = new ThemePalette();
+ var mode = (ThemeMode?)null;
+ if (element.TryGetProperty("mode", out var modeValue))
+ {
+ if (modeValue.ValueKind != JsonValueKind.String || !Enum.TryParse(modeValue.GetString(), true, out var parsed) || !Enum.IsDefined(parsed))
+ {
+ error = "mode is dark or light";
+ return false;
+ }
+ mode = parsed;
+ }
+
+ var sources = new[] { "preset", "base16", "seed" }.Count(name => element.TryGetProperty(name, out _));
+ if (sources > 1)
+ {
+ error = "use one of preset, base16 and seed";
+ return false;
+ }
+ if (element.TryGetProperty("preset", out var presetValue))
+ {
+ if (!TryRead(presetValue, out var preset, out error)) return false;
+ result = preset!;
+ }
+ else if (element.TryGetProperty("base16", out var base16))
+ {
+ if (base16.ValueKind != JsonValueKind.Array || base16.GetArrayLength() != 16)
+ {
+ error = "base16 is an array of sixteen colours";
+ return false;
+ }
+ var colors = new List(16);
+ foreach (var item in base16.EnumerateArray())
+ {
+ if (item.ValueKind != JsonValueKind.String || !ColorMath.TryParseHex(item.GetString(), out var rgb))
+ {
+ error = $"'{item}' is not a colour like #88c0d0";
+ return false;
+ }
+ colors.Add(rgb);
+ }
+ result = FromBase16("custom", colors);
+ }
+ else if (element.TryGetProperty("seed", out var seedValue))
+ {
+ if (seedValue.ValueKind != JsonValueKind.String || !ColorMath.TryParseHex(seedValue.GetString(), out var seed))
+ {
+ error = "seed is a colour like #7aa2f7";
+ return false;
+ }
+ var harmony = ThemeHarmony.Analogous;
+ if (element.TryGetProperty("harmony", out var harmonyValue)
+ && (harmonyValue.ValueKind != JsonValueKind.String || !Enum.TryParse(harmonyValue.GetString(), true, out harmony) || !Enum.IsDefined(harmony)))
+ {
+ error = "harmony is one of " + string.Join(", ", Enum.GetNames().Select(n => n.ToLowerInvariant()));
+ return false;
+ }
+ var level = 0.0;
+ if (element.TryGetProperty("contrast", out var contrastValue)
+ && (contrastValue.ValueKind != JsonValueKind.Number || !contrastValue.TryGetDouble(out level) || level is < 0 or > 1))
+ {
+ error = "contrast is a number from 0 to 1";
+ return false;
+ }
+ result = Generate(seed, harmony, mode ?? ThemeMode.Dark, level);
+ }
+
+ if (mode is { } chosen) result = result.InMode(chosen);
+ if (element.TryGetProperty("name", out var nameValue))
+ {
+ if (nameValue.ValueKind != JsonValueKind.String)
+ {
+ error = "name is text";
+ return false;
+ }
+ result = result with { Name = nameValue.GetString()! };
+ }
+
+ if (element.TryGetProperty("look", out var lookValue))
+ {
+ if (lookValue.ValueKind == JsonValueKind.Null) result = result with { Look = null };
+ else if (!ThemeLook.TryRead(lookValue, out var look, out error)) return false;
+ else result = result with { Look = look!.Over(result.Look) };
+ }
+
+ if (element.TryGetProperty("colors", out var colorsValue))
+ {
+ if (colorsValue.ValueKind != JsonValueKind.Object)
+ {
+ error = "colors is an object of role: colour";
+ return false;
+ }
+ foreach (var property in colorsValue.EnumerateObject())
+ {
+ if (!TryParseRole(property.Name, out var role))
+ {
+ error = $"'{property.Name}' is not a role; the roles are {string.Join(", ", Enum.GetNames().Select(n => n.ToLowerInvariant()))}";
+ return false;
+ }
+ if (property.Value.ValueKind == JsonValueKind.Null)
+ {
+ result = result.With(role, null);
+ continue;
+ }
+ if (!TryReadColor(property.Value, out var color, out error)) return false;
+ result = result.With(role, color);
+ }
+ }
+
+ foreach (var property in element.EnumerateObject())
+ {
+ if (property.Name is "preset" or "base16" or "seed" or "harmony" or "contrast" or "mode" or "name" or "colors" or "look") continue;
+ error = $"a theme has no '{property.Name}'";
+ return false;
+ }
+
+ palette = result;
+ return true;
+ }
+
+ private static bool TryReadColor(JsonElement value, out ThemeColor color, out string? error)
+ {
+ color = default;
+ error = null;
+ switch (value.ValueKind)
+ {
+ case JsonValueKind.String when ColorMath.TryParseHex(value.GetString(), out var rgb):
+ color = ThemeColor.Of(rgb);
+ return true;
+ case JsonValueKind.Number when value.TryGetInt32(out var slot) && slot is >= 0 and <= 15:
+ color = ThemeColor.Standard(slot);
+ return true;
+ case JsonValueKind.Object:
+ RgbColor? exact = null;
+ int? standard = null;
+ foreach (var part in value.EnumerateObject())
+ {
+ if (part.Name == "rgb" && part.Value.ValueKind == JsonValueKind.String && ColorMath.TryParseHex(part.Value.GetString(), out var hex)) exact = hex;
+ else if (part.Name == "slot" && part.Value.ValueKind == JsonValueKind.Number && part.Value.TryGetInt32(out var index) && index is >= 0 and <= 15) standard = index;
+ else
+ {
+ error = $"'{part.Name}' in a colour: a colour has rgb (like #88c0d0) and slot (0 to 15)";
+ return false;
+ }
+ }
+ if (exact is null && standard is null)
+ {
+ error = "a colour needs rgb or slot";
+ return false;
+ }
+ color = new ThemeColor(exact, standard ?? (exact is { } known ? ColorMath.StandardSlot(known) : null));
+ return true;
+ default:
+ error = $"'{value}' is not a colour: use #rrggbb, a standard colour 0 to 15, or {{\"rgb\":..,\"slot\":..}}";
+ return false;
+ }
+ }
+
+ ///
+ /// The palette as JSON that reads back:
+ /// its name, mode, and each role set as {"rgb":"#88c0d0","slot":6}.
+ ///
+ public string ToJson()
+ {
+ using var stream = new MemoryStream();
+ using (var json = new Utf8JsonWriter(stream))
+ {
+ json.WriteStartObject();
+ json.WriteString("name", Name);
+ json.WriteString("mode", Mode == ThemeMode.Dark ? "dark" : "light");
+ json.WriteStartObject("colors");
+ foreach (var role in Enum.GetValues())
+ {
+ if (this[role] is not { } color) continue;
+ json.WriteStartObject(RoleName(role));
+ if (color.Rgb is { } rgb) json.WriteString("rgb", rgb.ToHex());
+ if (color.Slot is { } slot) json.WriteNumber("slot", slot);
+ json.WriteEndObject();
+ }
+ json.WriteEndObject();
+ if (Seed is { } seed)
+ {
+ json.WriteString("seed", seed.ToHex());
+ json.WriteString("harmony", Harmony.ToString().ToLowerInvariant());
+ if (Contrast > 0) json.WriteNumber("contrast", Contrast);
+ }
+ if (Look is not null)
+ {
+ json.WritePropertyName("look");
+ Look.Write(json);
+ }
+ json.WriteEndObject();
+ }
+ return System.Text.Encoding.UTF8.GetString(stream.ToArray());
+ }
+
+ ///
+ public bool Equals(ThemePalette? other) =>
+ other is not null && Name == other.Name && Mode == other.Mode && Seed == other.Seed && Harmony == other.Harmony
+ && Contrast.Equals(other.Contrast) && Colors.Count == other.Colors.Count
+ && Look == other.Look && Colors.All(pair => other.Colors.TryGetValue(pair.Key, out var color) && color == pair.Value);
+
+ ///
+ public override int GetHashCode()
+ {
+ var hash = new HashCode();
+ hash.Add(Name);
+ hash.Add(Mode);
+ hash.Add(Look);
+ hash.Add(Seed);
+ foreach (var pair in Colors.OrderBy(pair => pair.Key))
+ {
+ hash.Add(pair.Key);
+ hash.Add(pair.Value);
+ }
+ return hash.ToHashCode();
+ }
+
+ /// A role's name as JSON and help write it.
+ public static string RoleName(ThemeRole role) => role.ToString().ToLower(CultureInfo.InvariantCulture);
+}
diff --git a/docs/layout.md b/docs/layout.md
index 20a9f4e..a4b2fba 100644
--- a/docs/layout.md
+++ b/docs/layout.md
@@ -329,6 +329,7 @@ your own copy of its rules, on the page.
| `.Shaded(gradient, flow)` | its borders and text in the colours of a gradient |
| `.Colored(markup)` | a colour (or any layer) under the colour it sets itself |
| `.Themed(theme)` | a different look for everything inside it that sets none of its own |
+| `.ThemedUnder(theme)` | a look that fills in only what the theme around it leaves unset, as a game's default does under a reader's own |
```csharp
// One sheet, heavy frames and arrow bullets throughout, the title row shaded.
@@ -473,6 +474,72 @@ a `TextBlock` otherwise, so a builder that takes text as an argument nests a blo
`BlockLayout.Blocks(content)` splits a text into the blocks standing on lines of their own and the
text between them.
+### Themes and palettes
+
+A `LayoutTheme` sets the colour of each part as well as its characters: `BorderColor`, `TitleColor`,
+`HeadingColor`, `LabelColor`, `SeparatorColor`, `BulletColor`, `GuideColor`, `HeaderRuleColor`,
+`GaugeFilledColor`, `GaugeEmptyColor` and `StripeColor`. Each is a markup layer, so a title can be bold as well as
+coloured, and colour a piece sets itself still wins. Nothing is coloured by default.
+
+A `ThemePalette` names eleven colours by what they are for (`ThemeRole`: background, surface,
+foreground, primary, secondary, tertiary, muted, success, warning, error, info), and `ToTheme` maps
+them onto the parts: borders and gauge bars primary, titles secondary and bold, labels secondary,
+bullets tertiary, headings primary and bold, guides and separators muted, stripes on the surface. The
+ANSI package does the painting:
+
+```csharp
+var sheet = character.Bordered(MarkupText.Plain("Ann")).Themed(ThemePalette.Preset("nord")!.ToLayoutTheme());
+```
+
+Each palette colour is a `ThemeColor`: an exact colour, the standard colour (0-15) a sixteen-colour
+client is sent instead, or both. The standard colour is picked by kind (`ColorMath.StandardSlot`), so a
+pastel blue is blue on a sixteen-colour client rather than the grey nearest it by RGB. A palette of
+standard colours alone, `ThemePalette.Terminal`, shows each reader the game in their own client's
+colours.
+
+Four ways to make one:
+
+- **Presets**: `terminal`; one for each MSSP genre, `adult`, `fantasy`, `historical`, `horror`,
+ `modern`, `mystery`, `romance`, `science-fiction` and `spiritual` (`ThemePalette.Genres`), each with
+ a look of its own (below); and `catppuccin-mocha`, `catppuccin-latte`, `dracula`, `gruvbox-dark`, `nord`,
+ `solarized-dark`, `solarized-light`, `tokyo-night` (`ThemePalette.Preset(name)`).
+- **base16**: `ThemePalette.FromBase16(name, colors)` takes any of the hundreds of base16 schemes,
+ mapped by base16's own guide (`base0D` primary, `base03` muted, `base08` error, ...).
+- **From one colour**: `ThemePalette.Generate(seed, harmony, mode, contrast)`. The accents' hues come
+ from the seed's by `ThemeHarmony` (monochrome, analogous, complementary, split, triadic, tetradic),
+ and each is made lighter or darker, keeping its hue, until its WCAG contrast with the background
+ reaches 3:1 for lines and 4.5:1 for text; `contrast` from 0 to 1 raises both toward 7:1. Success,
+ warning, error and info stay green, amber, red and blue, turned a little toward the seed.
+- **JSON**: `ThemePalette.TryParse` reads a preset's name, or an object with one of `preset`,
+ `base16` or `seed` (with `harmony`, `contrast`), and `mode`, `name` and `colors` to set roles:
+ `{"preset":"nord","colors":{"primary":"#bf616a","muted":8}}`. `ToJson` writes one back.
+
+A theme is more than its colours. `ThemePalette.Look`, a `ThemeLook`, sets the shapes too: a border
+preset and the ornaments round a title (`"╡ ❖ "`, `" ❖ ╞"`), the tree guide, the bullet, a gauge's
+pieces, the field separator and the rule under table headings. In JSON it is `look`:
+`{"preset":"nord","look":{"border":"double","title":["╡ "," ╞"],"bullet":"❧","gauge":["[","█","░","]"]}}`.
+A look given with a preset changes only what it names; `"look":null` drops the preset's. A reader
+whose client has only ASCII gets the ASCII form of each piece.
+
+`palette.Check()` lists the roles whose contrast with the background is under what they need, and
+`ColorMath` has the pieces: `Contrast`, `WithContrast`, `ToOklch`/`FromOklch`, `Rotate`.
+
+In HTML a themed block writes its colours as custom properties (`--ms-border`, `--ms-title`,
+`--ms-label`, ...), which `LayoutCss` reads, so a page that sets them themes every layout on it. A
+fallback theme (`ThemedUnder`) writes the `-default` form, under what the page sets.
+
+### Striped rows
+
+A wide table is easier to read across when every second row has a background of its own. Set
+`Striped` on a `Table` or `Fields`, and every second row, all of its lines and the whole width, is laid
+on the theme's `StripeColor`. A cell's own background still wins. With no stripe colour nothing is
+coloured; in HTML the table or list gets `ms-striped` and `LayoutCss` uses `--ms-stripe`, a faint grey
+when unset. A table drawn as cards, and a reading-order layout, are not striped.
+
+```csharp
+var roster = new Table(columns, rows) { Striped = true }.Themed(ThemePalette.Preset("nord")!.ToLayoutTheme());
+```
+
### A block of your own
Derive from `Block` and draw. The context says whether the reader wants ASCII (`context.Glyph`