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