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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@ 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).

## Unreleased

### Added

- **Terminal features beyond colour.** `AnsiOutputOptions` describes one client: its colour depth,
its `TerminalFeatures`, and where pictures' pixels come from (`ITerminalPictureSource`).
`WithAnsiOutput(options)` builds its registry; the `(depth, hyperlinks)` overload is that with
`Hyperlinks` or `None`.
- `CommandLinks` writes a command link as an MSLP link (`ESC ] 68 ; 1 ; SEND ; …`), which a client
advertising MTTS's MSLP bit sends back when clicked. A command holding a control character is
written as its text.
- **Pictures in a figure's cells.** A `Figure` laid out under `LayoutContext.Pictures` reserves the
cells its picture takes (its art's, or `PictureCells` for one with none) and marks each row with
`PictureCellsMarkup`. In `MarkupFormat.Ansi` such a row is drawn as the picture for a client with
`KittyGraphics` (Unicode placeholders, the picture sent once per connection), `InlineImages`
(iTerm2), `Sixel`, or `BlockArt` (half blocks), and as the art otherwise. The rows are the same
width either way, so a box or a flex row around the figure stays aligned.
- Each encoding is made once per picture and size and kept with the `TerminalPicture`, so a picture
shown to many connections is resized and compressed once. PNGs (iTerm2, and Kitty's `f=100`) are
RGB when the picture is opaque, filtered Up, at zlib level 2; sixel reads each band once and writes a
colour only over the columns it reaches.

## 2.11.2 — 2026-10-07

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion MarkupString.Ansi/AnsiColorDepth.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ namespace MarkupString.Ansi;

/// <summary>
/// How much colour a client can display. A registry built with
/// <see cref="AnsiRegistration.WithAnsiOutput"/> writes every colour at the depth it is given, so a client
/// <see cref="AnsiRegistration.WithAnsiOutput(MarkupRegistry, AnsiOutputOptions)"/> writes every colour at the depth it is given, so a client
/// is never sent a sequence it cannot read.
/// </summary>
public enum AnsiColorDepth
Expand Down
24 changes: 24 additions & 0 deletions MarkupString.Ansi/AnsiOutputOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace MarkupString.Ansi;

/// <summary>What one client is sent in <see cref="MarkupFormat.Ansi"/>: how much colour, and what else its terminal can do.</summary>
/// <param name="ColorDepth">The colour the client can display; every colour is written at this depth.</param>
/// <param name="Features">What the terminal can do beyond colour.</param>
public sealed record AnsiOutputOptions(
AnsiColorDepth ColorDepth = AnsiColorDepth.TrueColor,
TerminalFeatures Features = TerminalFeatures.Hyperlinks)
{
/// <summary>
/// Where pictures' pixels come from, for a client with any of <see cref="TerminalFeatures.Pictures"/>. Without
/// one, a picture is its text art whatever the client can draw.
/// </summary>
public ITerminalPictureSource? Pictures { get; init; }

/// <summary>
/// The width of the terminal's character cell in pixels, for a picture drawn in pixels (sixel). 10 when unknown,
/// which a terminal that does not know its own reports as 0: a value below 1 is taken as unknown.
/// </summary>
public int CellWidth { get; init => field = value > 0 ? value : 10; } = 10;

/// <summary>The height of the terminal's character cell in pixels, for a picture drawn in pixels (sixel). 20 when unknown, as is a value below 1.</summary>
public int CellHeight { get; init => field = value > 0 ? value : 20; } = 20;
}
19 changes: 15 additions & 4 deletions MarkupString.Ansi/AnsiRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,24 @@ public static MarkupRegistry WithAnsi(this MarkupRegistry registry)
/// Whether a URL link is written as an OSC 8 hyperlink in <see cref="MarkupFormat.Ansi"/>; when false, its
/// text is written alone. Pueblo and MXP write links as their own tags either way.
/// </param>
public static MarkupRegistry WithAnsiOutput(this MarkupRegistry registry, AnsiColorDepth colorDepth, bool hyperlinks = true)
public static MarkupRegistry WithAnsiOutput(this MarkupRegistry registry, AnsiColorDepth colorDepth, bool hyperlinks = true) =>
registry.WithAnsiOutput(new AnsiOutputOptions(colorDepth, hyperlinks ? TerminalFeatures.Hyperlinks : TerminalFeatures.None));

/// <summary>
/// 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.
/// </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>
public static MarkupRegistry WithAnsiOutput(this MarkupRegistry registry, AnsiOutputOptions options)
{
ArgumentNullException.ThrowIfNull(registry);
ArgumentNullException.ThrowIfNull(options);

return registry
.With(new AnsiSetEmitter(colorDepth, hyperlinks))
.With(new AnsiPuebloEmitter(colorDepth))
.With(new AnsiMxpEmitter(colorDepth));
.With(new AnsiSetEmitter(options))
.With(new AnsiPuebloEmitter(options.ColorDepth))
.With(new AnsiMxpEmitter(options.ColorDepth));
}
}
53 changes: 41 additions & 12 deletions MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,26 +194,55 @@ internal static void WriteWrapped(
}

/// <summary>
/// Writes the body inside an OSC 8 hyperlink when the style carries a navigable URL. OSC 8 can
/// only navigate, so a command link — and any URL with a scheme
/// <see cref="UrlSafety.IsSafeNavigableUrl"/> rejects — is written as plain text.
/// Writes the body as the link the style carries, in whichever form <paramref name="features"/> allows: a
/// navigable URL inside an OSC 8 hyperlink, a command as an MSLP link (<c>ESC ] 68 ; 1 ; SEND ; command BEL</c>
/// before the text, which MSLP delimits with underline). Anything else — no link, a form the client does not
/// read, a URL with a scheme <see cref="UrlSafety.IsSafeNavigableUrl"/> rejects, a command holding a control
/// character that would end the sequence early — is written as plain text.
/// </summary>
internal static void WriteHyperlinked(in AnsiStyle style, ReadOnlySpan<char> body, IBufferWriter<char> output)
internal static void WriteLinked(in AnsiStyle style, ReadOnlySpan<char> body, TerminalFeatures features, IBufferWriter<char> output)
{
if (style.LinkKind != LinkKind.Url
|| style.LinkUrl is not { Length: > 0 } url
|| !UrlSafety.IsSafeNavigableUrl(url))
if (style.LinkUrl is not { Length: > 0 } target)
{
output.Write(body);
return;
}

output.Write(Osc8);
output.Write(url);
output.Write(Bel);
if (style.LinkKind == LinkKind.Url && (features & TerminalFeatures.Hyperlinks) != 0 && UrlSafety.IsSafeNavigableUrl(target))
{
output.Write(Osc8);
output.Write(target);
output.Write(Bel);
output.Write(body);
output.Write(Osc8);
output.Write(Bel);
return;
}

if (style.LinkKind == LinkKind.Command && (features & TerminalFeatures.CommandLinks) != 0 && !HasControl(target))
{
output.Write(MslpSend);
output.Write(target);
output.Write(Bel);
output.Write(UnderlineOn);
output.Write(body);
// The underline is the link's extent. A run that was underlined anyway stays so.
output.Write(style.Underlined ? UnderlineOff + UnderlineOn : UnderlineOff);
return;
}

output.Write(body);
output.Write(Osc8);
output.Write(Bel);
}

private const string MslpSend = "\e]68;1;SEND;";
private const string UnderlineOn = "\e[4m";
private const string UnderlineOff = "\e[24m";

private static bool HasControl(string text)
{
foreach (var c in text)
if (char.IsControl(c)) return true;
return false;
}

/// <summary>
Expand Down
62 changes: 54 additions & 8 deletions MarkupString.Ansi/Emitters/AnsiSetEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,36 @@ namespace MarkupString.Ansi;
/// the stream carries only what actually changes. The state is closed with <c>ESC[0m</c> when the
/// run is the last one or plain text follows it; between adjacent runs the next run's diff does it.
/// </summary>
/// <param name="colorDepth">The colour the client can display; each style is written at this depth.</param>
/// <param name="hyperlinks">Whether a URL link is written as an OSC 8 hyperlink, or as its text alone.</param>
public sealed class AnsiSetEmitter(AnsiColorDepth colorDepth, bool hyperlinks) : IMarkupSetEmitter
/// <remarks>
/// Links and pictures are written as the client's <see cref="AnsiOutputOptions.Features"/> allow: a URL link
/// as OSC 8, a command link as MSLP, a row of a picture's cells (<see cref="PictureCellsMarkup"/>) as the
/// picture. Without the feature, a link is its text and a picture its text art.
/// </remarks>
/// <param name="options">What the client is sent: its colour depth and what else its terminal can do.</param>
public sealed class AnsiSetEmitter(AnsiOutputOptions options) : IMarkupSetEmitter
{
/// <summary>Every colour as it is, and URL links as OSC 8 hyperlinks.</summary>
public AnsiSetEmitter() : this(AnsiColorDepth.TrueColor, hyperlinks: true)
public AnsiSetEmitter() : this(new AnsiOutputOptions())
{
}

/// <summary>Every colour at <paramref name="colorDepth"/>, and URL links as OSC 8 hyperlinks or as their text alone.</summary>
/// <param name="colorDepth">The colour the client can display; each style is written at this depth.</param>
/// <param name="hyperlinks">Whether a URL link is written as an OSC 8 hyperlink, or as its text alone.</param>
public AnsiSetEmitter(AnsiColorDepth colorDepth, bool hyperlinks)
: this(new AnsiOutputOptions(colorDepth, hyperlinks ? TerminalFeatures.Hyperlinks : TerminalFeatures.None))
{
}

private readonly AnsiOutputOptions _options = options ?? throw new ArgumentNullException(nameof(options));
// Half blocks are colour and nothing else: at a depth without colour they are a grid of identical blocks,
// and the figure's text art says more.
private readonly TerminalFeatures _pictureMethod = options.Pictures is null
? TerminalFeatures.None
: TerminalPictureWriter.Method(options.ColorDepth is AnsiColorDepth.Attributes or AnsiColorDepth.None
? options.Features & ~TerminalFeatures.BlockArt
: options.Features);

/// <inheritdoc/>
public MarkupFormat Format => MarkupFormat.Ansi;

Expand All @@ -25,13 +46,23 @@ public bool TryEmit(MarkupSet set, ReadOnlySpan<char> body, in EmitContext conte
ArgumentNullException.ThrowIfNull(set);
ArgumentNullException.ThrowIfNull(output);

var effective = AnsiEmitterSupport.Fold(set, context.Format).AtDepth(colorDepth);
var previous = AnsiEmitterSupport.Fold(context.Previous, context.Format).AtDepth(colorDepth);
var depth = _options.ColorDepth;
var effective = AnsiEmitterSupport.Fold(set, context.Format).AtDepth(depth);
var previous = AnsiEmitterSupport.Fold(context.Previous, context.Format).AtDepth(depth);

using var core = new PooledCharWriter(body.Length + 32);
SgrWriter.Transition(previous, effective, core);
if (hyperlinks) AnsiEmitterSupport.WriteHyperlinked(effective, body, core);
else core.Write(body);
if (Picture(set) is { } picture)
{
// The run that starts the row draws all of it; the others in the same row draw nothing, so the row is
// as many cells as the text it stands over.
if (context.StartsRegion(picture.Cells))
TerminalPictureWriter.Write(_pictureMethod, picture.Cells, picture.Pixels, effective, _options, core, output);
}
else
{
AnsiEmitterSupport.WriteLinked(effective, body, _options.Features, core);
}

// Nothing follows that would diff this state away, so close it here rather than leaving the
// terminal coloured for whatever the connection writes next.
Expand All @@ -40,4 +71,19 @@ public bool TryEmit(MarkupSet set, ReadOnlySpan<char> body, in EmitContext conte
AnsiEmitterSupport.WriteWrapped(set, core.WrittenSpan, context, output);
return true;
}

/// <summary>The picture this run is a row of, when this client draws it and its pixels are to hand.</summary>
private (PictureCellsMarkup Cells, TerminalPicture Pixels)? Picture(MarkupSet set)
{
if (_pictureMethod == TerminalFeatures.None) return null;
for (var i = 0; i < set.Count; i++)
{
if (set[i] is not PictureCellsMarkup cells) continue;
return TerminalPictureWriter.CanDraw(_pictureMethod, cells)
&& _options.Pictures!.TryGetPicture(cells.Image, out var pixels)
? (cells, pixels)
: null;
}
return null;
}
}
70 changes: 70 additions & 0 deletions MarkupString.Ansi/Emitters/PictureEncodings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.Runtime.CompilerServices;
namespace MarkupString.Ansi;

/// <summary>
/// What a picture has been encoded as, kept with the picture so every connection shown it at the same size
/// shares one encoding. A host typically holds one <see cref="TerminalPicture"/> per picture and shows it to
/// many connections; resizing, compressing and encoding it is far dearer than copying the result.
/// </summary>
/// <remarks>
/// The encodings live exactly as long as the picture does (a <see cref="ConditionalWeakTable{TKey,TValue}"/>),
/// so a host that drops a picture drops them too. Each picture keeps at most <see cref="PerPicture"/>, the
/// least recently used going first, since a picture is seldom shown at more than a size or two.
/// </remarks>
internal static class PictureEncodings
{
/// <summary>How many encodings one picture keeps.</summary>
internal const int PerPicture = 64;

private static readonly ConditionalWeakTable<TerminalPicture, Cache> Caches = new();

/// <summary>
/// The encoding of <paramref name="picture"/> that <paramref name="key"/> names, made by <paramref name="make"/>
/// the first time. Two threads asking at once may both make it; one result is kept.
/// </summary>
internal static T GetOrAdd<TKey, T>(TerminalPicture picture, TKey key, Func<TerminalPicture, TKey, T> make)
where TKey : notnull
where T : class
{
var cache = Caches.GetValue(picture, static _ => new Cache());
lock (cache)
{
if (cache.TryGet(key) is T held) return held;
}

var made = make(picture, key);
lock (cache)
{
if (cache.TryGet(key) is T raced) return raced;
cache.Add(key, made);
}

return made;
}

/// <summary>A picture's encodings, most recently used last. Guarded by its own lock.</summary>
private sealed class Cache
{
private readonly List<(object Key, object Value)> _entries = [];

public object? TryGet(object key)
{
for (var i = _entries.Count - 1; i >= 0; i--)
{
if (!_entries[i].Key.Equals(key)) continue;
var entry = _entries[i];
_entries.RemoveAt(i);
_entries.Add(entry);
return entry.Value;
}

return null;
}

public void Add(object key, object value)
{
if (_entries.Count >= PerPicture) _entries.RemoveAt(0);
_entries.Add((key, value));
}
}
}
Loading
Loading