diff --git a/CHANGELOG.md b/CHANGELOG.md index b99a240..dba2c52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/MarkupString.Ansi/AnsiRegistration.cs b/MarkupString.Ansi/AnsiRegistration.cs index fb462b5..7573f87 100644 --- a/MarkupString.Ansi/AnsiRegistration.cs +++ b/MarkupString.Ansi/AnsiRegistration.cs @@ -63,6 +63,7 @@ public static MarkupRegistry WithAnsiOutput(this MarkupRegistry registry, AnsiCo /// Returns a registry that writes for one client as describe it: every colour at its /// depth in , and , /// and, in , links and pictures in the forms its terminal reads. + /// Pictures are also available as a fallback when MXP refuses its IMAGE element. /// /// The registry to add to, one has already filled. /// What the client is sent. @@ -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)); } diff --git a/MarkupString.Ansi/Emitters/ImageAnsiEmitter.cs b/MarkupString.Ansi/Emitters/ImageAnsiEmitter.cs new file mode 100644 index 0000000..871c965 --- /dev/null +++ b/MarkupString.Ansi/Emitters/ImageAnsiEmitter.cs @@ -0,0 +1,37 @@ +using System.Buffers; +namespace MarkupString.Ansi; + +/// The terminal picture fallback for a dialect that cannot draw an image itself. +internal sealed class ImageAnsiEmitter(AnsiOutputOptions options) : IMarkupEmitter +{ + public Type MarkupType => typeof(ImageMarkup); + public MarkupFormat Format => MarkupFormat.Ansi; + + public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context, IBufferWriter 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); + } + } +} diff --git a/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs b/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs index b9d0a82..788c7b5 100644 --- a/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs +++ b/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs @@ -67,7 +67,9 @@ public void Emit(IMarkup markup, ReadOnlySpan 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; } diff --git a/MarkupString.Mxp/MxpRegistration.cs b/MarkupString.Mxp/MxpRegistration.cs index 0c8c2c0..2a9cb2f 100644 --- a/MarkupString.Mxp/MxpRegistration.cs +++ b/MarkupString.Mxp/MxpRegistration.cs @@ -23,7 +23,8 @@ public static class MxpRegistration /// MXP lets a client say what it can render, and is where that /// answer comes in: it is asked with an element's name from , 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. /// diff --git a/MarkupString.Mxp/README.md b/MarkupString.Mxp/README.md index 4f0b046..1696141 100644 --- a/MarkupString.Mxp/README.md +++ b/MarkupString.Mxp/README.md @@ -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 | diff --git a/MarkupString.Tests/Ansi/TerminalFeatureTests.cs b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs index fd3888f..1d48171 100644 --- a/MarkupString.Tests/Ansi/TerminalFeatureTests.cs +++ b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using MarkupString.Ansi; using MarkupString.Layout; +using MarkupString.Mxp; /// /// What a terminal is sent beyond colour, by what its connection says it can do: OSC 8 and MSLP links, and @@ -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("look"); + } + + [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(" 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]