Skip to content

Commit 52dd3c8

Browse files
Terminal features: MSLP command links and pictures in a figure's cells (#45)
* Terminal features: MSLP command links and pictures in a figure's cells AnsiOutputOptions describes one client: colour depth, TerminalFeatures (Hyperlinks, CommandLinks, KittyGraphics, InlineImages, Sixel, BlockArt) and an ITerminalPictureSource for pixels. A Figure laid out under LayoutContext.Pictures marks each row of its cells with PictureCellsMarkup; the ANSI emitter draws the picture there or leaves the art. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01448U5MvpKkRkBjwzkkpPFP * Encode each picture once per size, and encode it faster Encodings (Kitty transmission, iTerm2 PNG, sixel, half-block rows) are kept with the TerminalPicture, so every connection shown a picture at a size gets a copy. Kitty now sends a PNG (f=100); PNGs are RGB when opaque, filtered Up, at zlib level 2. Sixel reads each band once and writes a colour only over the columns it reaches, with P2=0 for an opaque picture. Half blocks scale the picture once rather than once per row. Large payloads go straight to the output instead of through the run's buffers. On a 512x384 picture in 60x23 cells: Kitty 25 ms -> 16 ms to encode and 777 -> 423 KiB on the wire, 1.2 ms for each later connection; iTerm2 25 -> 13 ms; sixel 20 -> 7 ms; half blocks 14 -> 3 ms. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01448U5MvpKkRkBjwzkkpPFP * Take a zero cell size as unknown, and send colourless clients the text art A terminal that does not know its cell size reports 0, and a sixel at width 0 divided by zero. Half blocks at Attributes or None depth lost every colour and were a grid of identical blocks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01448U5MvpKkRkBjwzkkpPFP --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d48846f commit 52dd3c8

20 files changed

Lines changed: 1696 additions & 27 deletions

‎CHANGELOG.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,28 @@ and `MarkupString.Pueblo`. The packages share one version and are released toget
66
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
77
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

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

1133
### Fixed

‎MarkupString.Ansi/AnsiColorDepth.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ namespace MarkupString.Ansi;
22

33
/// <summary>
44
/// How much colour a client can display. A registry built with
5-
/// <see cref="AnsiRegistration.WithAnsiOutput"/> writes every colour at the depth it is given, so a client
5+
/// <see cref="AnsiRegistration.WithAnsiOutput(MarkupRegistry, AnsiOutputOptions)"/> writes every colour at the depth it is given, so a client
66
/// is never sent a sequence it cannot read.
77
/// </summary>
88
public enum AnsiColorDepth
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
namespace MarkupString.Ansi;
2+
3+
/// <summary>What one client is sent in <see cref="MarkupFormat.Ansi"/>: how much colour, and what else its terminal can do.</summary>
4+
/// <param name="ColorDepth">The colour the client can display; every colour is written at this depth.</param>
5+
/// <param name="Features">What the terminal can do beyond colour.</param>
6+
public sealed record AnsiOutputOptions(
7+
AnsiColorDepth ColorDepth = AnsiColorDepth.TrueColor,
8+
TerminalFeatures Features = TerminalFeatures.Hyperlinks)
9+
{
10+
/// <summary>
11+
/// Where pictures' pixels come from, for a client with any of <see cref="TerminalFeatures.Pictures"/>. Without
12+
/// one, a picture is its text art whatever the client can draw.
13+
/// </summary>
14+
public ITerminalPictureSource? Pictures { get; init; }
15+
16+
/// <summary>
17+
/// The width of the terminal's character cell in pixels, for a picture drawn in pixels (sixel). 10 when unknown,
18+
/// which a terminal that does not know its own reports as 0: a value below 1 is taken as unknown.
19+
/// </summary>
20+
public int CellWidth { get; init => field = value > 0 ? value : 10; } = 10;
21+
22+
/// <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>
23+
public int CellHeight { get; init => field = value > 0 ? value : 20; } = 20;
24+
}

‎MarkupString.Ansi/AnsiRegistration.cs‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,24 @@ public static MarkupRegistry WithAnsi(this MarkupRegistry registry)
5050
/// Whether a URL link is written as an OSC 8 hyperlink in <see cref="MarkupFormat.Ansi"/>; when false, its
5151
/// text is written alone. Pueblo and MXP write links as their own tags either way.
5252
/// </param>
53-
public static MarkupRegistry WithAnsiOutput(this MarkupRegistry registry, AnsiColorDepth colorDepth, bool hyperlinks = true)
53+
public static MarkupRegistry WithAnsiOutput(this MarkupRegistry registry, AnsiColorDepth colorDepth, bool hyperlinks = true) =>
54+
registry.WithAnsiOutput(new AnsiOutputOptions(colorDepth, hyperlinks ? TerminalFeatures.Hyperlinks : TerminalFeatures.None));
55+
56+
/// <summary>
57+
/// Returns a registry that writes for one client as <paramref name="options"/> describe it: every colour at its
58+
/// depth in <see cref="MarkupFormat.Ansi"/>, <see cref="MarkupFormat.Pueblo"/> and <see cref="MarkupFormat.Mxp"/>,
59+
/// and, in <see cref="MarkupFormat.Ansi"/>, links and pictures in the forms its terminal reads.
60+
/// </summary>
61+
/// <param name="registry">The registry to add to, one <see cref="WithAnsi"/> has already filled.</param>
62+
/// <param name="options">What the client is sent.</param>
63+
public static MarkupRegistry WithAnsiOutput(this MarkupRegistry registry, AnsiOutputOptions options)
5464
{
5565
ArgumentNullException.ThrowIfNull(registry);
66+
ArgumentNullException.ThrowIfNull(options);
5667

5768
return registry
58-
.With(new AnsiSetEmitter(colorDepth, hyperlinks))
59-
.With(new AnsiPuebloEmitter(colorDepth))
60-
.With(new AnsiMxpEmitter(colorDepth));
69+
.With(new AnsiSetEmitter(options))
70+
.With(new AnsiPuebloEmitter(options.ColorDepth))
71+
.With(new AnsiMxpEmitter(options.ColorDepth));
6172
}
6273
}

‎MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs‎

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -194,26 +194,55 @@ internal static void WriteWrapped(
194194
}
195195

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

211-
output.Write(Osc8);
212-
output.Write(url);
213-
output.Write(Bel);
211+
if (style.LinkKind == LinkKind.Url && (features & TerminalFeatures.Hyperlinks) != 0 && UrlSafety.IsSafeNavigableUrl(target))
212+
{
213+
output.Write(Osc8);
214+
output.Write(target);
215+
output.Write(Bel);
216+
output.Write(body);
217+
output.Write(Osc8);
218+
output.Write(Bel);
219+
return;
220+
}
221+
222+
if (style.LinkKind == LinkKind.Command && (features & TerminalFeatures.CommandLinks) != 0 && !HasControl(target))
223+
{
224+
output.Write(MslpSend);
225+
output.Write(target);
226+
output.Write(Bel);
227+
output.Write(UnderlineOn);
228+
output.Write(body);
229+
// The underline is the link's extent. A run that was underlined anyway stays so.
230+
output.Write(style.Underlined ? UnderlineOff + UnderlineOn : UnderlineOff);
231+
return;
232+
}
233+
214234
output.Write(body);
215-
output.Write(Osc8);
216-
output.Write(Bel);
235+
}
236+
237+
private const string MslpSend = "\e]68;1;SEND;";
238+
private const string UnderlineOn = "\e[4m";
239+
private const string UnderlineOff = "\e[24m";
240+
241+
private static bool HasControl(string text)
242+
{
243+
foreach (var c in text)
244+
if (char.IsControl(c)) return true;
245+
return false;
217246
}
218247

219248
/// <summary>

‎MarkupString.Ansi/Emitters/AnsiSetEmitter.cs‎

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

23+
/// <summary>Every colour at <paramref name="colorDepth"/>, and URL links as OSC 8 hyperlinks or as their text alone.</summary>
24+
/// <param name="colorDepth">The colour the client can display; each style is written at this depth.</param>
25+
/// <param name="hyperlinks">Whether a URL link is written as an OSC 8 hyperlink, or as its text alone.</param>
26+
public AnsiSetEmitter(AnsiColorDepth colorDepth, bool hyperlinks)
27+
: this(new AnsiOutputOptions(colorDepth, hyperlinks ? TerminalFeatures.Hyperlinks : TerminalFeatures.None))
28+
{
29+
}
30+
31+
private readonly AnsiOutputOptions _options = options ?? throw new ArgumentNullException(nameof(options));
32+
// Half blocks are colour and nothing else: at a depth without colour they are a grid of identical blocks,
33+
// and the figure's text art says more.
34+
private readonly TerminalFeatures _pictureMethod = options.Pictures is null
35+
? TerminalFeatures.None
36+
: TerminalPictureWriter.Method(options.ColorDepth is AnsiColorDepth.Attributes or AnsiColorDepth.None
37+
? options.Features & ~TerminalFeatures.BlockArt
38+
: options.Features);
39+
1940
/// <inheritdoc/>
2041
public MarkupFormat Format => MarkupFormat.Ansi;
2142

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

28-
var effective = AnsiEmitterSupport.Fold(set, context.Format).AtDepth(colorDepth);
29-
var previous = AnsiEmitterSupport.Fold(context.Previous, context.Format).AtDepth(colorDepth);
49+
var depth = _options.ColorDepth;
50+
var effective = AnsiEmitterSupport.Fold(set, context.Format).AtDepth(depth);
51+
var previous = AnsiEmitterSupport.Fold(context.Previous, context.Format).AtDepth(depth);
3052

3153
using var core = new PooledCharWriter(body.Length + 32);
3254
SgrWriter.Transition(previous, effective, core);
33-
if (hyperlinks) AnsiEmitterSupport.WriteHyperlinked(effective, body, core);
34-
else core.Write(body);
55+
if (Picture(set) is { } picture)
56+
{
57+
// The run that starts the row draws all of it; the others in the same row draw nothing, so the row is
58+
// as many cells as the text it stands over.
59+
if (context.StartsRegion(picture.Cells))
60+
TerminalPictureWriter.Write(_pictureMethod, picture.Cells, picture.Pixels, effective, _options, core, output);
61+
}
62+
else
63+
{
64+
AnsiEmitterSupport.WriteLinked(effective, body, _options.Features, core);
65+
}
3566

3667
// Nothing follows that would diff this state away, so close it here rather than leaving the
3768
// terminal coloured for whatever the connection writes next.
@@ -40,4 +71,19 @@ public bool TryEmit(MarkupSet set, ReadOnlySpan<char> body, in EmitContext conte
4071
AnsiEmitterSupport.WriteWrapped(set, core.WrittenSpan, context, output);
4172
return true;
4273
}
74+
75+
/// <summary>The picture this run is a row of, when this client draws it and its pixels are to hand.</summary>
76+
private (PictureCellsMarkup Cells, TerminalPicture Pixels)? Picture(MarkupSet set)
77+
{
78+
if (_pictureMethod == TerminalFeatures.None) return null;
79+
for (var i = 0; i < set.Count; i++)
80+
{
81+
if (set[i] is not PictureCellsMarkup cells) continue;
82+
return TerminalPictureWriter.CanDraw(_pictureMethod, cells)
83+
&& _options.Pictures!.TryGetPicture(cells.Image, out var pixels)
84+
? (cells, pixels)
85+
: null;
86+
}
87+
return null;
88+
}
4389
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using System.Runtime.CompilerServices;
2+
namespace MarkupString.Ansi;
3+
4+
/// <summary>
5+
/// What a picture has been encoded as, kept with the picture so every connection shown it at the same size
6+
/// shares one encoding. A host typically holds one <see cref="TerminalPicture"/> per picture and shows it to
7+
/// many connections; resizing, compressing and encoding it is far dearer than copying the result.
8+
/// </summary>
9+
/// <remarks>
10+
/// The encodings live exactly as long as the picture does (a <see cref="ConditionalWeakTable{TKey,TValue}"/>),
11+
/// so a host that drops a picture drops them too. Each picture keeps at most <see cref="PerPicture"/>, the
12+
/// least recently used going first, since a picture is seldom shown at more than a size or two.
13+
/// </remarks>
14+
internal static class PictureEncodings
15+
{
16+
/// <summary>How many encodings one picture keeps.</summary>
17+
internal const int PerPicture = 64;
18+
19+
private static readonly ConditionalWeakTable<TerminalPicture, Cache> Caches = new();
20+
21+
/// <summary>
22+
/// The encoding of <paramref name="picture"/> that <paramref name="key"/> names, made by <paramref name="make"/>
23+
/// the first time. Two threads asking at once may both make it; one result is kept.
24+
/// </summary>
25+
internal static T GetOrAdd<TKey, T>(TerminalPicture picture, TKey key, Func<TerminalPicture, TKey, T> make)
26+
where TKey : notnull
27+
where T : class
28+
{
29+
var cache = Caches.GetValue(picture, static _ => new Cache());
30+
lock (cache)
31+
{
32+
if (cache.TryGet(key) is T held) return held;
33+
}
34+
35+
var made = make(picture, key);
36+
lock (cache)
37+
{
38+
if (cache.TryGet(key) is T raced) return raced;
39+
cache.Add(key, made);
40+
}
41+
42+
return made;
43+
}
44+
45+
/// <summary>A picture's encodings, most recently used last. Guarded by its own lock.</summary>
46+
private sealed class Cache
47+
{
48+
private readonly List<(object Key, object Value)> _entries = [];
49+
50+
public object? TryGet(object key)
51+
{
52+
for (var i = _entries.Count - 1; i >= 0; i--)
53+
{
54+
if (!_entries[i].Key.Equals(key)) continue;
55+
var entry = _entries[i];
56+
_entries.RemoveAt(i);
57+
_entries.Add(entry);
58+
return entry.Value;
59+
}
60+
61+
return null;
62+
}
63+
64+
public void Add(object key, object value)
65+
{
66+
if (_entries.Count >= PerPicture) _entries.RemoveAt(0);
67+
_entries.Add((key, value));
68+
}
69+
}
70+
}

0 commit comments

Comments
 (0)