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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<role>, <colour>)`, 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
Expand Down
25 changes: 19 additions & 6 deletions MarkupString.Html/Emitters/LayoutHtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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("><div class=\"ms-gauge-fill\" style=\"width:");
var toned = gauge.Shade != GaugeShade.Value && gradient.HasRoles;
html.Write(toned ? "><div class=\"ms-gauge-fill ms-toned\" style=\"width:" : "><div class=\"ms-gauge-fill\" style=\"width:");
html.Write(Decimal(Math.Round(ratio * 100, 2)));
html.Write("%;");
if (gauge.Shade == GaugeShade.Value)
Expand All @@ -428,7 +429,9 @@ private static void GaugeBar(Gauge gauge, ColorGradient gradient, double maximum
{
html.Write("background-image:");
html.Write(gradient.ToCss(9));
html.Write(";background-image:");
// A theme colour's var() would leave the declaration invalid, not dropped, in a browser that cannot blend in
// the space; the stylesheet puts it in place only where it can (as for a shaded block).
html.Write(toned ? ";--ms-shade-toned:" : ";background-image:");
html.Write(gradient.ToCss());
html.Write(";background-size:");
html.Write(Decimal(Math.Round(100 / ratio, 2)));
Expand Down Expand Up @@ -595,15 +598,25 @@ private static void Shaded(Shaded shaded, HtmlLayoutWriter html)
GradientFlow.Diagonal => "to bottom right",
_ => "to right",
};
var toned = shaded.Gradient.HasRoles;
html.Write(shaded.Target switch
{
GradientTarget.Borders => "<div class=\"ms-shaded ms-shade-borders\" style=\"--ms-shade:",
GradientTarget.Text => "<div class=\"ms-shaded ms-shade-text\" style=\"--ms-shade:",
_ => "<div class=\"ms-shaded\" style=\"--ms-shade:",
GradientTarget.Borders => "<div class=\"ms-shaded ms-shade-borders",
GradientTarget.Text => "<div class=\"ms-shaded ms-shade-text",
_ => "<div class=\"ms-shaded",
});
html.Write(toned ? " ms-toned\" style=\"--ms-shade:" : "\" style=\"--ms-shade:");
html.Write(shaded.Gradient.ToCss(9, direction));
if (toned)
{
// A stop that names a theme colour is written as its variable, which only the unsampled gradient keeps. Its
// var() would leave the declaration invalid in a browser that cannot blend in the space, rather than dropped,
// so the stylesheet uses it only where the space is supported, and the sampled colours elsewhere.
html.Write(";--ms-shade-toned:");
html.Write(shaded.Gradient.ToCss(direction));
}
// Shading the box drawing alone, the text is not clipped to the gradient, so nothing is painted behind it.
if (shaded.Target != GradientTarget.Borders)
else if (shaded.Target != GradientTarget.Borders)
{
html.Write(";background-image:var(--ms-shade);background-image:");
html.Write(shaded.Gradient.ToCss(direction));
Expand Down
2 changes: 2 additions & 0 deletions MarkupString.Html/LayoutCss.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ public static class LayoutCss
".ms-shaded:not(.ms-shade-borders) { -webkit-background-clip: text; background-clip: text; color: transparent; }\n" +
".ms-shaded:not(.ms-shade-borders) :is(.ms-box-title, .ms-rule-title, .ms-field > 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" +
Expand Down
58 changes: 58 additions & 0 deletions MarkupString.Tests/Layout/GradientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>A gradient from the theme's primary colour (red when written) to blue, in a box.</summary>
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:");
}
}
42 changes: 38 additions & 4 deletions MarkupString/ColorGradient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,27 @@ public sealed record ColorGradient(ImmutableArray<IColorMarkup> Stops, GradientS
/// <summary>How many times the colours run over the length, one after the other.</summary>
public int Repeat { get; init; } = 1;

/// <summary>
/// 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 (<see cref="Layout.LayoutContext.Tones"/>), and a page writes it as the CSS variable
/// <c>--ms-tone-&lt;role&gt;</c>, with its own colour as the fallback.
/// </summary>
public ImmutableArray<ThemeRole?> Roles { get; init; } = [];

/// <summary>
/// The gradient with each stop that names a theme colour (<see cref="Roles"/>) in the colour
/// <paramref name="tones"/> gives it; a stop it gives no colour keeps its own.
/// </summary>
public ColorGradient Toned(Func<ThemeRole, IColorMarkup?> 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);
Comment thread
HarryCordewener marked this conversation as resolved.
Resolved
return this with { Stops = [.. stops] };
}

private int Cycles => Math.Clamp(Repeat, 1, 1000);

/// <summary>Where along the stops <paramref name="position"/> falls, once repeated and mirrored.</summary>
Expand Down Expand Up @@ -223,19 +244,19 @@ internal MarkupText[] ShadeLines(IReadOnlyList<MarkupText> lines, GradientFlow f
/// <summary>The gradient as a CSS <c>linear-gradient</c> running <paramref name="direction"/>, <c>to bottom</c> say, in its own space.</summary>
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<string>();
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)})";
}

Expand All @@ -255,13 +276,25 @@ public string ToCss(int samples, string direction)
return $"linear-gradient({direction}, {list})";
}

/// <summary>The stops' colours for a page: a stop that names a theme colour as its CSS variable, its own colour the fallback.</summary>
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())];

/// <summary>Whether any stop names a theme colour (<see cref="Roles"/>).</summary>
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) + "%";

/// <inheritdoc/>
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);

/// <inheritdoc/>
public override int GetHashCode()
Expand All @@ -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();
}

Expand Down
10 changes: 10 additions & 0 deletions MarkupString/Layout/Blocks/Block.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,16 @@ public sealed record LayoutContext

private bool ShadesLines => Shading is GradientTarget.All or GradientTarget.Borders;

/// <summary>
/// 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
/// (<see cref="ColorGradient.Roles"/>) is drawn in it.
/// </summary>
public Func<ThemeRole, IColorMarkup?>? Tones { get; init; }

/// <summary><paramref name="gradient"/> in this reader's theme colours (<see cref="Tones"/>).</summary>
internal ColorGradient Toned(ColorGradient gradient) => Tones is { } tones ? gradient.Toned(tones) : gradient;

/// <summary>Where text with no alignment of its own sits: a table column's, or one set with <see cref="BlockExtensions.Aligned"/>.</summary>
public Alignment TextAlignment { get; init; } = Alignment.Left;

Expand Down
28 changes: 24 additions & 4 deletions MarkupString/Layout/Blocks/BlockCodec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -313,6 +321,13 @@ public ImmutableArray<MarkupText> Texts(string name) =>
? [.. list.EnumerateArray().Where(item => item.ValueKind == JsonValueKind.Object).Select(item => MarkupTextSerializer.Read(item, _state.Registry))]
: [];

/// <summary>The theme colour each of a gradient's stops names, null for one that names none; empty when there are none.</summary>
internal ImmutableArray<ThemeRole?> 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)]
: [];

/// <summary>A child block.</summary>
public Block? Block(string name) =>
_element.ValueKind == JsonValueKind.Object && _element.TryGetProperty(name, out var value) ? Inner(value).Object() : null;
Expand Down Expand Up @@ -364,13 +379,18 @@ public TEnum Enum<TEnum>(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<IColorMarkup>().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<IColorMarkup>().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)],
};
}

Expand Down
2 changes: 1 addition & 1 deletion MarkupString/Layout/Blocks/Gauge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public override void Draw(LayoutContext context, int width, IList<MarkupText> 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));
Expand Down
7 changes: 4 additions & 3 deletions MarkupString/Layout/Blocks/Modifiers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ public override Block MapText(Func<MarkupText, MarkupText> map) =>
/// <inheritdoc/>
public override void Draw(LayoutContext context, int width, IList<MarkupText> lines)
{
if (Gradient.IsEmpty)
var gradient = context.Toned(Gradient);
if (gradient.IsEmpty)
{
context.Draw(Content, width, lines);
return;
Expand All @@ -49,11 +50,11 @@ public override void Draw(LayoutContext context, int width, IList<MarkupText> 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));
}
}
Expand Down
6 changes: 6 additions & 0 deletions MarkupString/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.ThemeRole?>
MarkupString.ColorGradient.Roles.init -> void
MarkupString.ColorGradient.Toned(System.Func<MarkupString.ThemeRole, MarkupString.IColorMarkup?>! tones) -> MarkupString.ColorGradient!
MarkupString.Layout.LayoutContext.Tones.get -> System.Func<MarkupString.ThemeRole, MarkupString.IColorMarkup?>?
MarkupString.Layout.LayoutContext.Tones.init -> void
Loading