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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Fixed

- MXP images a client does not support can fall back to configured ANSI terminal graphics, preserving
the other MXP elements. The fallback requires both enabled graphics and available picture pixels.


### Added

- **Output effects.** `OutputEffect` is a C# union of what output asks of a client besides text: the points
Expand Down
2 changes: 2 additions & 0 deletions MarkupString.Ansi/AnsiRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public static MarkupRegistry WithAnsiOutput(this MarkupRegistry registry, AnsiCo
/// Returns a registry that writes for one client as <paramref name="options"/> describe it: every colour at its
/// depth in <see cref="MarkupFormat.Ansi"/>, <see cref="MarkupFormat.Pueblo"/> and <see cref="MarkupFormat.Mxp"/>,
/// and, in <see cref="MarkupFormat.Ansi"/>, links and pictures in the forms its terminal reads.
/// Pictures are also available as a fallback when MXP refuses its IMAGE element.
/// </summary>
/// <param name="registry">The registry to add to, one <see cref="WithAnsi"/> has already filled.</param>
/// <param name="options">What the client is sent.</param>
Expand All @@ -73,6 +74,7 @@ public static MarkupRegistry WithAnsiOutput(this MarkupRegistry registry, AnsiOu

return registry
.With(new AnsiSetEmitter(options))
.With(new ImageAnsiEmitter(options))
.With(new AnsiPuebloEmitter(options.ColorDepth))
.With(new AnsiMxpEmitter(options.ColorDepth));
}
Expand Down
37 changes: 37 additions & 0 deletions MarkupString.Ansi/Emitters/ImageAnsiEmitter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Buffers;
namespace MarkupString.Ansi;

/// <summary>The terminal picture fallback for a dialect that cannot draw an image itself.</summary>
internal sealed class ImageAnsiEmitter(AnsiOutputOptions options) : IMarkupEmitter
{
public Type MarkupType => typeof(ImageMarkup);
public MarkupFormat Format => MarkupFormat.Ansi;

public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output)
{
// ANSI's set emitter has already drawn the picture before wrapping its remaining layers.
if (context.Format == MarkupFormat.Ansi)
{
output.Write(body);
return;
}

var image = (ImageMarkup)markup;
var method = TerminalPictureWriter.Method(options.ColorDepth is AnsiColorDepth.Attributes or AnsiColorDepth.None
? options.Features & ~TerminalFeatures.BlockArt : options.Features);
if (method == TerminalFeatures.None || image.Row is not { } cells || !TerminalPictureWriter.CanDraw(method, cells)
|| options.Pictures is null || !options.Pictures.TryGetPicture(image with { Row = null }, out var pixels))
{
output.Write(body);
return;
}

if (context.StartsRegion(image))
{
var style = AnsiEmitterSupport.Fold(context.Markups, context.Format).AtDepth(options.ColorDepth);
SgrWriter.Transition(AnsiStyle.None, style, output);
TerminalPictureWriter.Write(method, cells, pixels, style, options, output, output);
if (AnsiEmitterSupport.LeavesState(style)) SgrWriter.Reset(output);
}
}
}
4 changes: 3 additions & 1 deletion MarkupString.Mxp/Emitters/ElementMxpEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context
{
if (!Supports("IMAGE"))
{
output.Write(body);
if (context.Registry.FindEmitter(typeof(ImageMarkup), MarkupFormat.Ansi) is { } fallback)
fallback.Emit(markup, body, context, output);
else output.Write(body);
return;
}

Expand Down
3 changes: 2 additions & 1 deletion MarkupString.Mxp/MxpRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ public static class MxpRegistration
/// <para>MXP lets a client say what it can render, and <paramref name="supports"/> is where that
/// answer comes in: it is asked with an element's name from <see cref="Elements"/>, upper case, and an
/// element it refuses is written as a format without MXP writes it — nothing for a sound, the
/// description for a picture, the text in the main window for a pane. The answer belongs to one
/// description for a picture, the text in the main window for a pane. An unsupported picture first
/// delegates to a registered ANSI image emitter, allowing enabled terminal graphics as a fallback. The answer belongs to one
/// connection, so a server builds a registry per answer. With no predicate, every element is written,
/// which is right for a client that was never asked.</para>
/// </remarks>
Expand Down
6 changes: 6 additions & 0 deletions MarkupString.Mxp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ An element the client refused is written as a format without MXP writes it: noth
description for a picture, the text in the main window for a pane. With no predicate every element is
written. Asking belongs to the telnet layer, and the answer to one connection.

For a client that refuses `IMAGE` but renders terminal graphics, also configure
`WithAnsiOutput(new AnsiOutputOptions(depth, features) { Pictures = source })`. Laid-out image rows
then use the enabled Sixel, iTerm2, Kitty or block-art renderer while links and other supported elements
remain MXP. Without pixels or an enabled graphics method, the picture keeps its text art. A client
that supports `IMAGE` continues receiving MXP images.

## Elements

| Factory | Written as |
Expand Down
42 changes: 42 additions & 0 deletions MarkupString.Tests/Ansi/TerminalFeatureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Text.RegularExpressions;
using MarkupString.Ansi;
using MarkupString.Layout;
using MarkupString.Mxp;

/// <summary>
/// What a terminal is sent beyond colour, by what its connection says it can do: OSC 8 and MSLP links, and
Expand Down Expand Up @@ -54,6 +55,47 @@ private static string RenderString(MarkupText text, AnsiOutputOptions options) =
private static MarkupText Laid(Block block, int width, PictureCells? cells = null) =>
BlockLayout.Build(block, width, context: new LayoutContext { Pictures = (_, _) => cells ?? new PictureCells(4, 2) });

[Test]
[Arguments(TerminalFeatures.InlineImages, "\e]1337;")]
[Arguments(TerminalFeatures.Sixel, "\eP")]
public async Task MxpWithoutImageUsesTerminalPictures(TerminalFeatures feature, string sequence)
{
var registry = MarkupRegistry.Empty.WithAnsi().WithMxp(_ => false).WithMxpSecureLines()
.WithAnsiOutput(new AnsiOutputOptions(Features: feature) { Pictures = new Source(RedBlue()) });
var figure = Laid(new Figure(Cat, MarkupText.Plain("cat")), 20);
var link = MarkupText.Wrap(AnsiMarkup.Create(linkUrl: "look", linkKind: LinkKind.Command), "look");
var rendered = MarkupText.Concat(figure, link).Render(MarkupFormat.Mxp, registry);
await Assert.That(rendered).Contains(sequence).And.DoesNotContain("<IMAGE");
await Assert.That(rendered).Contains("<SEND HREF=\"look\">look</SEND>");
}

[Test]
[Arguments(true, true, true)]
[Arguments(false, false, true)]
[Arguments(false, true, false)]
public async Task MxpPrefersSupportedImagesAndKeepsArtWhenFallbackUnavailable(bool supportsImage, bool enabled, bool available)
{
var registry = MarkupRegistry.Empty.WithAnsi().WithMxp(_ => supportsImage)
.WithAnsiOutput(new AnsiOutputOptions(Features: enabled ? TerminalFeatures.InlineImages : TerminalFeatures.None)
{ Pictures = new Source(available ? RedBlue() : null) });
var output = Laid(new Figure(Cat, MarkupText.Plain("cat")), 20).Render(MarkupFormat.Mxp, registry);
await Assert.That(output.Contains("<IMAGE")).IsEqualTo(supportsImage);
await Assert.That(output).DoesNotContain("\e]1337;");
if (!supportsImage) await Assert.That(output).Contains("cat");
}

[Test]
public async Task MxpBlockArtPreservesTheSurroundingBackground()
{
var pixels = new TerminalPicture("transparent", 2, 2,
new byte[] { 255, 0, 0, 255, 0, 0, 0, 0, 0, 0, 255, 255, 0, 0, 0, 0 });
var registry = MarkupRegistry.Empty.WithAnsi().WithMxp(_ => false)
.WithAnsiOutput(new AnsiOutputOptions(Features: TerminalFeatures.BlockArt) { Pictures = new Source(pixels) });
var laid = Laid(new Figure(Cat, MarkupText.Plain("cat")), 2, new PictureCells(2, 1));
var styled = MarkupText.Wrap(AnsiMarkup.Create(background: new AnsiColor.Standard(2, false)), laid);
await Assert.That(styled.Render(MarkupFormat.Mxp, registry).Split("42m").Length).IsGreaterThanOrEqualTo(3);
}

// ── Links ───────────────────────────────────────────────────────────────────

[Test]
Expand Down
Loading