Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions MarkupString.Ansi/AnsiMarkup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ public sealed record AnsiMarkup(AnsiStyle Style) : IColorMarkup, IAnsiStyleSourc
/// <remarks>A palette colour is resolved to its usual RGB value; the terminal's default has none.</remarks>
public RgbColor? Foreground => Style.Foreground?.ToRgb() is { } rgb ? new RgbColor(rgb.R, rgb.G, rgb.B) : null;

/// <inheritdoc/>
/// <remarks>A palette colour is resolved to its usual RGB value; the terminal's default has none.</remarks>
public RgbColor? Background => Style.Background?.ToRgb() is { } rgb ? new RgbColor(rgb.R, rgb.G, rgb.B) : null;

/// <inheritdoc/>
/// <remarks>
/// Everything else about the style is kept, and the colour this layer had is what a client with only
Expand Down
35 changes: 35 additions & 0 deletions MarkupString.Ansi/AnsiTheme.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using MarkupString.Layout;

namespace MarkupString.Ansi;

/// <summary>Theme colours as terminal colour.</summary>
public static class AnsiTheme
{
/// <summary>
/// <paramref name="color"/> as an ANSI layer: its exact colour, with its standard colour kept for a
/// client that has only the sixteen (<see cref="AnsiStyle.StandardForeground"/>); 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.
/// </summary>
/// <param name="color">The colour.</param>
/// <param name="paint">As text, bold text or a background.</param>
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,
});
}

/// <summary><paramref name="palette"/> as a layout's part colours, in ANSI (<see cref="ThemePalette.ToTheme"/>).</summary>
public static LayoutTheme ToLayoutTheme(this ThemePalette palette)
{
ArgumentNullException.ThrowIfNull(palette);
return palette.ToTheme(Paint);
}
}
4 changes: 4 additions & 0 deletions MarkupString.Ansi/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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!
59 changes: 57 additions & 2 deletions MarkupString.Html/Emitters/LayoutHtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ private static KeyValuePair<Type, Action<Block, HtmlLayoutWriter>> Of<T>(BlockHt
}),
Of<Shaded>(Shaded),
Of<Colored>(Colored),
Of<Themed>((themed, html) => html.Block(themed.Content, html.Context with { Theme = themed.Theme.Over(html.Context.Theme) })),
Of<Themed>(Themed),
];

private static void Text(TextBlock text, HtmlLayoutWriter html)
Expand Down Expand Up @@ -237,6 +237,7 @@ private static void FieldList(ReadOnlySpan<Field> items, Fields fields, MarkupTe
html.Write("<dl class=\"ms-fields");
if (fields.LabelAlignment == Alignment.Right) html.Write(" ms-label-right");
if (leader) html.Write(" ms-leader");
if (fields.Striped) html.Write(" ms-striped");
html.Write("\">");
var head = separator.Trim(TrimType.TrimEnd);
foreach (var field in items)
Expand Down Expand Up @@ -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("<div class=\"ms-table-wrap\"><table class=\"ms-table\"><thead><tr>");
html.Write("<div class=\"ms-table-wrap\"><table class=\"ms-table");
if (table.Striped) html.Write(" ms-striped");
html.Write("\"><thead><tr>");
foreach (var column in columns)
{
html.Write("<th scope=\"col\"");
Expand Down Expand Up @@ -489,6 +492,58 @@ private static void Shaded(Shaded shaded, HtmlLayoutWriter html)
html.Write("</div>");
}

/// <summary>The CSS custom property each part's colour is written to, and read from by <see cref="LayoutCss"/>.</summary>
private static readonly (string Property, Func<LayoutTheme, IMarkup?> 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),
];

/// <summary>
/// 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 <c>-default</c>, under any the page sets.
/// </summary>
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("<div class=\"ms-themed\" style=\"");
foreach (var (property, color) in colors)
{
html.Write(property);
if (themed.Fallback) html.Write("-default");
html.Write(":");
html.Write(color!);
html.Write(";");
}
html.Write("\">");
html.Block(themed.Content, html.Context with { Theme = themed.Within(html.Context.Theme) });
html.Write("</div>");
}

/// <summary>A coloured block: a colour as the block's own, any other layer opened round it as it renders.</summary>
private static void Colored(Colored colored, HtmlLayoutWriter html)
{
Expand Down
35 changes: 23 additions & 12 deletions MarkupString.Html/LayoutCss.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ namespace MarkupString.Html;
/// A page that shows rendered layouts includes it, or carries its own copy of these rules.
/// </summary>
/// <remarks>
/// Borders and rules take <c>currentColor</c>, so they follow the colour of the text around them.
/// Borders and rules take <c>currentColor</c>, so they follow the colour of the text around them, unless
/// a theme sets a part's colour: <c>--ms-border</c>, <c>--ms-title</c>, <c>--ms-heading</c>, <c>--ms-label</c>,
/// <c>--ms-separator</c>, <c>--ms-bullet</c>, <c>--ms-guide</c>, <c>--ms-header-rule</c>, <c>--ms-gauge</c>, <c>--ms-gauge-empty</c>, and <c>--ms-stripe</c>, 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 <c>-default</c> form of each, under what the page sets.
/// Everything is sized in <c>ch</c>, 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.
/// </remarks>
Expand All @@ -15,51 +20,56 @@ 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" +
".ms-float-left > .ms-figure-image, .ms-float-left > .ms-figure-art { float: left; max-width: 50%; margin: 0 2ch 0.5em 0; }\n" +
".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" +
Expand All @@ -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";
Expand Down
Loading
Loading