diff --git a/CHANGELOG.md b/CHANGELOG.md index e51948d..8b8b511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/MarkupString.Html/Emitters/LayoutHtml.cs b/MarkupString.Html/Emitters/LayoutHtml.cs index 999cf4d..897089e 100644 --- a/MarkupString.Html/Emitters/LayoutHtml.cs +++ b/MarkupString.Html/Emitters/LayoutHtml.cs @@ -595,10 +595,19 @@ private static void Shaded(Shaded shaded, HtmlLayoutWriter html) GradientFlow.Diagonal => "to bottom right", _ => "to right", }; - html.Write("
"
"
"
"); html.Block(shaded.Content); html.Write("
"); diff --git a/MarkupString.Html/LayoutCss.cs b/MarkupString.Html/LayoutCss.cs index 87392c3..6d45099 100644 --- a/MarkupString.Html/LayoutCss.cs +++ b/MarkupString.Html/LayoutCss.cs @@ -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" + diff --git a/MarkupString.Tests/Layout/CompositionTests.cs b/MarkupString.Tests/Layout/CompositionTests.cs index 30ddac3..2036c28 100644 --- a/MarkupString.Tests/Layout/CompositionTests.cs +++ b/MarkupString.Tests/Layout/CompositionTests.cs @@ -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("
run.Markups).OfType()).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(); + 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() { diff --git a/MarkupString/ColorGradient.cs b/MarkupString/ColorGradient.cs index d895ba4..4e75077 100644 --- a/MarkupString/ColorGradient.cs +++ b/MarkupString/ColorGradient.cs @@ -64,6 +64,22 @@ public enum GradientFlow Diagonal, } +/// Which parts of a layout a block's gradient colours. +public enum GradientTarget +{ + /// Everything that shows: borders, rules and text alike. + All, + + /// + /// 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. + /// + Borders, + + /// The text only, titles and labels with it. The box drawing keeps its theme's colour. + Text, +} + /// /// Colours blended one into the next, evenly spaced from 0 to 1. gives the colour /// at a point; the same gradient for a page. Colours are blended in @@ -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))); } - /// Each of shaded as one block cells wide. - internal MarkupText[] ShadeLines(IReadOnlyList lines, GradientFlow flow, int width) + /// + /// Each of shaded as one block cells wide; given + /// , only the characters it says yes to, which alone count along the way. + /// + internal MarkupText[] ShadeLines(IReadOnlyList lines, GradientFlow flow, int width, Func? shades = null) { if (IsEmpty || lines.Count == 0) return [.. lines]; @@ -153,7 +172,7 @@ internal MarkupText[] ShadeLines(IReadOnlyList 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)); diff --git a/MarkupString/Layout/Blocks/Block.cs b/MarkupString/Layout/Blocks/Block.cs index 56e0106..8b8de8c 100644 --- a/MarkupString/Layout/Blocks/Block.cs +++ b/MarkupString/Layout/Blocks/Block.cs @@ -95,6 +95,18 @@ public sealed record LayoutContext /// The look a block takes where it sets none of its own. public LayoutTheme Theme { get; init; } = LayoutTheme.Default; + /// + /// Inside a 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 () so the gradient can tell it + /// from the text. A stripe is a background, and stays. + /// + 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; + /// Where text with no alignment of its own sits: a table column's, or one set with . public Alignment TextAlignment { get; init; } = Alignment.Left; @@ -151,7 +163,7 @@ public TreeGuide Guide(TreeGuide? guide) /// /// in the theme's colour for , or as it is when the theme - /// sets none. Colour the text sets itself wins. + /// sets none or a gradient shades the text (). Colour the text sets itself wins. /// /// The part's colour: theme => theme.BorderColor. /// The piece. @@ -159,9 +171,24 @@ 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; + return !ShadesText && part(Theme) is { } markup && text.Length > 0 ? MarkupText.Wrap(markup, text) : text; } + /// + /// 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 as + /// draws it, unless a gradient shades the box drawing, and marked as box drawing (). + /// + internal MarkupText PaintLine(Func part, MarkupText text) => + Line(!ShadesLines && part(Theme) is { } markup && text.Length > 0 ? MarkupText.Wrap(markup, text) : text); + + /// + /// marked as box drawing while a gradient shades only the box drawing or + /// only the text (); as it is otherwise. + /// + internal MarkupText Line(MarkupText piece) => + Shading is GradientTarget.Borders or GradientTarget.Text && piece.Length > 0 ? MarkupText.Wrap(LinePiece.Instance, piece) : piece; + /// /// 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 @@ -177,8 +204,9 @@ internal void Stripe(IList lines, int first, int row) /// 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; + 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), @@ -199,8 +227,9 @@ internal BorderStyle Paint(BorderStyle style) /// 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; + 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) }; } } diff --git a/MarkupString/Layout/Blocks/Gauge.cs b/MarkupString/Layout/Blocks/Gauge.cs index 83fd452..ae3fa1a 100644 --- a/MarkupString/Layout/Blocks/Gauge.cs +++ b/MarkupString/Layout/Blocks/Gauge.cs @@ -53,8 +53,8 @@ public override Block MapText(Func map) => public override void Draw(LayoutContext context, int width, IList 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; diff --git a/MarkupString/Layout/Blocks/LayoutJson.cs b/MarkupString/Layout/Blocks/LayoutJson.cs index 0cc76c3..16274ff 100644 --- a/MarkupString/Layout/Blocks/LayoutJson.cs +++ b/MarkupString/Layout/Blocks/LayoutJson.cs @@ -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", (b, w) => { diff --git a/MarkupString/Layout/Blocks/Modifiers.cs b/MarkupString/Layout/Blocks/Modifiers.cs index 0258b56..2e6e590 100644 --- a/MarkupString/Layout/Blocks/Modifiers.cs +++ b/MarkupString/Layout/Blocks/Modifiers.cs @@ -18,7 +18,8 @@ public override void Draw(LayoutContext context, int width, IList li /// /// drawn in the colours of : 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. /// /// What is shaded. /// The colours. @@ -31,9 +32,30 @@ public override Block MapText(Func map) => /// Which way the colours run. public GradientFlow Flow { get; init; } = GradientFlow.Across; + /// Which parts the colours fall on: everything, the box drawing only, or the text only. + public GradientTarget Target { get; init; } = GradientTarget.All; + /// - public override void Draw(LayoutContext context, int width, IList lines) => - lines.AddRange(Gradient.ShadeLines(context.Lines(Content, width), Flow, width)); + public override void Draw(LayoutContext context, int width, IList 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)); + } } /// drawn under — a colour, say — which colour it sets itself overrides. @@ -122,3 +144,19 @@ public static Shaded Shaded(this Block content, ColorGradient gradient, Gradient /// Blocks side by side. public static Flex InRow(this IEnumerable blocks) => new([.. blocks]); } + +/// +/// Marks a piece of box drawing while a 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. +/// +internal sealed record LinePiece : IDataMarkup +{ + public static LinePiece Instance { get; } = new(); + + /// Whether is box drawing. + public static bool Marks(MarkupText character) => + !character.Runs.IsDefaultOrEmpty && character.Runs.Any(run => run.Markups.Any(markup => markup is LinePiece)); + + /// without the mark. + public static MarkupText Unmark(MarkupText line) => line.WithoutMarkup(markup => markup is LinePiece); +} diff --git a/MarkupString/Layout/Blocks/Table.cs b/MarkupString/Layout/Blocks/Table.cs index 12bd998..e3e333e 100644 --- a/MarkupString/Layout/Blocks/Table.cs +++ b/MarkupString/Layout/Blocks/Table.cs @@ -47,7 +47,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.Paint(theme => theme.SeparatorColor, context.Glyph(drawn, " | ")) : BlockText.Blank(Gap); + var divider = Separator is { } drawn ? context.PaintLine(theme => theme.SeparatorColor, context.Glyph(drawn, " | ")) : BlockText.Blank(Gap); if (Widths(context, width, divider.DisplayWidth) is not { } widths) { DrawCards(context, width, lines); @@ -61,7 +61,7 @@ public override void Draw(LayoutContext context, int width, IList li 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(context.Paint(theme => theme.HeaderRuleColor, BlockText.Run(context.Glyph(rule, "-"), tableWidth)), width)); + if (rule.Length > 0) lines.Add(BlockText.Fit(context.PaintLine(theme => theme.HeaderRuleColor, BlockText.Run(context.Glyph(rule, "-"), tableWidth)), width)); var cellLines = new List[Columns.Length]; var index = 0; diff --git a/MarkupString/MarkupText.cs b/MarkupString/MarkupText.cs index f276449..b7c62a1 100644 --- a/MarkupString/MarkupText.cs +++ b/MarkupString/MarkupText.cs @@ -197,29 +197,32 @@ public MarkupText WithoutVisualMarkup() /// The text with every data layer () taken off and everything else kept: the /// counterpart of , for text whose data must not be trusted. /// - public MarkupText WithoutDataMarkup() + public MarkupText WithoutDataMarkup() => WithoutMarkup(static markup => markup is IDataMarkup); + + /// The text with every layer says yes to taken off and everything else kept. + internal MarkupText WithoutMarkup(Func drop) { if (Runs.IsDefaultOrEmpty) return this; - var hasData = false; + var dropping = false; foreach (var run in Runs) { foreach (var markup in run.Markups) { - if (markup is not IDataMarkup) continue; - hasData = true; + if (!drop(markup)) continue; + dropping = true; break; } - if (hasData) break; + if (dropping) break; } - if (!hasData) return this; + if (!dropping) return this; var builder = ImmutableArray.CreateBuilder(Runs.Length); foreach (var run in Runs) { var kept = new List(run.Markups.Count); foreach (var markup in run.Markups) - if (markup is not IDataMarkup) kept.Add(markup); + if (!drop(markup)) kept.Add(markup); if (kept.Count == run.Markups.Count) builder.Add(run); else if (kept.Count > 0) builder.Add(run with { Markups = MarkupSet.Of(kept) }); } diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index 9acac77..6054a51 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -4,3 +4,9 @@ static readonly MarkupString.MarkupFormat.Markdown -> MarkupString.MarkupFormat! MarkupString.IDataMarkup MarkupString.MarkupText.WithoutVisualMarkup() -> MarkupString.MarkupText! MarkupString.MarkupText.WithoutDataMarkup() -> MarkupString.MarkupText! +MarkupString.GradientTarget +MarkupString.GradientTarget.All = 0 -> MarkupString.GradientTarget +MarkupString.GradientTarget.Borders = 1 -> MarkupString.GradientTarget +MarkupString.GradientTarget.Text = 2 -> MarkupString.GradientTarget +MarkupString.Layout.Shaded.Target.get -> MarkupString.GradientTarget +MarkupString.Layout.Shaded.Target.init -> void