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
26 changes: 25 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,31 @@ and `MarkupString.Pueblo`. The packages share one version and are released toget
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 2.21.0 — Unreleased
## 2.22.0 — Unreleased

### Added

- **`Shaded.Target`**: what a gradient over a layout falls on. `GradientTarget.All` (the default)
shades everything, `Borders` only the box drawing (borders and the rules across them, table column
separators and heading rules, tree guides, gauge brackets) and `Text` only the text, titles and
labels with it. The part left out keeps its own colour or its theme's. The HTML writes
`ms-shade-borders` or `ms-shade-text` beside `ms-shaded`, and the stylesheet clips only that part to
the gradient.

### Fixed

- **A gradient over a themed layout shades the whole of it.** The colours a `LayoutTheme` gives a
layout's parts (its border, title, labels, headings, bullets, guides) were laid inside a `Shaded`
block's gradient and won over it, so a box drawn in a game's theme showed the gradient only in its
plain text. Inside a `Shaded` block those part colours are now left off; colour the text sets
itself, and a table's stripe, are still kept.
- **A gradient runs over what a layout draws, not the padding after it.** A table narrower than the
width it was laid out at was shaded across that whole width, so `across` and `diagonal` reached
only the first few colours over its columns. In HTML a shaded block holding nothing that fills the
width (no box, rule, flex row or gauge), a table say, is as wide as what it holds, for the same
reason.

## 2.21.0

### Added

Expand Down
15 changes: 12 additions & 3 deletions MarkupString.Html/Emitters/LayoutHtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -595,10 +595,19 @@ private static void Shaded(Shaded shaded, HtmlLayoutWriter html)
GradientFlow.Diagonal => "to bottom right",
_ => "to right",
};
html.Write("<div class=\"ms-shaded\" style=\"--ms-shade:");
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:",
});
html.Write(shaded.Gradient.ToCss(9, direction));
html.Write(";background-image:var(--ms-shade);background-image:");
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)
{
html.Write(";background-image:var(--ms-shade);background-image:");
html.Write(shaded.Gradient.ToCss(direction));
}
html.Write("\">");
html.Block(shaded.Content);
html.Write("</div>");
Expand Down
6 changes: 4 additions & 2 deletions MarkupString.Html/LayoutCss.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@ public static class LayoutCss
".ms-grid.ms-down { display: block; }\n" +
".ms-grid > li { break-inside: avoid; white-space: pre-wrap; overflow-wrap: anywhere; }\n" +
".ms-pre { margin: 0; font: inherit; white-space: pre; overflow-x: auto; }\n" +
".ms-shaded { -webkit-background-clip: text; background-clip: text; color: transparent; }\n" +
".ms-shaded :is(.ms-box, .ms-gauge-bar), .ms-shaded :is(.ms-rule, .ms-divider)::before, .ms-shaded :is(.ms-rule, .ms-divider)::after, .ms-shaded .ms-line { border-image: var(--ms-shade) 1; }\n" +
".ms-shaded:not(:has(.ms-box, .ms-flex, .ms-rule, .ms-divider, .ms-gauge)) { width: fit-content; max-width: 100%; }\n" +
".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-table-wrap { max-width: 100%; overflow-x: auto; }\n" +
".ms-table { border-collapse: collapse; }\n" +
".ms-table.ms-fill { width: 100%; }\n" +
Expand Down
75 changes: 75 additions & 0 deletions MarkupString.Tests/Layout/CompositionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,81 @@ public async Task Shaded_ColoursTheBordersAndTextOfABlock()
await Assert.That(ansi).Contains("\u001b[38;2;0;0;255m+");
}

[Test]
public async Task Shaded_ATheme_ColoursNoneOfTheParts_SoTheGradientShows()
{
var green = AnsiMarkup.Create(foreground: new AnsiColor.Rgb(0, 128, 0));
var theme = new LayoutTheme { BorderColor = green, TitleColor = green };
var box = P("hi").ToBlock().Bordered(P("T"), BorderStyle.Ascii).Themed(theme).Shaded(RedToBlue);
var ansi = BlockLayout.Build(box, 8).Render(MarkupFormat.Ansi, Registry);

await Assert.That(ansi).DoesNotContain("38;2;0;128;0");
await Assert.That(ansi).Contains("\u001b[38;2;255;0;0m+");
await Assert.That(ansi).Contains("\u001b[38;2;0;0;255m+");
}

[Test]
public async Task Shaded_Across_RunsOverWhatIsDrawn_NotThePaddingAfterIt()
{
var ansi = BlockLayout.Build(P("abc").ToBlock().Shaded(RedToBlue), 40).Render(MarkupFormat.Ansi, Registry);

await Assert.That(ansi).Contains("\u001b[38;2;255;0;0ma");
await Assert.That(ansi).Contains("\u001b[38;2;0;0;255mc");
}

[Test]
public async Task Shaded_Borders_ColoursTheBoxDrawingAndLeavesTheTextInItsTheme()
{
var green = AnsiMarkup.Create(foreground: new AnsiColor.Rgb(0, 128, 0));
var box = P("hi").ToBlock().Bordered(P("T"), BorderStyle.Ascii).Themed(new LayoutTheme { BorderColor = green, TitleColor = green })
.Shaded(RedToBlue) with
{ Target = GradientTarget.Borders };
var text = BlockLayout.Build(box, 8);
var ansi = text.Render(MarkupFormat.Ansi, Registry);

await Assert.That(ansi).Contains("\u001b[38;2;255;0;0m+");
await Assert.That(ansi).Contains("\u001b[38;2;0;0;255m+");
await Assert.That(ansi).Contains("\u001b[38;2;0;128;0mT");
await Assert.That(ansi).Contains("\u001b[0m hi");
await Assert.That(text.Render(MarkupFormat.Html, Registry)).Contains("<div class=\"ms-shaded ms-shade-borders\" style=\"--ms-shade:linear-gradient(to right, #ff0000, ");
await Assert.That(text.Render(MarkupFormat.Html, Registry)).DoesNotContain("background-image");
}

[Test]
public async Task Shaded_Text_ColoursTheTextAndLeavesTheBoxDrawingInItsTheme()
{
var green = AnsiMarkup.Create(foreground: new AnsiColor.Rgb(0, 128, 0));
var table = new Table([new TableColumn(P("A")), new TableColumn(P("B"))], [[P("ab"), P("cd")]]) { Separator = P("|") };
var shaded = table.Themed(new LayoutTheme { SeparatorColor = green, HeaderRuleColor = green }).Shaded(RedToBlue) with { Target = GradientTarget.Text };
var text = BlockLayout.Build(shaded, 7);
var ansi = text.Render(MarkupFormat.Ansi, Registry);

await Assert.That(ansi).Contains("\u001b[38;2;0;128;0m|");
await Assert.That(ansi).Contains("\u001b[38;2;0;128;0m-");
await Assert.That(ansi).Contains("\u001b[38;2;255;0;0mA");
await Assert.That(ansi).Contains("\u001b[38;2;0;0;255md");
await Assert.That(text.Runs.SelectMany(run => run.Markups).OfType<IDataMarkup>()).IsEmpty();
}

[Test]
public async Task Shaded_Target_IsKeptThroughTheWire()
{
var box = P("hi").ToBlock().Bordered(border: BorderStyle.Ascii).Shaded(RedToBlue) with { Target = GradientTarget.Text };
var read = MarkupTextSerializer.Deserialize(MarkupTextSerializer.Serialize(BlockLayout.Build(box, 6), Registry), Registry);

await Assert.That(BlockLayout.AsBlock(read)).IsTypeOf<Shaded>();
await Assert.That(((Shaded)BlockLayout.AsBlock(read)).Target).IsEqualTo(GradientTarget.Text);
}

[Test]
public async Task Shaded_ColourTheTextSetsItself_IsKept()
{
var green = MarkupText.Wrap(AnsiMarkup.Create(foreground: new AnsiColor.Rgb(0, 128, 0)), "g");
var ansi = BlockLayout.Build(new TextBlock(MarkupText.Concat([P("a"), green, P("b")])).Shaded(RedToBlue), 3).Render(MarkupFormat.Ansi, Registry);

await Assert.That(ansi).Contains("\u001b[38;2;0;128;0mg");
}

[Test]
public async Task Shaded_InHtml_ClipsTheGradientToTheText()
{
Expand Down
25 changes: 22 additions & 3 deletions MarkupString/ColorGradient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ public enum GradientFlow
Diagonal,
}

/// <summary>Which parts of a layout a <see cref="Layout.Shaded"/> block's gradient colours.</summary>
public enum GradientTarget
{
/// <summary>Everything that shows: borders, rules and text alike.</summary>
All,

/// <summary>
/// The box drawing only: borders and the rules across them, table column separators and heading
/// rules, tree guides, gauge brackets. The text keeps its own colour, or its theme's.
/// </summary>
Borders,

/// <summary>The text only, titles and labels with it. The box drawing keeps its theme's colour.</summary>
Text,
}

/// <summary>
/// Colours blended one into the next, evenly spaced from 0 to 1. <see cref="At"/> gives the colour
/// at a point; <see cref="ToCss()"/> the same gradient for a page. Colours are blended in
Expand Down Expand Up @@ -137,8 +153,11 @@ public MarkupText Shade(MarkupText text, GradientFlow flow = GradientFlow.Charac
return MarkupText.Join(MarkupText.NewLine, ShadeLines(lines, flow, lines.Max(line => line.DisplayWidth)));
}

/// <summary>Each of <paramref name="lines"/> shaded as one block <paramref name="width"/> cells wide.</summary>
internal MarkupText[] ShadeLines(IReadOnlyList<MarkupText> lines, GradientFlow flow, int width)
/// <summary>
/// Each of <paramref name="lines"/> shaded as one block <paramref name="width"/> cells wide; given
/// <paramref name="shades"/>, only the characters it says yes to, which alone count along the way.
/// </summary>
internal MarkupText[] ShadeLines(IReadOnlyList<MarkupText> lines, GradientFlow flow, int width, Func<MarkupText, bool>? shades = null)
{
if (IsEmpty || lines.Count == 0) return [.. lines];

Expand All @@ -153,7 +172,7 @@ internal MarkupText[] ShadeLines(IReadOnlyList<MarkupText> lines, GradientFlow f
var inWord = false;
foreach (var character in lines[row].EnumerateGraphemes())
{
var visible = !string.IsNullOrWhiteSpace(character.ToPlainText());
var visible = !string.IsNullOrWhiteSpace(character.ToPlainText()) && (shades?.Invoke(character) ?? true);
if (visible && !inWord) words++;
inWord = visible;
cells[row].Add((row, visible ? column : -1, words - 1, character));
Expand Down
41 changes: 35 additions & 6 deletions MarkupString/Layout/Blocks/Block.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,18 @@ public sealed record LayoutContext
/// <summary>The look a block takes where it sets none of its own.</summary>
public LayoutTheme Theme { get; init; } = LayoutTheme.Default;

/// <summary>
/// Inside a <see cref="Shaded"/> block, the parts its gradient colours; null outside one. The theme's
/// colours for those parts are left off, as they would otherwise win over the gradient, and while only
/// some parts are shaded the box drawing is marked (<see cref="Line"/>) so the gradient can tell it
/// from the text. A stripe is a background, and stays.
/// </summary>
internal GradientTarget? Shading { get; init; }

private bool ShadesText => Shading is GradientTarget.All or GradientTarget.Text;

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

/// <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 Expand Up @@ -151,17 +163,32 @@ public TreeGuide Guide(TreeGuide? guide)

/// <summary>
/// <paramref name="text"/> in the theme's colour for <paramref name="part"/>, or as it is when the theme
/// sets none. Colour the text sets itself wins.
/// sets none or a gradient shades the text (<see cref="Shading"/>). Colour the text sets itself wins.
/// </summary>
/// <param name="part">The part's colour: <c>theme =&gt; theme.BorderColor</c>.</param>
/// <param name="text">The piece.</param>
public MarkupText Paint(Func<LayoutTheme, IMarkup?> part, MarkupText text)
{
ArgumentNullException.ThrowIfNull(part);
ArgumentNullException.ThrowIfNull(text);
return part(Theme) is { } markup && text.Length > 0 ? MarkupText.Wrap(markup, text) : text;
return !ShadesText && part(Theme) is { } markup && text.Length > 0 ? MarkupText.Wrap(markup, text) : text;
}

/// <summary>
/// A piece of box drawing outside a border (a gauge's bracket, a table's column separator or heading rule), in
/// the theme's colour for <paramref name="part"/> as <see cref="Paint(Func{LayoutTheme, IMarkup?}, MarkupText)"/>
/// draws it, unless a gradient shades the box drawing, and marked as box drawing (<see cref="Line"/>).
/// </summary>
internal MarkupText PaintLine(Func<LayoutTheme, IMarkup?> part, MarkupText text) =>
Line(!ShadesLines && part(Theme) is { } markup && text.Length > 0 ? MarkupText.Wrap(markup, text) : text);

/// <summary>
/// <paramref name="piece"/> marked as box drawing while a gradient shades only the box drawing or
/// only the text (<see cref="LinePiece"/>); as it is otherwise.
/// </summary>
internal MarkupText Line(MarkupText piece) =>
Shading is GradientTarget.Borders or GradientTarget.Text && piece.Length > 0 ? MarkupText.Wrap(LinePiece.Instance, piece) : piece;

/// <summary>
/// The lines from <paramref name="first"/> on, a row of a striped table or list, laid on the theme's
/// stripe colour when <paramref name="row"/> is odd (the second, the fourth, ...). Colour a cell sets
Expand All @@ -177,8 +204,9 @@ internal void Stripe(IList<MarkupText> lines, int first, int row)
/// <summary><paramref name="style"/> with every piece in the theme's border colour.</summary>
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;
var markup = ShadesLines ? null : Theme.BorderColor;
if (markup is null && Shading is null or GradientTarget.All) return style;
MarkupText Piece(MarkupText piece) => Line(markup is not null && piece.Length > 0 ? MarkupText.Wrap(markup, piece) : piece);
return style with
{
TopLeft = Piece(style.TopLeft),
Expand All @@ -199,8 +227,9 @@ internal BorderStyle Paint(BorderStyle style)
/// <summary><paramref name="guide"/> with every piece in the theme's guide colour.</summary>
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;
var markup = ShadesLines ? null : Theme.GuideColor;
if (markup is null && Shading is null or GradientTarget.All) return guide;
MarkupText Piece(MarkupText piece) => Line(markup is not null && 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) };
}
}
Expand Down
4 changes: 2 additions & 2 deletions MarkupString/Layout/Blocks/Gauge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ public override Block MapText(Func<MarkupText, MarkupText> map) =>
public override void Draw(LayoutContext context, int width, IList<MarkupText> lines)
{
var theme = context.Theme;
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 open = context.PaintLine(t => t.BorderColor, context.Glyph(Open ?? theme.Piece(t => t.GaugeOpen), "["));
var close = context.PaintLine(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;
Expand Down
7 changes: 6 additions & 1 deletion MarkupString/Layout/Blocks/LayoutJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,13 @@ public static IMarkup Read(JsonElement element, MarkupRegistry? registry)
w.Block("c", b.Content);
w.Gradient("gr", b.Gradient);
w.Enum("fl", b.Flow, GradientFlow.Across);
w.Enum("tg", b.Target);
},
r => new Shaded(r.Block("c") ?? new Stack([]), r.Gradient("gr") ?? new ColorGradient([])) { Flow = r.Enum("fl", GradientFlow.Across) }),
r => new Shaded(r.Block("c") ?? new Stack([]), r.Gradient("gr") ?? new ColorGradient([]))
{
Flow = r.Enum("fl", GradientFlow.Across),
Target = r.Enum("tg", GradientTarget.All),
}),
BlockCodec.Create<Colored>("colored",
(b, w) =>
{
Expand Down
44 changes: 41 additions & 3 deletions MarkupString/Layout/Blocks/Modifiers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ public override void Draw(LayoutContext context, int width, IList<MarkupText> li

/// <summary>
/// <paramref name="Content"/> drawn in the colours of <paramref name="Gradient"/>: its borders, rules
/// and text alike. Spaces are left alone, and colour the content sets itself is kept.
/// and text alike. Spaces are left alone, and colour the content sets itself is kept; the colours a
/// theme gives its parts are not, so a themed box is shaded borders and all.
/// </summary>
/// <param name="Content">What is shaded.</param>
/// <param name="Gradient">The colours.</param>
Expand All @@ -31,9 +32,30 @@ public override Block MapText(Func<MarkupText, MarkupText> map) =>
/// <summary>Which way the colours run.</summary>
public GradientFlow Flow { get; init; } = GradientFlow.Across;

/// <summary>Which parts the colours fall on: everything, the box drawing only, or the text only.</summary>
public GradientTarget Target { get; init; } = GradientTarget.All;

/// <inheritdoc/>
public override void Draw(LayoutContext context, int width, IList<MarkupText> lines) =>
lines.AddRange(Gradient.ShadeLines(context.Lines(Content, width), Flow, width));
public override void Draw(LayoutContext context, int width, IList<MarkupText> lines)
{
if (Gradient.IsEmpty)
{
context.Draw(Content, width, lines);
return;
}
// The colours run over what is drawn, not the padding after it: a table narrower than the
// screen is shaded across its own columns.
var drawn = (context with { Shading = Target }).Lines(Content, width);
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));
return;
}
var borders = Target == GradientTarget.Borders;
foreach (var line in Gradient.ShadeLines(drawn, Flow, used, character => LinePiece.Marks(character) == borders))
lines.Add(LinePiece.Unmark(line));
}
}

/// <summary><paramref name="Content"/> drawn under <paramref name="Markup"/> — a colour, say — which colour it sets itself overrides.</summary>
Expand Down Expand Up @@ -122,3 +144,19 @@ public static Shaded Shaded(this Block content, ColorGradient gradient, Gradient
/// <summary>Blocks side by side.</summary>
public static Flex InRow(this IEnumerable<Block> blocks) => new([.. blocks]);
}

/// <summary>
/// Marks a piece of box drawing while a <see cref="Shaded"/> block that shades only the box drawing, or
/// only the text, is drawn, so its gradient can tell the two apart. Taken off again once it is shaded.
/// </summary>
internal sealed record LinePiece : IDataMarkup
{
public static LinePiece Instance { get; } = new();

/// <summary>Whether <paramref name="character"/> is box drawing.</summary>
public static bool Marks(MarkupText character) =>
!character.Runs.IsDefaultOrEmpty && character.Runs.Any(run => run.Markups.Any(markup => markup is LinePiece));

/// <summary><paramref name="line"/> without the mark.</summary>
public static MarkupText Unmark(MarkupText line) => line.WithoutMarkup(markup => markup is LinePiece);
}
Loading
Loading