diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ef12d7..9832d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## 2.24.0 — Unreleased +### Added + +- **`ColorGradient.Roles`**: the theme colour each stop stands for (`ThemeRole?`, by position). Where a + layout is drawn for a reader with a theme of their own, `LayoutContext.Tones` gives the colour for + each role, and a `Shaded` block or a gauge is shaded in those colours rather than the ones the stops + were written with (`ColorGradient.Toned`). A reader with no theme keeps the written colours. A page + writes such a stop as `var(--ms-tone-, )`, in `--ms-shade-toned` + beside the sampled `--ms-shade` (class `ms-toned`), and the stylesheet uses it where the browser can + blend in the gradient's space. The roles are stored on the wire as `ro`. + ### Changed - **A `Table` read linearly is one line per row**: its labelled values, comma-separated diff --git a/MarkupString.Html/Emitters/LayoutHtml.cs b/MarkupString.Html/Emitters/LayoutHtml.cs index 897089e..5ebaa6c 100644 --- a/MarkupString.Html/Emitters/LayoutHtml.cs +++ b/MarkupString.Html/Emitters/LayoutHtml.cs @@ -416,7 +416,8 @@ private static void GaugeBar(Gauge gauge, ColorGradient gradient, double maximum html.Write(Decimal(value)); html.Write("\""); BarWidth(gauge, html); - html.Write(">
"to bottom right", _ => "to right", }; + var toned = shaded.Gradient.HasRoles; html.Write(shaded.Target switch { - GradientTarget.Borders => "
"
"
"
"
"
dt, .ms-table th, .ms-bullets .ms-marker) { color: transparent; }\n" + ".ms-shaded:not(.ms-shade-text) :is(.ms-box, .ms-gauge-bar), .ms-shaded:not(.ms-shade-text) :is(.ms-rule, .ms-divider)::before, .ms-shaded:not(.ms-shade-text) :is(.ms-rule, .ms-divider)::after, .ms-shaded:not(.ms-shade-text) .ms-line { border-image: var(--ms-shade) 1; }\n" + + ".ms-shaded.ms-toned:not(.ms-shade-borders) { background-image: var(--ms-shade); }\n" + + "@supports (background-image: linear-gradient(in oklch, red, blue)) { .ms-shaded.ms-toned:not(.ms-shade-borders) { background-image: var(--ms-shade-toned); } .ms-shaded.ms-toned:not(.ms-shade-text) :is(.ms-box, .ms-gauge-bar), .ms-shaded.ms-toned:not(.ms-shade-text) :is(.ms-rule, .ms-divider)::before, .ms-shaded.ms-toned:not(.ms-shade-text) :is(.ms-rule, .ms-divider)::after, .ms-shaded.ms-toned:not(.ms-shade-text) .ms-line { border-image: var(--ms-shade-toned) 1; } .ms-gauge-fill.ms-toned { background-image: var(--ms-shade-toned) !important; } }\n" + ".ms-table-wrap { max-width: 100%; overflow-x: auto; }\n" + ".ms-table { border-collapse: collapse; }\n" + ".ms-table.ms-fill { width: 100%; }\n" + diff --git a/MarkupString.Tests/Layout/GradientTests.cs b/MarkupString.Tests/Layout/GradientTests.cs index 00c712d..0e2451e 100644 --- a/MarkupString.Tests/Layout/GradientTests.cs +++ b/MarkupString.Tests/Layout/GradientTests.cs @@ -169,4 +169,62 @@ public async Task TheFallback_SurvivesSerialization() await Assert.That(json).Contains("\"fs\":2"); await Assert.That(MarkupTextSerializer.Serialize(read, Registry)).IsEqualTo(json); } + + /// A gradient from the theme's primary colour (red when written) to blue, in a box. + private static MarkupText TonedBox() => + BlockLayout.Build(new TextBlock(MarkupText.Plain("abc")).Bordered() + .Shaded(new ColorGradient([Red, Blue]) { Roles = [ThemeRole.Primary, null] }, GradientFlow.Characters), 0); + + [Test] + public async Task AStopNamingAThemeColour_TakesTheReadersColour_AfterTheSerializer() + { + var read = MarkupTextSerializer.Deserialize(MarkupTextSerializer.Serialize(TonedBox(), Registry), Registry); + var context = LayoutContext.Default with { Tones = role => role == ThemeRole.Primary ? Lime : null }; + + var output = BlockLayout.Relayout(read, 0, context).Render(MarkupFormat.Ansi, Registry); + + await Assert.That(output).Contains("38;2;0;255;0m"); + await Assert.That(output).DoesNotContain("38;2;255;0;0m"); + await Assert.That(output).Contains("38;2;0;0;255m"); + } + + [Test] + public async Task AStopNamingAThemeColour_KeepsItsOwn_ForAReaderWithoutATheme() + { + var read = MarkupTextSerializer.Deserialize(MarkupTextSerializer.Serialize(TonedBox(), Registry), Registry); + + await Assert.That(BlockLayout.Relayout(read, 0, LayoutContext.Default).Render(MarkupFormat.Ansi, Registry)) + .IsEqualTo(TonedBox().Render(MarkupFormat.Ansi, Registry)); + } + + [Test] + public async Task AStopNamingAThemeColour_IsItsVariable_OnAPage() + { + var gradient = new ColorGradient([Red, Blue]) { Roles = [ThemeRole.Primary, null] }; + + await Assert.That(gradient.ToCss()).IsEqualTo("linear-gradient(to right in oklch, var(--ms-tone-primary, #ff0000), #0000ff)"); + } + + [Test] + public async Task AStopThatCannotBeRead_TakesItsThemeColourWithIt() + { + var json = MarkupTextSerializer.Serialize(TonedBox(), Registry); + var unreadable = new System.Text.RegularExpressions.Regex("\"st\":\\[\\{\"t\":\"#\",\"p\":\\[null,\\[\\{\"k\":\"ansi\"") + .Replace(json, "\"st\":[{\"t\":\"#\",\"p\":[null,[{\"k\":\"nosuch\""); + var read = MarkupTextSerializer.Deserialize(unreadable, Registry); + var context = LayoutContext.Default with { Tones = role => role == ThemeRole.Primary ? Lime : null }; + + await Assert.That(unreadable).IsNotEqualTo(json); + await Assert.That(BlockLayout.Relayout(read, 0, context).Render(MarkupFormat.Ansi, Registry)).DoesNotContain("38;2;0;255;0m"); + } + + [Test] + public async Task AStopNamingAThemeColour_IsUsedOnAPage_OnlyWhereTheBrowserCanBlendIt() + { + var html = TonedBox().Render(MarkupFormat.Html, Registry); + + await Assert.That(html).Contains("class=\"ms-shaded ms-toned\" style=\"--ms-shade:linear-gradient(to right, #"); + await Assert.That(html).Contains(";--ms-shade-toned:linear-gradient(to right in oklch, var(--ms-tone-primary, #ff0000), #0000ff)"); + await Assert.That(html).DoesNotContain("background-image:"); + } } diff --git a/MarkupString/ColorGradient.cs b/MarkupString/ColorGradient.cs index 4e75077..65ead59 100644 --- a/MarkupString/ColorGradient.cs +++ b/MarkupString/ColorGradient.cs @@ -101,6 +101,27 @@ public sealed record ColorGradient(ImmutableArray Stops, GradientS /// How many times the colours run over the length, one after the other. public int Repeat { get; init; } = 1; + /// + /// The theme colour each stop stands for, by position, or null for a stop that is only its colour; empty + /// when none does. A stop that names one is drawn in the reader's colour for it where a layout is drawn + /// for a reader (), and a page writes it as the CSS variable + /// --ms-tone-<role>, with its own colour as the fallback. + /// + public ImmutableArray Roles { get; init; } = []; + + /// + /// The gradient with each stop that names a theme colour () in the colour + /// gives it; a stop it gives no colour keeps its own. + /// + public ColorGradient Toned(Func tones) + { + ArgumentNullException.ThrowIfNull(tones); + if (Roles.IsDefaultOrEmpty || Stops.IsDefaultOrEmpty) return this; + var stops = Stops.Select((stop, i) => + i < Roles.Length && Roles[i] is { } role && tones(role) is { Foreground: not null } toned ? toned : stop); + return this with { Stops = [.. stops] }; + } + private int Cycles => Math.Clamp(Repeat, 1, 1000); /// Where along the stops falls, once repeated and mirrored. @@ -223,19 +244,19 @@ internal MarkupText[] ShadeLines(IReadOnlyList lines, GradientFlow f /// The gradient as a CSS linear-gradient running , to bottom say, in its own space. public string ToCss(string direction) { - var colors = Colors; + var colors = CssColors; if (colors.Length == 0) return string.Empty; if (colors.Length == 1) colors = [colors[0], colors[0]]; var space = Space.ToString().ToLowerInvariant(); if (!Mirror && Cycles == 1) - return $"linear-gradient({direction} in {space}, {string.Join(", ", colors.Select(color => color.ToHex()))})"; + return $"linear-gradient({direction} in {space}, {string.Join(", ", colors)})"; // Each run of the colours over its own stretch; a mirrored run goes there and back. var run = Mirror ? [.. colors, .. colors.Reverse().Skip(1)] : colors; var stops = new List(); for (var cycle = 0; cycle < Cycles; cycle++) for (var i = 0; i < run.Length; i++) - stops.Add($"{run[i].ToHex()} {Percent((cycle + i / (double)(run.Length - 1)) / Cycles)}"); + stops.Add($"{run[i]} {Percent((cycle + i / (double)(run.Length - 1)) / Cycles)}"); return $"linear-gradient({direction} in {space}, {string.Join(", ", stops)})"; } @@ -255,13 +276,25 @@ public string ToCss(int samples, string direction) return $"linear-gradient({direction}, {list})"; } + /// The stops' colours for a page: a stop that names a theme colour as its CSS variable, its own colour the fallback. + private string[] CssColors => [.. (Stops.IsDefault ? [] : Stops) + .Select((stop, i) => (stop.Foreground, Role: !Roles.IsDefault && i < Roles.Length ? Roles[i] : null)) + .Where(stop => stop.Foreground is not null) + .Select(stop => stop.Role is { } role + ? $"var(--ms-tone-{ThemePalette.RoleName(role)}, {stop.Foreground!.Value.ToHex()})" + : stop.Foreground!.Value.ToHex())]; + + /// Whether any stop names a theme colour (). + public bool HasRoles => !Roles.IsDefault && Roles.Any(role => role is not null); + private static string Percent(double fraction) => (Math.Round(fraction * 100, 2)).ToString("0.##", CultureInfo.InvariantCulture) + "%"; /// public bool Equals(ColorGradient? other) => other is not null && Space == other.Space && Mirror == other.Mirror && Cycles == other.Cycles - && (Stops.IsDefault ? [] : Stops).SequenceEqual(other.Stops.IsDefault ? [] : other.Stops); + && (Stops.IsDefault ? [] : Stops).SequenceEqual(other.Stops.IsDefault ? [] : other.Stops) + && (Roles.IsDefault ? [] : Roles).SequenceEqual(other.Roles.IsDefault ? [] : other.Roles); /// public override int GetHashCode() @@ -271,6 +304,7 @@ public override int GetHashCode() hash.Add(Mirror); hash.Add(Cycles); foreach (var stop in Stops.IsDefault ? [] : Stops) hash.Add(stop); + foreach (var role in Roles.IsDefault ? [] : Roles) hash.Add(role); return hash.ToHashCode(); } diff --git a/MarkupString/Layout/Blocks/Block.cs b/MarkupString/Layout/Blocks/Block.cs index 8b8de8c..bdf2cee 100644 --- a/MarkupString/Layout/Blocks/Block.cs +++ b/MarkupString/Layout/Blocks/Block.cs @@ -107,6 +107,16 @@ public sealed record LayoutContext private bool ShadesLines => Shading is GradientTarget.All or GradientTarget.Borders; + /// + /// For a reader with a theme of their own, the colour it gives each theme colour, or null where it gives + /// none; null for a reader without one. A gradient stop that names a theme colour + /// () is drawn in it. + /// + public Func? Tones { get; init; } + + /// in this reader's theme colours (). + internal ColorGradient Toned(ColorGradient gradient) => Tones is { } tones ? gradient.Toned(tones) : gradient; + /// Where text with no alignment of its own sits: a table column's, or one set with . public Alignment TextAlignment { get; init; } = Alignment.Left; diff --git a/MarkupString/Layout/Blocks/BlockCodec.cs b/MarkupString/Layout/Blocks/BlockCodec.cs index 1892600..8c6014c 100644 --- a/MarkupString/Layout/Blocks/BlockCodec.cs +++ b/MarkupString/Layout/Blocks/BlockCodec.cs @@ -176,6 +176,14 @@ public void Gradient(string name, ColorGradient? gradient) foreach (var stop in gradient.Stops.IsDefault ? [] : gradient.Stops) MarkupTextSerializer.Write(_json, MarkupText.Wrap(stop, "#"), _registry); _json.WriteEndArray(); + if (gradient.HasRoles) + { + _json.WriteStartArray("ro"); + foreach (var role in gradient.Roles) + if (role is { } named) _json.WriteStringValue(ThemePalette.RoleName(named)); + else _json.WriteNullValue(); + _json.WriteEndArray(); + } _json.WriteEndObject(); } @@ -313,6 +321,13 @@ public ImmutableArray Texts(string name) => ? [.. list.EnumerateArray().Where(item => item.ValueKind == JsonValueKind.Object).Select(item => MarkupTextSerializer.Read(item, _state.Registry))] : []; + /// The theme colour each of a gradient's stops names, null for one that names none; empty when there are none. + internal ImmutableArray Roles(string name) => + TryGet(name, JsonValueKind.Array, out var list) + ? [.. list.EnumerateArray().Select(item => + item.ValueKind == JsonValueKind.String && ThemePalette.TryParseRole(item.GetString()!, out var role) ? role : (ThemeRole?)null)] + : []; + /// A child block. public Block? Block(string name) => _element.ValueKind == JsonValueKind.Object && _element.TryGetProperty(name, out var value) ? Inner(value).Object() : null; @@ -364,13 +379,18 @@ public TEnum Enum(string name, TEnum fallback = default) where TEnum : st { if (!TryGet(name, JsonValueKind.Object, out var value)) return null; var inner = Inner(value); - var stops = inner.Texts("st") - .SelectMany(text => text.Runs.SelectMany(run => run.Markups).OfType().Take(1)) - .ToImmutableArray(); - return new ColorGradient(stops, inner.Enum("sp", GradientSpace.Oklch)) + var roles = inner.Roles("ro"); + // A stop this reader has no codec for is left out, and the theme colour it named with it, so the rest keep theirs. + var read = inner.Texts("st") + .Select((text, i) => (Stop: text.Runs.SelectMany(run => run.Markups).OfType().FirstOrDefault(), + Role: i < roles.Length ? roles[i] : null)) + .Where(stop => stop.Stop is not null) + .ToArray(); + return new ColorGradient([.. read.Select(stop => stop.Stop!)], inner.Enum("sp", GradientSpace.Oklch)) { Mirror = inner.Bool("mi"), Repeat = inner.Int("re", 1, 1, 1000), + Roles = roles.IsEmpty ? [] : [.. read.Select(stop => stop.Role)], }; } diff --git a/MarkupString/Layout/Blocks/Gauge.cs b/MarkupString/Layout/Blocks/Gauge.cs index ae3fa1a..67caad6 100644 --- a/MarkupString/Layout/Blocks/Gauge.cs +++ b/MarkupString/Layout/Blocks/Gauge.cs @@ -65,7 +65,7 @@ public override void Draw(LayoutContext context, int width, IList li var bar = BarWidth > 0 ? Math.Min(BarWidth, Math.Max(1, room)) : Math.Max(1, room); var full = (int)Math.Round(bar * Ratio); var fill = BlockText.Run(filled, full); - if (Gradient is { IsEmpty: false } gradient) + if (Gradient is { } given && context.Toned(given) is { IsEmpty: false } gradient) fill = Shade == GaugeShade.Value ? gradient.Paint(fill, Ratio) : gradient.ShadeLines([fill], GradientFlow.Across, bar)[0]; else fill = context.Paint(t => t.GaugeFilledColor, fill); var rest = context.Paint(t => t.GaugeEmptyColor, BlockText.Run(empty, bar - full)); diff --git a/MarkupString/Layout/Blocks/Modifiers.cs b/MarkupString/Layout/Blocks/Modifiers.cs index 2e6e590..ef8d623 100644 --- a/MarkupString/Layout/Blocks/Modifiers.cs +++ b/MarkupString/Layout/Blocks/Modifiers.cs @@ -38,7 +38,8 @@ public override Block MapText(Func map) => /// public override void Draw(LayoutContext context, int width, IList lines) { - if (Gradient.IsEmpty) + var gradient = context.Toned(Gradient); + if (gradient.IsEmpty) { context.Draw(Content, width, lines); return; @@ -49,11 +50,11 @@ public override void Draw(LayoutContext context, int width, IList li var used = drawn.Count == 0 ? width : Math.Max(1, drawn.Max(line => line.Trim(TrimType.TrimEnd).DisplayWidth)); if (Target == GradientTarget.All) { - lines.AddRange(Gradient.ShadeLines(drawn, Flow, used)); + lines.AddRange(gradient.ShadeLines(drawn, Flow, used)); return; } var borders = Target == GradientTarget.Borders; - foreach (var line in Gradient.ShadeLines(drawn, Flow, used, character => LinePiece.Marks(character) == borders)) + foreach (var line in gradient.ShadeLines(drawn, Flow, used, character => LinePiece.Marks(character) == borders)) lines.Add(LinePiece.Unmark(line)); } } diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index 9237e48..b94aa8b 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -23,3 +23,9 @@ override MarkupString.StandInMarkup.ToString() -> string! static MarkupString.StandInMarkup.operator !=(MarkupString.StandInMarkup? left, MarkupString.StandInMarkup? right) -> bool static MarkupString.StandInMarkup.operator ==(MarkupString.StandInMarkup? left, MarkupString.StandInMarkup? right) -> bool static MarkupString.MarkupText.StandIn(MarkupString.MarkupText! content, string! text) -> MarkupString.MarkupText! +MarkupString.ColorGradient.HasRoles.get -> bool +MarkupString.ColorGradient.Roles.get -> System.Collections.Immutable.ImmutableArray +MarkupString.ColorGradient.Roles.init -> void +MarkupString.ColorGradient.Toned(System.Func! tones) -> MarkupString.ColorGradient! +MarkupString.Layout.LayoutContext.Tones.get -> System.Func? +MarkupString.Layout.LayoutContext.Tones.init -> void