From 028212ca04225aeb838dc04a12557eb432ecb1e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 05:05:19 +0000 Subject: [PATCH 1/5] Themes that colour a layout's parts, and palettes to make them from LayoutTheme colours borders, titles, headings, labels, separators, bullets, guides, the header rule and gauge parts. ThemePalette names ten colours by role and maps them onto those parts; palettes come from presets, base16 schemes, JSON, or one seed colour with a harmony, each accent moved in lightness until it meets a WCAG contrast ratio. Every colour keeps a standard colour, picked by hue, for sixteen-colour clients. Themed.Fallback puts a game's default under a reader's theme. HTML writes the colours as --ms-* custom properties that LayoutCss reads. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FuCWyo4QX6dza8MxQQoZM --- CHANGELOG.md | 12 + MarkupString.Ansi/AnsiTheme.cs | 32 ++ MarkupString.Ansi/PublicAPI.Unshipped.txt | 3 + MarkupString.Html/Emitters/LayoutHtml.cs | 47 +- MarkupString.Html/LayoutCss.cs | 30 +- MarkupString.Tests/Layout/ThemeTests.cs | 235 +++++++++ MarkupString/ColorGradient.cs | 8 +- MarkupString/ColorMath.cs | 148 ++++++ MarkupString/Layout/Blocks/Block.cs | 101 +++- MarkupString/Layout/Blocks/BlockCodec.cs | 20 + MarkupString/Layout/Blocks/Bullets.cs | 2 +- MarkupString/Layout/Blocks/Fields.cs | 8 +- MarkupString/Layout/Blocks/Frame.cs | 14 +- MarkupString/Layout/Blocks/Gauge.cs | 8 +- MarkupString/Layout/Blocks/LayoutJson.cs | 3 +- MarkupString/Layout/Blocks/Modifiers.cs | 18 +- MarkupString/Layout/Blocks/Table.cs | 6 +- MarkupString/Layout/Blocks/Tree.cs | 2 +- MarkupString/PublicAPI.Unshipped.txt | 137 +++++ MarkupString/ThemePalette.cs | 580 ++++++++++++++++++++++ docs/layout.md | 45 ++ 21 files changed, 1418 insertions(+), 41 deletions(-) create mode 100644 MarkupString.Ansi/AnsiTheme.cs create mode 100644 MarkupString.Tests/Layout/ThemeTests.cs create mode 100644 MarkupString/ColorMath.cs create mode 100644 MarkupString/ThemePalette.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c33157..c3832a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **Themes with colour.** `LayoutTheme` colours each part of a layout (`BorderColor`, `TitleColor`, + `HeadingColor`, `LabelColor`, `SeparatorColor`, `BulletColor`, `GuideColor`, `HeaderRuleColor`, + `GaugeFilledColor`, `GaugeEmptyColor`), kept through the serializer and written in HTML as custom + properties (`--ms-border`, ...) that `LayoutCss` reads. `ThemePalette` names ten colours by role and + maps them onto the parts (`ToTheme`; `AnsiTheme.ToLayoutTheme` in the ANSI package). Palettes come + from presets (`terminal`, catppuccin, dracula, gruvbox, nord, solarized, tokyo-night), from base16 + schemes, from JSON, or from one colour (`ThemePalette.Generate`): accent hues by harmony + (monochrome, analogous, complementary, split, triadic, tetradic), each made lighter or darker until + it meets a WCAG contrast ratio against the background. Every colour carries the standard colour a + sixteen-colour client gets instead, picked by hue rather than RGB distance (`ColorMath.StandardSlot`). + `ColorMath` has contrast, OKLCH conversion and hue rotation. `Themed.Fallback` (`ThemedUnder`) puts a + theme under the one it is drawn in, for a game's default under a reader's own. - **Gradients fall back on sixteen-colour terminals.** A gradient-painted character carries the layer of the stop nearest it, and `AnsiStyle.StandardForeground` keeps that stop's colour, so a client with only the standard colours gets bands of the stops instead of each shade's nearest standard colour. diff --git a/MarkupString.Ansi/AnsiTheme.cs b/MarkupString.Ansi/AnsiTheme.cs new file mode 100644 index 0000000..07dcc2b --- /dev/null +++ b/MarkupString.Ansi/AnsiTheme.cs @@ -0,0 +1,32 @@ +using MarkupString.Layout; + +namespace MarkupString.Ansi; + +/// Theme colours as terminal colour. +public static class AnsiTheme +{ + /// + /// as an ANSI layer: its exact colour, with its standard colour kept for a + /// client that has only the sixteen (); or the standard colour + /// alone, which each client shows in its own palette. + /// + /// The colour. + /// Whether the text is bold as well. + public static AnsiMarkup Paint(ThemeColor color, bool bold = false) + { + AnsiColor? standard = color.Slot is { } slot ? new AnsiColor.Standard((byte)(slot % 8), slot >= 8) : null; + return new AnsiMarkup(new AnsiStyle + { + Foreground = color.Rgb is { } rgb ? new AnsiColor.Rgb(rgb.R, rgb.G, rgb.B) : standard, + StandardForeground = color.Rgb is not null ? standard : null, + Bold = bold, + }); + } + + /// as a layout's part colours, in ANSI (). + public static LayoutTheme ToLayoutTheme(this ThemePalette palette) + { + ArgumentNullException.ThrowIfNull(palette); + return palette.ToTheme((color, bold) => Paint(color, bold)); + } +} diff --git a/MarkupString.Ansi/PublicAPI.Unshipped.txt b/MarkupString.Ansi/PublicAPI.Unshipped.txt index 50a6099..57a9568 100644 --- a/MarkupString.Ansi/PublicAPI.Unshipped.txt +++ b/MarkupString.Ansi/PublicAPI.Unshipped.txt @@ -26,3 +26,6 @@ MarkupString.Ansi.AnsiStyle.AtDepth(MarkupString.Ansi.AnsiColorDepth depth) -> M static MarkupString.Ansi.AnsiCodeWriter.Write(in MarkupString.Ansi.AnsiStyle style) -> string! static MarkupString.Ansi.AnsiCodeWriter.WriteColor(MarkupString.Ansi.AnsiColor? color, bool background) -> string! static MarkupString.Ansi.AnsiRegistration.WithAnsiOutput(this MarkupString.MarkupRegistry! registry, MarkupString.Ansi.AnsiColorDepth colorDepth, bool hyperlinks = true) -> MarkupString.MarkupRegistry! +MarkupString.Ansi.AnsiTheme +static MarkupString.Ansi.AnsiTheme.Paint(MarkupString.ThemeColor color, bool bold = false) -> MarkupString.Ansi.AnsiMarkup! +static MarkupString.Ansi.AnsiTheme.ToLayoutTheme(this MarkupString.ThemePalette! palette) -> MarkupString.Layout.LayoutTheme! diff --git a/MarkupString.Html/Emitters/LayoutHtml.cs b/MarkupString.Html/Emitters/LayoutHtml.cs index 4b902cc..99b0272 100644 --- a/MarkupString.Html/Emitters/LayoutHtml.cs +++ b/MarkupString.Html/Emitters/LayoutHtml.cs @@ -47,7 +47,7 @@ private static KeyValuePair> Of(BlockHt }), Of(Shaded), Of(Colored), - Of((themed, html) => html.Block(themed.Content, html.Context with { Theme = themed.Theme.Over(html.Context.Theme) })), + Of(Themed), ]; private static void Text(TextBlock text, HtmlLayoutWriter html) @@ -480,6 +480,51 @@ private static void Shaded(Shaded shaded, HtmlLayoutWriter html) html.Write(""); } + /// The CSS custom property each part's colour is written to, and read from by . + private static readonly (string Property, Func Part)[] ThemeProperties = + [ + ("--ms-border", theme => theme.BorderColor), + ("--ms-title", theme => theme.TitleColor), + ("--ms-heading", theme => theme.HeadingColor), + ("--ms-label", theme => theme.LabelColor), + ("--ms-separator", theme => theme.SeparatorColor), + ("--ms-bullet", theme => theme.BulletColor), + ("--ms-guide", theme => theme.GuideColor), + ("--ms-header-rule", theme => theme.HeaderRuleColor), + ("--ms-gauge", theme => theme.GaugeFilledColor), + ("--ms-gauge-empty", theme => theme.GaugeEmptyColor), + ]; + + /// + /// A themed block. The colours it sets are written as custom properties, so a page can set the same + /// ones to theme every layout; a fallback theme writes them as -default, under any the page sets. + /// + private static void Themed(Themed themed, HtmlLayoutWriter html) + { + var colors = ThemeProperties + .Select(entry => (entry.Property, Color: entry.Part(themed.Theme) is IColorMarkup { Foreground: { } rgb } ? rgb.ToHex() : null)) + .Where(entry => entry.Color is not null) + .ToArray(); + if (colors.Length == 0) + { + html.Block(themed.Content, html.Context with { Theme = themed.Within(html.Context.Theme) }); + return; + } + + html.Write("
"); + html.Block(themed.Content, html.Context with { Theme = themed.Within(html.Context.Theme) }); + html.Write("
"); + } + /// A coloured block: a colour as the block's own, any other layer opened round it as it renders. private static void Colored(Colored colored, HtmlLayoutWriter html) { diff --git a/MarkupString.Html/LayoutCss.cs b/MarkupString.Html/LayoutCss.cs index 8f4bc0e..220ee7d 100644 --- a/MarkupString.Html/LayoutCss.cs +++ b/MarkupString.Html/LayoutCss.cs @@ -5,7 +5,11 @@ namespace MarkupString.Html; /// A page that shows rendered layouts includes it, or carries its own copy of these rules. /// /// -/// Borders and rules take currentColor, so they follow the colour of the text around them. +/// Borders and rules take currentColor, so they follow the colour of the text around them, unless +/// a theme sets a part's colour: --ms-border, --ms-title, --ms-heading, --ms-label, +/// --ms-separator, --ms-bullet, --ms-guide, --ms-header-rule, --ms-gauge. A page +/// sets these to theme every layout on it; a layout's own theme sets them on its block, and a game's +/// default theme sets the -default form of each, under what the page sets. /// Everything is sized in ch, the width of a cell in the terminal font the layout was laid out /// in, and every row wraps, so nothing scrolls sideways on a narrow page. /// @@ -15,25 +19,26 @@ public static class LayoutCss public static readonly string Fixed = ".ms-layout { display: block; max-width: 100%; white-space: normal; }\n" + ".ms-text { white-space: pre-wrap; overflow-wrap: anywhere; }\n" + - ".ms-box { margin: 0; padding: 0 1ch; min-width: 0; border: 1px solid currentColor; }\n" + + ".ms-box { margin: 0; padding: 0 1ch; min-width: 0; border: 1px solid var(--ms-border, var(--ms-border-default, currentColor)); }\n" + ".ms-box.ms-border-none { border: none; padding: 0; }\n" + - ".ms-box.ms-border-double { border: 3px double currentColor; }\n" + + ".ms-box.ms-border-double { border: 3px double var(--ms-border, var(--ms-border-default, currentColor)); }\n" + ".ms-box.ms-border-heavy { border-width: 2px; }\n" + ".ms-box.ms-border-rounded { border-radius: 0.5em; }\n" + ".ms-box-title { padding: 0 1ch; margin-inline: auto; }\n" + + ".ms-box-title, .ms-rule-title { color: var(--ms-title, var(--ms-title-default, inherit)); }\n" + ".ms-box-title[data-align=\"left\"] { margin-inline: 0 auto; }\n" + ".ms-box-title[data-align=\"right\"] { margin-inline: auto 0; }\n" + ".ms-rule, .ms-divider { display: flex; align-items: center; gap: 1ch; }\n" + - ".ms-rule::before, .ms-rule::after, .ms-divider::before, .ms-divider::after { content: \"\"; flex: 1 1 0; border-top: 1px solid currentColor; }\n" + + ".ms-rule::before, .ms-rule::after, .ms-divider::before, .ms-divider::after { content: \"\"; flex: 1 1 0; border-top: 1px solid var(--ms-border, var(--ms-border-default, currentColor)); }\n" + ".ms-rule[data-align=\"left\"]::before, .ms-divider[data-align=\"left\"]::before { flex: 0 0 1ch; }\n" + ".ms-rule[data-align=\"right\"]::after, .ms-divider[data-align=\"right\"]::after { flex: 0 0 1ch; }\n" + ".ms-divider { margin: 0 -1ch; }\n" + - ".ms-border-double.ms-rule::before, .ms-border-double.ms-rule::after { border-top: 3px double currentColor; }\n" + + ".ms-border-double.ms-rule::before, .ms-border-double.ms-rule::after { border-top: 3px double var(--ms-border, var(--ms-border-default, currentColor)); }\n" + ".ms-border-none.ms-rule::before, .ms-border-none.ms-rule::after { border-top: none; }\n" + ".ms-flex { display: flex; flex-wrap: wrap; }\n" + ".ms-flex.ms-vertical { flex-direction: column; }\n" + ".ms-item { min-width: 0; }\n" + - ".ms-flex.ms-divided > .ms-item + .ms-item { border-left: 1px solid currentColor; padding-left: 1ch; }\n" + + ".ms-flex.ms-divided > .ms-item + .ms-item { border-left: 1px solid var(--ms-separator, var(--ms-separator-default, currentColor)); padding-left: 1ch; }\n" + ".ms-figure { display: flow-root; }\n" + ".ms-figure-image { max-width: 100%; height: auto; }\n" + ".ms-figure-art { margin: 0; font: inherit; white-space: pre; }\n" + @@ -41,25 +46,26 @@ public static class LayoutCss ".ms-float-right > .ms-figure-image, .ms-float-right > .ms-figure-art { float: right; max-width: 50%; margin: 0 0 0.5em 2ch; }\n" + ".ms-fields { display: grid; grid-template-columns: minmax(min-content, max-content) minmax(0, 1fr); column-gap: 1ch; margin: 0; }\n" + ".ms-field { display: contents; }\n" + - ".ms-field > dt { grid-column: 1; white-space: pre-wrap; overflow-wrap: anywhere; }\n" + + ".ms-field > dt { grid-column: 1; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ms-label, var(--ms-label-default, inherit)); }\n" + ".ms-field > dd { grid-column: 2; margin: 0; min-width: 0; }\n" + ".ms-fields.ms-label-right > .ms-field > dt { text-align: right; }\n" + ".ms-fields.ms-leader > .ms-field > dt { display: flex; gap: 0.5ch; }\n" + ".ms-fields.ms-leader > .ms-field > dt::after { content: \"\"; flex: 1 0 2ch; border-bottom: 1px dotted currentColor; margin-bottom: 0.3em; }\n" + ".ms-tree, .ms-tree ul { list-style: none; margin: 0; padding: 0; }\n" + ".ms-tree ul { margin-left: 1ch; }\n" + - ".ms-tree ul > li { position: relative; padding-left: 3ch; border-left: 1px solid currentColor; }\n" + + ".ms-tree ul > li { position: relative; padding-left: 3ch; border-left: 1px solid var(--ms-guide, var(--ms-guide-default, currentColor)); }\n" + ".ms-tree ul > li:last-child { border-left-color: transparent; }\n" + - ".ms-tree ul > li::before { content: \"\"; position: absolute; left: -1px; top: 0; width: 2ch; height: 0.7em; border-left: 1px solid currentColor; border-bottom: 1px solid currentColor; }\n" + + ".ms-tree ul > li::before { content: \"\"; position: absolute; left: -1px; top: 0; width: 2ch; height: 0.7em; border-left: 1px solid var(--ms-guide, var(--ms-guide-default, currentColor)); border-bottom: 1px solid var(--ms-guide, var(--ms-guide-default, currentColor)); }\n" + ".ms-tree.ms-guide-rounded ul > li:last-child::before { border-bottom-left-radius: 0.4em; }\n" + ".ms-tree.ms-guide-heavy ul > li, .ms-tree.ms-guide-heavy ul > li::before { border-width: 2px; }\n" + ".ms-tree.ms-guide-double ul > li, .ms-tree.ms-guide-double ul > li::before { border-left-style: double; border-left-width: 3px; }\n" + ".ms-tree.ms-guide-none ul > li, .ms-tree.ms-guide-none ul > li::before { border-color: transparent; }\n" + ".ms-gauge { display: flex; align-items: center; gap: 1ch; }\n" + - ".ms-gauge > meter { flex: 1 1 8ch; min-width: 4ch; }\n" + - ".ms-gauge-bar { flex: 1 1 8ch; min-width: 4ch; height: 1em; display: flex; border: 1px solid currentColor; box-sizing: border-box; }\n" + + ".ms-gauge > meter { flex: 1 1 8ch; min-width: 4ch; accent-color: var(--ms-gauge, var(--ms-gauge-default, auto)); }\n" + + ".ms-gauge-bar { flex: 1 1 8ch; min-width: 4ch; height: 1em; display: flex; border: 1px solid var(--ms-border, var(--ms-border-default, currentColor)); box-sizing: border-box; }\n" + ".ms-gauge-fill { height: 100%; }\n" + ".ms-bullets { margin: 0; padding-left: 3ch; }\n" + + ".ms-bullets > li::marker, .ms-bullets .ms-marker { color: var(--ms-bullet, var(--ms-bullet-default, inherit)); }\n" + ".ms-bullet-dash { list-style-type: \"- \"; }\n" + ".ms-bullet-star { list-style-type: \"* \"; }\n" + ".ms-bullet-none, .ms-bullet-custom { list-style: none; }\n" + @@ -73,7 +79,7 @@ public static class LayoutCss ".ms-table-wrap { max-width: 100%; overflow-x: auto; }\n" + ".ms-table { border-collapse: collapse; }\n" + ".ms-table th, .ms-table td { padding: 0 1ch; vertical-align: top; text-align: left; }\n" + - ".ms-table th { border-bottom: 1px solid currentColor; }\n" + + ".ms-table th { border-bottom: 1px solid var(--ms-header-rule, var(--ms-header-rule-default, currentColor)); color: var(--ms-heading, var(--ms-heading-default, inherit)); }\n" + "@media (max-width: 48em) { .ms-table .ms-p3 { display: none; } }\n" + "@media (max-width: 32em) { .ms-table .ms-p2 { display: none; } }\n"; } diff --git a/MarkupString.Tests/Layout/ThemeTests.cs b/MarkupString.Tests/Layout/ThemeTests.cs new file mode 100644 index 0000000..2784724 --- /dev/null +++ b/MarkupString.Tests/Layout/ThemeTests.cs @@ -0,0 +1,235 @@ +using MarkupString.Ansi; +using MarkupString.Html; +using MarkupString.Layout; + +namespace MarkupString.Tests.Layout; + +/// Palettes, how they are made, and the colours they give a layout's parts. +public class ThemeTests +{ + private static readonly MarkupRegistry Registry = MarkupRegistry.Empty.WithAnsi().WithHtml(); + + private static MarkupText P(string text) => MarkupText.Plain(text); + + private static RgbColor Hex(string hex) => ColorMath.TryParseHex(hex, out var rgb) ? rgb : throw new ArgumentException(hex); + + [Test] + public async Task Contrast_IsWcags() + { + await Assert.That(ColorMath.Contrast(new RgbColor(0, 0, 0), new RgbColor(255, 255, 255))).IsEqualTo(21).Within(0.01); + await Assert.That(ColorMath.Contrast(Hex("#777777"), Hex("#ffffff"))).IsEqualTo(4.48).Within(0.01); + } + + [Test] + public async Task WithContrast_MovesOnlyAsFarAsItMust() + { + var background = Hex("#1e1e2e"); + var dim = Hex("#303060"); + var lifted = ColorMath.WithContrast(dim, background, 4.5); + + await Assert.That(ColorMath.Contrast(lifted, background)).IsBetween(4.5, 4.7); + await Assert.That(ColorMath.ToOklch(lifted).H).IsEqualTo(ColorMath.ToOklch(dim).H).Within(3); + await Assert.That(ColorMath.WithContrast(Hex("#ffffff"), background, 4.5)).IsEqualTo(Hex("#ffffff")); + } + + [Test] + [Arguments("#89b4fa", 12)] + [Arguments("#1e66f5", 4)] + [Arguments("#a6e3a1", 10)] + [Arguments("#f38ba8", 9)] + [Arguments("#fab387", 11)] + [Arguments("#94e2d5", 14)] + [Arguments("#cba6f7", 13)] + [Arguments("#45475a", 8)] + [Arguments("#11111b", 0)] + [Arguments("#cdd6f4", 15)] + [Arguments("#a9b1d6", 7)] + public async Task StandardSlot_GoesByKindNotDistance(string hex, int slot) + { + await Assert.That(ColorMath.StandardSlot(Hex(hex))).IsEqualTo(slot); + } + + [Test] + [MethodDataSource(nameof(Harmonies))] + public async Task Generate_MeetsItsContrastInBothModes(ThemeHarmony harmony, ThemeMode mode) + { + var palette = ThemePalette.Generate(Hex("#7aa2f7"), harmony, mode); + + await Assert.That(palette.Check()).IsEmpty(); + await Assert.That(palette.Mode).IsEqualTo(mode); + foreach (var role in Enum.GetValues()) await Assert.That(palette[role]).IsNotNull(); + } + + public static IEnumerable> Harmonies() => + from harmony in Enum.GetValues() + from mode in Enum.GetValues() + select (Func<(ThemeHarmony, ThemeMode)>)(() => (harmony, mode)); + + [Test] + public async Task Generate_FullContrastReachesSevenToOne() + { + var palette = ThemePalette.Generate(Hex("#5e81ac"), ThemeHarmony.Triadic, ThemeMode.Light, contrast: 1); + + foreach (var role in Enum.GetValues().Where(role => role != ThemeRole.Background)) + await Assert.That(ColorMath.Contrast(palette[role]!.Value.Resolved, palette.BackgroundColor)).IsGreaterThanOrEqualTo(7); + } + + [Test] + public async Task Generate_HarmonyPicksTheHues() + { + var palette = ThemePalette.Generate(Hex("#7aa2f7"), ThemeHarmony.Complementary); + var primary = ColorMath.ToOklch(palette[ThemeRole.Primary]!.Value.Rgb!.Value).H; + var secondary = ColorMath.ToOklch(palette[ThemeRole.Secondary]!.Value.Rgb!.Value).H; + + await Assert.That(Math.Abs(180 - Math.Abs(primary - secondary))).IsLessThan(8); + } + + [Test] + public async Task AGreySeed_GivesGreyAccents() + { + var palette = ThemePalette.Generate(Hex("#808080"), ThemeHarmony.Triadic); + + await Assert.That(ColorMath.ToOklch(palette[ThemeRole.Primary]!.Value.Rgb!.Value).C).IsLessThan(0.01); + await Assert.That(palette.Check()).IsEmpty(); + } + + [Test] + public async Task Base16_MapsByItsOwnGuide() + { + var nord = ThemePalette.Preset("NORD")!; + + await Assert.That(nord[ThemeRole.Primary]).IsEqualTo(new ThemeColor(Hex("#81a1c1"), 4)); + await Assert.That(nord[ThemeRole.Error]).IsEqualTo(new ThemeColor(Hex("#bf616a"), 1)); + await Assert.That(nord.Mode).IsEqualTo(ThemeMode.Dark); + await Assert.That(ThemePalette.Preset("solarized-light")!.Mode).IsEqualTo(ThemeMode.Light); + } + + [Test] + public async Task Json_ReadsEveryForm() + { + await Assert.That(ThemePalette.TryParse("nord", out var bare, out _)).IsTrue(); + await Assert.That(bare!.Name).IsEqualTo("nord"); + await Assert.That(ThemePalette.TryParse("\"dracula\"", out var quoted, out _)).IsTrue(); + await Assert.That(quoted!.Name).IsEqualTo("dracula"); + + await Assert.That(ThemePalette.TryParse("""{"seed":"#7aa2f7","harmony":"triadic","mode":"light","contrast":0.5}""", out var generated, out _)).IsTrue(); + await Assert.That(generated!.Mode).IsEqualTo(ThemeMode.Light); + await Assert.That(generated).IsEqualTo(ThemePalette.Generate(Hex("#7aa2f7"), ThemeHarmony.Triadic, ThemeMode.Light, 0.5)); + + await Assert.That(ThemePalette.TryParse("""{"preset":"nord","name":"mine","colors":{"primary":"#bf616a","muted":8,"info":null,"tertiary":{"rgb":"#ffffff","slot":11}}}""", out var edited, out _)).IsTrue(); + await Assert.That(edited!.Name).IsEqualTo("mine"); + await Assert.That(edited[ThemeRole.Primary]).IsEqualTo(new ThemeColor(Hex("#bf616a"), 1)); + await Assert.That(edited[ThemeRole.Muted]).IsEqualTo(ThemeColor.Standard(8)); + await Assert.That(edited[ThemeRole.Info]).IsNull(); + await Assert.That(edited[ThemeRole.Tertiary]).IsEqualTo(new ThemeColor(Hex("#ffffff"), 11)); + } + + [Test] + [Arguments("nowhere", "not JSON or a theme name")] + [Arguments("\"nowhere\"", "no theme named 'nowhere'")] + [Arguments("""{"seed":"blue"}""", "seed is a colour like #7aa2f7")] + [Arguments("""{"seed":"#123456","preset":"nord"}""", "use one of preset, base16 and seed")] + [Arguments("""{"colors":{"accent":"#fff"}}""", "'accent' is not a role; the roles are background, foreground, primary, secondary, tertiary, muted, success, warning, error, info")] + [Arguments("""{"colors":{"primary":16}}""", "'16' is not a colour: use #rrggbb, a standard colour 0 to 15, or {\"rgb\":..,\"slot\":..}")] + [Arguments("""{"border":"red"}""", "a theme has no 'border'")] + public async Task Json_SaysWhatIsWrong(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 Json_RoundTrips() + { + foreach (var palette in ThemePalette.Presets.Append(ThemePalette.Generate(Hex("#bb9af7"), ThemeHarmony.Split))) + { + await Assert.That(ThemePalette.TryParse(palette.ToJson(), out var read, out _)).IsTrue(); + await Assert.That(read).IsEqualTo(palette); + } + } + + [Test] + public async Task ThePalette_ColoursTheParts() + { + var theme = ThemePalette.Preset("nord")!.ToLayoutTheme(); + var box = new Stack([new Bullets([P("one")]), new Fields([new Field(P("Name"), (Block)P("Ann"))])]).Bordered(P("Sheet")); + var ansi = BlockLayout.Build(box.Themed(theme), 20).Render(MarkupFormat.Ansi, Registry); + + await Assert.That(ansi).Contains("\u001b[38;2;129;161;193m┌"); + await Assert.That(ansi).Contains("\u001b[1;38;2;180;142;173mSheet"); + await Assert.That(ansi).Contains("\u001b[38;2;136;192;208m•"); + await Assert.That(ansi).Contains("\u001b[38;2;180;142;173mName"); + await Assert.That(BlockLayout.Build(box.Themed(theme), 20).ToPlainText()).IsEqualTo(BlockLayout.Build(box, 20).ToPlainText()); + } + + [Test] + public async Task ASixteenColourClient_GetsTheSlot() + { + var theme = ThemePalette.Preset("catppuccin-mocha")!.ToLayoutTheme(); + var sixteen = MarkupRegistry.Empty.WithAnsiOutput(AnsiColorDepth.Standard); + var output = BlockLayout.Build(P("hi").ToBlock().Bordered().Themed(theme), 6).Render(MarkupFormat.Ansi, sixteen); + + // Blue (34), where catppuccin's pastel blue is nearest a grey by distance. + await Assert.That(output).Contains("\u001b[34m┌"); + } + + [Test] + public async Task TheTerminalPalette_SendsStandardColoursOnly() + { + var output = BlockLayout.Build(P("hi").ToBlock().Bordered(P("T")).Themed(ThemePalette.Terminal.ToLayoutTheme()), 10).Render(MarkupFormat.Ansi, Registry); + + await Assert.That(output).Contains("\u001b[36m┌"); + await Assert.That(output).DoesNotContain("38;2"); + } + + [Test] + public async Task ColourAPieceSetsItself_Wins() + { + var red = AnsiMarkup.Create(foreground: new AnsiColor.Rgb(255, 0, 0)); + var border = BorderStyle.Single with { TopLeft = MarkupText.Wrap(red, "┌") }; + var theme = new LayoutTheme { BorderColor = AnsiMarkup.Create(foreground: new AnsiColor.Rgb(0, 0, 255)) }; + var output = BlockLayout.Build(P("hi").ToBlock().Bordered(border: border).Themed(theme), 6).Render(MarkupFormat.Ansi, Registry); + + await Assert.That(output).Contains("\u001b[38;2;255;0;0m┌"); + await Assert.That(output).Contains("\u001b[38;2;0;0;255m─"); + } + + [Test] + public async Task AFallbackTheme_GivesWayToTheReaders() + { + var blue = AnsiMarkup.Create(foreground: new AnsiColor.Rgb(0, 0, 255)); + var green = AnsiMarkup.Create(foreground: new AnsiColor.Rgb(0, 255, 0)); + var house = new LayoutTheme { BorderColor = blue, Border = BorderStyle.Ascii }; + var reader = LayoutContext.Default with { Theme = new LayoutTheme { BorderColor = green } }; + var box = P("hi").ToBlock().Bordered(); + + var under = BlockLayout.Build(box.ThemedUnder(house), 6, context: reader).Render(MarkupFormat.Ansi, Registry); + var over = BlockLayout.Build(box.Themed(house), 6, context: reader).Render(MarkupFormat.Ansi, Registry); + + await Assert.That(under).Contains("\u001b[38;2;0;255;0m+"); + await Assert.That(over).Contains("\u001b[38;2;0;0;255m+"); + } + + [Test] + public async Task ThemeColours_SurviveTheSerializerAndRelayout() + { + var theme = ThemePalette.Preset("gruvbox-dark")!.ToLayoutTheme(); + var text = BlockLayout.Build(new Tree([new TreeItem(P("root"), [new TreeItem(P("leaf"))])]).Bordered(P("T")).ThemedUnder(theme), 20, fluid: true); + var read = MarkupTextSerializer.Deserialize(MarkupTextSerializer.Serialize(text, Registry), Registry); + var relaid = BlockLayout.Relayout(read, 30, LayoutContext.Default); + + await Assert.That(relaid.Render(MarkupFormat.Ansi, Registry)).IsEqualTo(BlockLayout.Build(new Tree([new TreeItem(P("root"), [new TreeItem(P("leaf"))])]).Bordered(P("T")).ThemedUnder(theme), 30, fluid: true).Render(MarkupFormat.Ansi, Registry)); + await Assert.That(relaid.Render(MarkupFormat.Ansi, Registry)).Contains("\u001b[38;2;102;92;84m└─"); + } + + [Test] + public async Task Html_WritesThePartsAsCustomProperties() + { + var theme = ThemePalette.Preset("nord")!.ToLayoutTheme(); + var box = P("hi").ToBlock().Bordered(); + + await Assert.That(BlockLayout.Build(box.Themed(theme), 10).Render(MarkupFormat.Html, Registry)).Contains("
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 +316,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 +343,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..cdc9a30 100644 --- a/MarkupString/Layout/Blocks/Block.cs +++ b/MarkupString/Layout/Blocks/Block.cs @@ -123,14 +123,61 @@ 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; + } + + /// 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 +227,42 @@ 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; } + + /// 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; + /// This theme over : what this sets, and what it leaves unset from there. public LayoutTheme Over(LayoutTheme below) { @@ -195,6 +278,16 @@ 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, }; } diff --git a/MarkupString/Layout/Blocks/BlockCodec.cs b/MarkupString/Layout/Blocks/BlockCodec.cs index a446b19..a60d8fb 100644 --- a/MarkupString/Layout/Blocks/BlockCodec.cs +++ b/MarkupString/Layout/Blocks/BlockCodec.cs @@ -229,6 +229,16 @@ 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); _json.WriteEndObject(); } @@ -420,6 +430,16 @@ 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"), }; } 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..8ea6564 100644 --- a/MarkupString/Layout/Blocks/Fields.cs +++ b/MarkupString/Layout/Blocks/Fields.cs @@ -60,7 +60,7 @@ 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)) @@ -70,7 +70,7 @@ public override void Draw(LayoutContext context, int width, IList li foreach (var field in Items) { if (field.Label.Length > 0) - lines.AddRange(MarkupText.Concat([field.Label, separator.Trim(TrimType.TrimEnd)]).FormatColumn(BlockText.Column(width, Alignment.Left))); + 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(MarkupText.Concat([indent, line])); } return; @@ -88,13 +88,13 @@ public override void Draw(LayoutContext context, int width, IList li 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)); } 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..6fc843c 100644 --- a/MarkupString/Layout/Blocks/LayoutJson.cs +++ b/MarkupString/Layout/Blocks/LayoutJson.cs @@ -260,8 +260,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 4bee777..aa7deec 100644 --- a/MarkupString/Layout/Blocks/Table.cs +++ b/MarkupString/Layout/Blocks/Table.cs @@ -38,14 +38,14 @@ public override void Draw(LayoutContext context, int width, IList li } var shown = Enumerable.Range(0, Columns.Length).Where(c => widths[c] > 0).ToArray(); - 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); var tableWidth = shown.Sum(c => widths[c]) + divider.DisplayWidth * (shown.Length - 1); 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]; foreach (var row in AllRows) 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..0676c86 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -752,3 +752,140 @@ 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.ToTheme(System.Func! paint) -> MarkupString.Layout.LayoutTheme! +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 +MarkupString.ThemeRole.Error = 8 -> MarkupString.ThemeRole +MarkupString.ThemeRole.Foreground = 1 -> MarkupString.ThemeRole +MarkupString.ThemeRole.Info = 9 -> MarkupString.ThemeRole +MarkupString.ThemeRole.Muted = 5 -> MarkupString.ThemeRole +MarkupString.ThemeRole.Primary = 2 -> MarkupString.ThemeRole +MarkupString.ThemeRole.Secondary = 3 -> MarkupString.ThemeRole +MarkupString.ThemeRole.Success = 6 -> MarkupString.ThemeRole +MarkupString.ThemeRole.Tertiary = 4 -> MarkupString.ThemeRole +MarkupString.ThemeRole.Warning = 7 -> 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 diff --git a/MarkupString/ThemePalette.cs b/MarkupString/ThemePalette.cs new file mode 100644 index 0000000..d807ffa --- /dev/null +++ b/MarkupString/ThemePalette.cs @@ -0,0 +1,580 @@ +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, + + /// 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 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 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 => 1, + ThemeRole.Primary or ThemeRole.Muted => 3, + _ => 4.5, + }; + + /// The roles set whose contrast with the background is under . + public IReadOnlyList Check() + { + var background = BackgroundColor; + var shortfalls = new List(); + foreach (var role in Enum.GetValues()) + { + if (role == ThemeRole.Background || this[role] is not { } 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, and guides, separators, the rule under headings and a gauge's empty part muted. + /// + /// Makes the markup a format draws a colour with, bold or not: an ANSI colour, say. + public LayoutTheme ToTheme(Func paint) + { + ArgumentNullException.ThrowIfNull(paint); + IMarkup? Part(ThemeRole role, bool bold = false) => this[role] is { } color ? paint(color, bold) : null; + return new LayoutTheme + { + BorderColor = Part(ThemeRole.Primary), + TitleColor = Part(ThemeRole.Secondary, bold: true), + HeadingColor = Part(ThemeRole.Primary, bold: true), + 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), + }; + } + + /// + /// 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), + }), + }; + + /// Every preset: and well-known schemes. + public static IReadOnlyList Presets { get; } = + [ + Terminal, + 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.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 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", + Mode = mode, + Colors = ImmutableDictionary.CreateRange(new Dictionary + { + [ThemeRole.Background] = new(background, dark ? 0 : 15), + [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 with { Mode = 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("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") 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(); + 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 && Colors.Count == other.Colors.Count + && 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); + 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..beb245f 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,50 @@ 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` and `GaugeEmptyColor`. 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 ten colours by what they are for (`ThemeRole`: background, 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. 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`, `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. + +`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. + ### A block of your own Derive from `Block` and draw. The context says whether the reader wants ASCII (`context.Glyph` From de7b43cef938bc061ef702a016bfead11c0ef7a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 05:15:48 +0000 Subject: [PATCH 2/5] Leave standard colours out of a palette's contrast check A standard colour looks however the reader's client makes it, so a ratio against the VGA value says nothing about what the reader sees. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FuCWyo4QX6dza8MxQQoZM --- MarkupString.Tests/Layout/ThemeTests.cs | 7 +++++++ MarkupString/ThemePalette.cs | 8 ++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/MarkupString.Tests/Layout/ThemeTests.cs b/MarkupString.Tests/Layout/ThemeTests.cs index 2784724..04d0c35 100644 --- a/MarkupString.Tests/Layout/ThemeTests.cs +++ b/MarkupString.Tests/Layout/ThemeTests.cs @@ -93,6 +93,13 @@ public async Task AGreySeed_GivesGreyAccents() await Assert.That(palette.Check()).IsEmpty(); } + [Test] + public async Task Check_LeavesStandardColoursToTheClient() + { + await Assert.That(ThemePalette.Terminal.Check()).IsEmpty(); + await Assert.That(ThemePalette.Preset("nord")!.Check().Select(shortfall => shortfall.Role)).Contains(ThemeRole.Muted); + } + [Test] public async Task Base16_MapsByItsOwnGuide() { diff --git a/MarkupString/ThemePalette.cs b/MarkupString/ThemePalette.cs index d807ffa..c63bc52 100644 --- a/MarkupString/ThemePalette.cs +++ b/MarkupString/ThemePalette.cs @@ -140,14 +140,18 @@ public ThemePalette With(ThemeRole role, ThemeColor? color) => _ => 4.5, }; - /// The roles set whose contrast with the background is under . + /// + /// 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. + /// 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 == ThemeRole.Background || this[role] is not { } color) continue; + if (role == ThemeRole.Background || 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))); } From 6a63f6db7276525034e7e63a94bbf05f9d50e876 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 05:35:50 +0000 Subject: [PATCH 3/5] Striped tables and field lists, on a palette's surface colour Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FuCWyo4QX6dza8MxQQoZM --- CHANGELOG.md | 6 ++ MarkupString.Ansi/AnsiMarkup.cs | 4 + MarkupString.Ansi/AnsiTheme.cs | 17 ++-- MarkupString.Ansi/PublicAPI.Unshipped.txt | 3 +- MarkupString.Html/Emitters/LayoutHtml.cs | 14 ++- MarkupString.Html/LayoutCss.cs | 7 +- MarkupString.Tests/Layout/ThemeTests.cs | 114 +++++++++++++++++++++- MarkupString/ColorGradient.cs | 3 + MarkupString/Layout/Blocks/Block.cs | 21 +++- MarkupString/Layout/Blocks/BlockCodec.cs | 2 + MarkupString/Layout/Blocks/Fields.cs | 18 +++- MarkupString/Layout/Blocks/LayoutJson.cs | 4 + MarkupString/Layout/Blocks/Table.cs | 10 ++ MarkupString/PublicAPI.Unshipped.txt | 32 ++++-- MarkupString/ThemePalette.cs | 40 ++++++-- docs/layout.md | 23 ++++- 16 files changed, 277 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3832a6..ccbdda6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). sixteen-colour client gets instead, picked by hue rather than RGB distance (`ColorMath.StandardSlot`). `ColorMath` has contrast, OKLCH conversion and hue rotation. `Themed.Fallback` (`ThemedUnder`) puts a theme under the one it is drawn in, for a game's default under a reader's own. +- **Striped rows.** `Table.Striped` and `Fields.Striped` lay every second row, all its lines and the + full width, on the theme's `StripeColor`, a background; a cell's own background still wins. Palettes + gained a `surface` role for it (base16's `base01`, or a step off the background when generated), and + `ToTheme` now asks for each colour as text, bold text or a background (`ThemePaint`). In HTML the + table and list get `ms-striped` and the colour is `--ms-stripe`, a faint grey when unset. + `IColorMarkup.Background` reports a layer's background. - **Gradients fall back on sixteen-colour terminals.** A gradient-painted character carries the layer of the stop nearest it, and `AnsiStyle.StandardForeground` keeps that stop's colour, so a client with only the standard colours gets bands of the stops instead of each shade's nearest standard colour. diff --git a/MarkupString.Ansi/AnsiMarkup.cs b/MarkupString.Ansi/AnsiMarkup.cs index 0afaf15..d5276c0 100644 --- a/MarkupString.Ansi/AnsiMarkup.cs +++ b/MarkupString.Ansi/AnsiMarkup.cs @@ -10,6 +10,10 @@ public sealed record AnsiMarkup(AnsiStyle Style) : IColorMarkup, IAnsiStyleSourc /// A palette colour is resolved to its usual RGB value; the terminal's default has none. public RgbColor? Foreground => Style.Foreground?.ToRgb() is { } rgb ? new RgbColor(rgb.R, rgb.G, rgb.B) : null; + /// + /// A palette colour is resolved to its usual RGB value; the terminal's default has none. + public RgbColor? Background => Style.Background?.ToRgb() is { } rgb ? new RgbColor(rgb.R, rgb.G, rgb.B) : null; + /// /// /// Everything else about the style is kept, and the colour this layer had is what a client with only diff --git a/MarkupString.Ansi/AnsiTheme.cs b/MarkupString.Ansi/AnsiTheme.cs index 07dcc2b..b0502e0 100644 --- a/MarkupString.Ansi/AnsiTheme.cs +++ b/MarkupString.Ansi/AnsiTheme.cs @@ -8,18 +8,21 @@ public static class AnsiTheme /// /// as an ANSI layer: its exact colour, with its standard colour kept for a /// client that has only the sixteen (); or the standard colour - /// alone, which each client shows in its own palette. + /// alone, which each client shows in its own palette. As a background, the exact colour alone, which a + /// sixteen-colour client is sent the nearest of. /// /// The colour. - /// Whether the text is bold as well. - public static AnsiMarkup Paint(ThemeColor color, bool bold = false) + /// As text, bold text or a background. + public static AnsiMarkup Paint(ThemeColor color, ThemePaint paint = ThemePaint.Text) { AnsiColor? standard = color.Slot is { } slot ? new AnsiColor.Standard((byte)(slot % 8), slot >= 8) : null; + AnsiColor? exact = color.Rgb is { } rgb ? new AnsiColor.Rgb(rgb.R, rgb.G, rgb.B) : null; + if (paint == ThemePaint.Background) return new AnsiMarkup(new AnsiStyle { Background = exact ?? standard }); return new AnsiMarkup(new AnsiStyle { - Foreground = color.Rgb is { } rgb ? new AnsiColor.Rgb(rgb.R, rgb.G, rgb.B) : standard, - StandardForeground = color.Rgb is not null ? standard : null, - Bold = bold, + Foreground = exact ?? standard, + StandardForeground = exact is not null ? standard : null, + Bold = paint == ThemePaint.Bold, }); } @@ -27,6 +30,6 @@ public static AnsiMarkup Paint(ThemeColor color, bool bold = false) public static LayoutTheme ToLayoutTheme(this ThemePalette palette) { ArgumentNullException.ThrowIfNull(palette); - return palette.ToTheme((color, bold) => Paint(color, bold)); + return palette.ToTheme(Paint); } } diff --git a/MarkupString.Ansi/PublicAPI.Unshipped.txt b/MarkupString.Ansi/PublicAPI.Unshipped.txt index 57a9568..62ce389 100644 --- a/MarkupString.Ansi/PublicAPI.Unshipped.txt +++ b/MarkupString.Ansi/PublicAPI.Unshipped.txt @@ -27,5 +27,6 @@ static MarkupString.Ansi.AnsiCodeWriter.Write(in MarkupString.Ansi.AnsiStyle sty static MarkupString.Ansi.AnsiCodeWriter.WriteColor(MarkupString.Ansi.AnsiColor? color, bool background) -> string! static MarkupString.Ansi.AnsiRegistration.WithAnsiOutput(this MarkupString.MarkupRegistry! registry, MarkupString.Ansi.AnsiColorDepth colorDepth, bool hyperlinks = true) -> MarkupString.MarkupRegistry! MarkupString.Ansi.AnsiTheme -static MarkupString.Ansi.AnsiTheme.Paint(MarkupString.ThemeColor color, bool bold = false) -> MarkupString.Ansi.AnsiMarkup! static MarkupString.Ansi.AnsiTheme.ToLayoutTheme(this MarkupString.ThemePalette! palette) -> MarkupString.Layout.LayoutTheme! +MarkupString.Ansi.AnsiMarkup.Background.get -> MarkupString.RgbColor? +static MarkupString.Ansi.AnsiTheme.Paint(MarkupString.ThemeColor color, MarkupString.ThemePaint paint = MarkupString.ThemePaint.Text) -> MarkupString.Ansi.AnsiMarkup! diff --git a/MarkupString.Html/Emitters/LayoutHtml.cs b/MarkupString.Html/Emitters/LayoutHtml.cs index 99b0272..ea99342 100644 --- a/MarkupString.Html/Emitters/LayoutHtml.cs +++ b/MarkupString.Html/Emitters/LayoutHtml.cs @@ -237,6 +237,7 @@ private static void FieldList(ReadOnlySpan items, Fields fields, MarkupTe html.Write("
"); var head = separator.Trim(TrimType.TrimEnd); foreach (var field in items) @@ -411,7 +412,9 @@ private static void Grid(Grid grid, HtmlLayoutWriter html) private static void Table(Table table, HtmlLayoutWriter html) { var columns = table.Columns.IsDefault ? [] : table.Columns; - html.Write("
"); + html.Write("
"); foreach (var column in columns) { html.Write("
Part)[] Th ("--ms-header-rule", theme => theme.HeaderRuleColor), ("--ms-gauge", theme => theme.GaugeFilledColor), ("--ms-gauge-empty", theme => theme.GaugeEmptyColor), + ("--ms-stripe", theme => theme.StripeColor), ]; /// @@ -502,7 +506,13 @@ private static readonly (string Property, Func Part)[] Th private static void Themed(Themed themed, HtmlLayoutWriter html) { var colors = ThemeProperties - .Select(entry => (entry.Property, Color: entry.Part(themed.Theme) is IColorMarkup { Foreground: { } rgb } ? rgb.ToHex() : null)) + .Select(entry => (entry.Property, Color: (entry.Part(themed.Theme) as IColorMarkup) switch + { + // The stripe is a background; every other part is a colour of text or lines. + { Background: { } back } when entry.Property == "--ms-stripe" => back.ToHex(), + { Foreground: { } fore } when entry.Property != "--ms-stripe" => fore.ToHex(), + _ => null, + })) .Where(entry => entry.Color is not null) .ToArray(); if (colors.Length == 0) diff --git a/MarkupString.Html/LayoutCss.cs b/MarkupString.Html/LayoutCss.cs index 220ee7d..c191e26 100644 --- a/MarkupString.Html/LayoutCss.cs +++ b/MarkupString.Html/LayoutCss.cs @@ -7,7 +7,8 @@ namespace MarkupString.Html; /// /// Borders and rules take currentColor, so they follow the colour of the text around them, unless /// a theme sets a part's colour: --ms-border, --ms-title, --ms-heading, --ms-label, -/// --ms-separator, --ms-bullet, --ms-guide, --ms-header-rule, --ms-gauge. A page +/// --ms-separator, --ms-bullet, --ms-guide, --ms-header-rule, --ms-gauge, --ms-gauge-empty, and --ms-stripe, the background of every +/// second row of a striped table or list (a faint grey when unset). A page /// sets these to theme every layout on it; a layout's own theme sets them on its block, and a game's /// default theme sets the -default form of each, under what the page sets. /// Everything is sized in ch, the width of a cell in the terminal font the layout was laid out @@ -51,6 +52,9 @@ public static class LayoutCss ".ms-fields.ms-label-right > .ms-field > dt { text-align: right; }\n" + ".ms-fields.ms-leader > .ms-field > dt { display: flex; gap: 0.5ch; }\n" + ".ms-fields.ms-leader > .ms-field > dt::after { content: \"\"; flex: 1 0 2ch; border-bottom: 1px dotted currentColor; margin-bottom: 0.3em; }\n" + + ".ms-fields.ms-striped { column-gap: 0; }\n" + + ".ms-fields.ms-striped > .ms-field > dt { padding-right: 1ch; }\n" + + ".ms-fields.ms-striped > .ms-field:nth-child(even) > * { background: var(--ms-stripe, var(--ms-stripe-default, rgba(127, 127, 127, 0.12))); }\n" + ".ms-tree, .ms-tree ul { list-style: none; margin: 0; padding: 0; }\n" + ".ms-tree ul { margin-left: 1ch; }\n" + ".ms-tree ul > li { position: relative; padding-left: 3ch; border-left: 1px solid var(--ms-guide, var(--ms-guide-default, currentColor)); }\n" + @@ -80,6 +84,7 @@ public static class LayoutCss ".ms-table { border-collapse: collapse; }\n" + ".ms-table th, .ms-table td { padding: 0 1ch; vertical-align: top; text-align: left; }\n" + ".ms-table th { border-bottom: 1px solid var(--ms-header-rule, var(--ms-header-rule-default, currentColor)); color: var(--ms-heading, var(--ms-heading-default, inherit)); }\n" + + ".ms-table.ms-striped > tbody > tr:nth-child(even) { background: var(--ms-stripe, var(--ms-stripe-default, rgba(127, 127, 127, 0.12))); }\n" + "@media (max-width: 48em) { .ms-table .ms-p3 { display: none; } }\n" + "@media (max-width: 32em) { .ms-table .ms-p2 { display: none; } }\n"; } diff --git a/MarkupString.Tests/Layout/ThemeTests.cs b/MarkupString.Tests/Layout/ThemeTests.cs index 04d0c35..93a50cb 100644 --- a/MarkupString.Tests/Layout/ThemeTests.cs +++ b/MarkupString.Tests/Layout/ThemeTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using MarkupString.Ansi; using MarkupString.Html; using MarkupString.Layout; @@ -70,7 +71,7 @@ public async Task Generate_FullContrastReachesSevenToOne() { var palette = ThemePalette.Generate(Hex("#5e81ac"), ThemeHarmony.Triadic, ThemeMode.Light, contrast: 1); - foreach (var role in Enum.GetValues().Where(role => role != ThemeRole.Background)) + foreach (var role in Enum.GetValues().Where(role => role is not (ThemeRole.Background or ThemeRole.Surface))) await Assert.That(ColorMath.Contrast(palette[role]!.Value.Resolved, palette.BackgroundColor)).IsGreaterThanOrEqualTo(7); } @@ -136,7 +137,7 @@ public async Task Json_ReadsEveryForm() [Arguments("\"nowhere\"", "no theme named 'nowhere'")] [Arguments("""{"seed":"blue"}""", "seed is a colour like #7aa2f7")] [Arguments("""{"seed":"#123456","preset":"nord"}""", "use one of preset, base16 and seed")] - [Arguments("""{"colors":{"accent":"#fff"}}""", "'accent' is not a role; the roles are background, foreground, primary, secondary, tertiary, muted, success, warning, error, info")] + [Arguments("""{"colors":{"accent":"#fff"}}""", "'accent' is not a role; the roles are background, surface, foreground, primary, secondary, tertiary, muted, success, warning, error, info")] [Arguments("""{"colors":{"primary":16}}""", "'16' is not a colour: use #rrggbb, a standard colour 0 to 15, or {\"rgb\":..,\"slot\":..}")] [Arguments("""{"border":"red"}""", "a theme has no 'border'")] public async Task Json_SaysWhatIsWrong(string json, string error) @@ -239,4 +240,113 @@ public async Task Html_WritesThePartsAsCustomProperties() await Assert.That(BlockLayout.Build(box.ThemedUnder(theme), 10).Render(MarkupFormat.Html, Registry)).Contains("--ms-border-default:#81a1c1;"); await Assert.That(LayoutCss.Fixed).Contains("border: 1px solid var(--ms-border, var(--ms-border-default, currentColor))"); } + + /// 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) })); + } } diff --git a/MarkupString/ColorGradient.cs b/MarkupString/ColorGradient.cs index b3e1724..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); } diff --git a/MarkupString/Layout/Blocks/Block.cs b/MarkupString/Layout/Blocks/Block.cs index cdc9a30..6a7ce1b 100644 --- a/MarkupString/Layout/Blocks/Block.cs +++ b/MarkupString/Layout/Blocks/Block.cs @@ -137,6 +137,18 @@ public MarkupText Paint(Func part, MarkupText 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) { @@ -257,11 +269,17 @@ public sealed record LayoutTheme /// 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; + || 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) @@ -288,6 +306,7 @@ public LayoutTheme Over(LayoutTheme below) 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 a60d8fb..1892600 100644 --- a/MarkupString/Layout/Blocks/BlockCodec.cs +++ b/MarkupString/Layout/Blocks/BlockCodec.cs @@ -239,6 +239,7 @@ public void Theme(string name, LayoutTheme theme) 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(); } @@ -440,6 +441,7 @@ public LayoutTheme Theme(string name) HeaderRuleColor = inner.Markup("cr"), GaugeFilledColor = inner.Markup("cf"), GaugeEmptyColor = inner.Markup("ce"), + StripeColor = inner.Markup("cz"), }; } diff --git a/MarkupString/Layout/Blocks/Fields.cs b/MarkupString/Layout/Blocks/Fields.cs index 8ea6564..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); @@ -67,11 +73,14 @@ public override void Draw(LayoutContext context, int width, IList li { // 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([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(MarkupText.Concat([indent, line])); + 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,8 +92,10 @@ 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) { @@ -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/LayoutJson.cs b/MarkupString/Layout/Blocks/LayoutJson.cs index 6fc843c..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) => diff --git a/MarkupString/Layout/Blocks/Table.cs b/MarkupString/Layout/Blocks/Table.cs index aa7deec..74a8737 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); @@ -48,8 +54,10 @@ public override void Draw(LayoutContext context, int width, IList li 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) { @@ -59,6 +67,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/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index 0676c86..e63ac56 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -833,20 +833,10 @@ MarkupString.ThemePalette.Name.get -> string! MarkupString.ThemePalette.Name.init -> void MarkupString.ThemePalette.ThemePalette() -> void MarkupString.ThemePalette.ToJson() -> string! -MarkupString.ThemePalette.ToTheme(System.Func! paint) -> MarkupString.Layout.LayoutTheme! 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 -MarkupString.ThemeRole.Error = 8 -> MarkupString.ThemeRole -MarkupString.ThemeRole.Foreground = 1 -> MarkupString.ThemeRole -MarkupString.ThemeRole.Info = 9 -> MarkupString.ThemeRole -MarkupString.ThemeRole.Muted = 5 -> MarkupString.ThemeRole -MarkupString.ThemeRole.Primary = 2 -> MarkupString.ThemeRole -MarkupString.ThemeRole.Secondary = 3 -> MarkupString.ThemeRole -MarkupString.ThemeRole.Success = 6 -> MarkupString.ThemeRole -MarkupString.ThemeRole.Tertiary = 4 -> MarkupString.ThemeRole -MarkupString.ThemeRole.Warning = 7 -> MarkupString.ThemeRole override MarkupString.ContrastShortfall.GetHashCode() -> int override MarkupString.OklchColor.GetHashCode() -> int override MarkupString.ThemeColor.GetHashCode() -> int @@ -889,3 +879,25 @@ static MarkupString.ThemePalette.operator ==(MarkupString.ThemePalette? left, Ma ~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 diff --git a/MarkupString/ThemePalette.cs b/MarkupString/ThemePalette.cs index c63bc52..9ee8238 100644 --- a/MarkupString/ThemePalette.cs +++ b/MarkupString/ThemePalette.cs @@ -44,6 +44,9 @@ 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, @@ -72,6 +75,19 @@ public enum ThemeRole 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 { @@ -135,14 +151,15 @@ public ThemePalette With(ThemeRole role, ThemeColor? color) => /// 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 => 1, + 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. + /// 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() { @@ -151,7 +168,7 @@ public IReadOnlyList Check() if (this[ThemeRole.Background] is { Rgb: null }) return shortfalls; foreach (var role in Enum.GetValues()) { - if (role == ThemeRole.Background || this[role] is not { Rgb: not null } color) continue; + 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))); } @@ -161,18 +178,19 @@ public IReadOnlyList Check() /// /// 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, and guides, separators, the rule under headings and a gauge's empty part muted. + /// 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, bold or not: an ANSI colour, say. - public LayoutTheme ToTheme(Func paint) + /// 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, bool bold = false) => this[role] is { } color ? paint(color, bold) : null; + IMarkup? Part(ThemeRole role, ThemePaint how = ThemePaint.Text) => this[role] is { } color ? paint(color, how) : null; return new LayoutTheme { BorderColor = Part(ThemeRole.Primary), - TitleColor = Part(ThemeRole.Secondary, bold: true), - HeadingColor = Part(ThemeRole.Primary, bold: true), + TitleColor = Part(ThemeRole.Secondary, ThemePaint.Bold), + HeadingColor = Part(ThemeRole.Primary, ThemePaint.Bold), LabelColor = Part(ThemeRole.Secondary), BulletColor = Part(ThemeRole.Tertiary), GaugeFilledColor = Part(ThemeRole.Primary), @@ -180,6 +198,7 @@ public LayoutTheme ToTheme(Func paint) SeparatorColor = Part(ThemeRole.Muted), HeaderRuleColor = Part(ThemeRole.Muted), GaugeEmptyColor = Part(ThemeRole.Muted), + StripeColor = Part(ThemeRole.Surface, ThemePaint.Background), }; } @@ -250,6 +269,7 @@ public static ThemePalette FromBase16(string name, IReadOnlyList color 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), @@ -286,6 +306,7 @@ public static ThemePalette Generate(RgbColor seed, ThemeHarmony harmony = ThemeH 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) => @@ -320,6 +341,7 @@ ThemeColor Status(double conventional) 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), diff --git a/docs/layout.md b/docs/layout.md index beb245f..9e265fe 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -478,13 +478,14 @@ text between them. A `LayoutTheme` sets the colour of each part as well as its characters: `BorderColor`, `TitleColor`, `HeadingColor`, `LabelColor`, `SeparatorColor`, `BulletColor`, `GuideColor`, `HeaderRuleColor`, -`GaugeFilledColor` and `GaugeEmptyColor`. Each is a markup layer, so a title can be bold as well as +`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 ten colours by what they are for (`ThemeRole`: background, 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. The ANSI package does the painting: +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()); @@ -518,6 +519,18 @@ In HTML a themed block writes its colours as custom properties (`--ms-border`, ` `--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` From 441934acc63d4acec0a92d66709ac90e44c6c144 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 05:47:26 +0000 Subject: [PATCH 4/5] Genre themes with borders, ornaments and glyphs of their own Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FuCWyo4QX6dza8MxQQoZM --- CHANGELOG.md | 7 + MarkupString.Tests/Layout/ThemeTests.cs | 53 +++++++ MarkupString/PublicAPI.Unshipped.txt | 38 +++++ MarkupString/ThemeLook.cs | 196 ++++++++++++++++++++++++ MarkupString/ThemePalette.cs | 85 +++++++++- docs/layout.md | 11 +- 6 files changed, 385 insertions(+), 5 deletions(-) create mode 100644 MarkupString/ThemeLook.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ccbdda6..de2102a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,13 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). sixteen-colour client gets instead, picked by hue rather than RGB distance (`ColorMath.StandardSlot`). `ColorMath` has contrast, OKLCH conversion and hue rotation. `Themed.Fallback` (`ThemedUnder`) puts a theme under the one it is drawn in, for a game's default under a reader's own. +- **Genre themes with a look of their own.** `ThemeLook` holds the shapes a theme draws with beside + its colours: a border preset, ornaments round a title, the tree guide, the bullet, a gauge's pieces, + the field separator and the header rule. `ThemePalette.Look` carries one into `ToTheme`, and JSON + reads and writes it as `look`. `ThemePalette.Genres` has a preset for each MSSP genre (`adult`, + `fantasy`, `historical`, `horror`, `modern`, `mystery`, `romance`, `science-fiction`, `spiritual`), + each generated from a fitting colour and given its own look; an ASCII-only reader gets the ASCII + form of each piece. - **Striped rows.** `Table.Striped` and `Fields.Striped` lay every second row, all its lines and the full width, on the theme's `StripeColor`, a background; a cell's own background still wins. Palettes gained a `surface` role for it (base16's `base01`, or a step off the background when generated), and diff --git a/MarkupString.Tests/Layout/ThemeTests.cs b/MarkupString.Tests/Layout/ThemeTests.cs index 93a50cb..b1c14e3 100644 --- a/MarkupString.Tests/Layout/ThemeTests.cs +++ b/MarkupString.Tests/Layout/ThemeTests.cs @@ -349,4 +349,57 @@ public async Task TheSurface_IsABackgroundNearTheBackground() 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); + } } diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index e63ac56..b267789 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -901,3 +901,41 @@ 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! 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 index 9ee8238..76b5651 100644 --- a/MarkupString/ThemePalette.cs +++ b/MarkupString/ThemePalette.cs @@ -134,6 +134,9 @@ public sealed record ThemePalette /// Whether it is made for a dark background or a light one. public ThemeMode Mode { get; init; } = ThemeMode.Dark; + /// 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; @@ -186,7 +189,7 @@ 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 new LayoutTheme + return (Look?.ToTheme() ?? new LayoutTheme()) with { BorderColor = Part(ThemeRole.Primary), TitleColor = Part(ThemeRole.Secondary, ThemePaint.Bold), @@ -224,10 +227,71 @@ public LayoutTheme ToTheme(Func paint) }), }; - /// Every preset: and well-known schemes. + /// + /// 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"), @@ -482,6 +546,13 @@ public static bool TryRead(JsonElement element, out ThemePalette? palette, out s 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) @@ -508,7 +579,7 @@ public static bool TryRead(JsonElement element, out ThemePalette? palette, out s 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") continue; + 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; } @@ -577,6 +648,11 @@ public string ToJson() json.WriteEndObject(); } json.WriteEndObject(); + if (Look is not null) + { + json.WritePropertyName("look"); + Look.Write(json); + } json.WriteEndObject(); } return System.Text.Encoding.UTF8.GetString(stream.ToArray()); @@ -585,7 +661,7 @@ public string ToJson() /// public bool Equals(ThemePalette? other) => other is not null && Name == other.Name && Mode == other.Mode && Colors.Count == other.Colors.Count - && Colors.All(pair => other.Colors.TryGetValue(pair.Key, out var color) && color == pair.Value); + && Look == other.Look && Colors.All(pair => other.Colors.TryGetValue(pair.Key, out var color) && color == pair.Value); /// public override int GetHashCode() @@ -593,6 +669,7 @@ public override int GetHashCode() var hash = new HashCode(); hash.Add(Name); hash.Add(Mode); + hash.Add(Look); foreach (var pair in Colors.OrderBy(pair => pair.Key)) { hash.Add(pair.Key); diff --git a/docs/layout.md b/docs/layout.md index 9e265fe..a4b2fba 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -499,7 +499,9 @@ colours. Four ways to make one: -- **Presets**: `terminal`, `catppuccin-mocha`, `catppuccin-latte`, `dracula`, `gruvbox-dark`, `nord`, +- **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, ...). @@ -512,6 +514,13 @@ Four ways to make one: `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`. From d8125f24d86ec7d5acdce19afbe93542d3804c5d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 05:55:09 +0000 Subject: [PATCH 5/5] Make a generated theme again for the other background Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FuCWyo4QX6dza8MxQQoZM --- CHANGELOG.md | 3 +++ MarkupString.Tests/Layout/ThemeTests.cs | 26 ++++++++++++++++++ MarkupString/PublicAPI.Unshipped.txt | 7 +++++ MarkupString/ThemePalette.cs | 36 +++++++++++++++++++++++-- 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de2102a..ea8f90f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `fantasy`, `historical`, `horror`, `modern`, `mystery`, `romance`, `science-fiction`, `spiritual`), each generated from a fitting colour and given its own look; an ASCII-only reader gets the ASCII form of each piece. +- **Light and dark from one theme.** A generated palette keeps its seed, harmony and contrast + (`ThemePalette.Seed`, written to JSON as `seed`), so `InMode` makes it again for the other + background, and `{"preset":"fantasy","mode":"light"}` is fantasy for a light client. - **Striped rows.** `Table.Striped` and `Fields.Striped` lay every second row, all its lines and the full width, on the theme's `StripeColor`, a background; a cell's own background still wins. Palettes gained a `surface` role for it (base16's `base01`, or a step off the background when generated), and diff --git a/MarkupString.Tests/Layout/ThemeTests.cs b/MarkupString.Tests/Layout/ThemeTests.cs index b1c14e3..4ca7870 100644 --- a/MarkupString.Tests/Layout/ThemeTests.cs +++ b/MarkupString.Tests/Layout/ThemeTests.cs @@ -402,4 +402,30 @@ 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/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index b267789..78426d9 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -939,3 +939,10 @@ static MarkupString.ThemeLook.TryRead(System.Text.Json.JsonElement element, out 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/ThemePalette.cs b/MarkupString/ThemePalette.cs index 76b5651..a8be159 100644 --- a/MarkupString/ThemePalette.cs +++ b/MarkupString/ThemePalette.cs @@ -134,6 +134,27 @@ public sealed record ThemePalette /// 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; } @@ -401,6 +422,9 @@ ThemeColor Status(double conventional) return new ThemePalette { Name = "generated", + Seed = seed, + Harmony = harmony, + Contrast = level, Mode = mode, Colors = ImmutableDictionary.CreateRange(new Dictionary { @@ -535,7 +559,7 @@ public static bool TryRead(JsonElement element, out ThemePalette? palette, out s result = Generate(seed, harmony, mode ?? ThemeMode.Dark, level); } - if (mode is { } chosen) result = result with { Mode = chosen }; + if (mode is { } chosen) result = result.InMode(chosen); if (element.TryGetProperty("name", out var nameValue)) { if (nameValue.ValueKind != JsonValueKind.String) @@ -648,6 +672,12 @@ public string ToJson() 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"); @@ -660,7 +690,8 @@ public string ToJson() /// public bool Equals(ThemePalette? other) => - other is not null && Name == other.Name && Mode == other.Mode && Colors.Count == other.Colors.Count + 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); /// @@ -670,6 +701,7 @@ public override int GetHashCode() 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);