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
135 changes: 127 additions & 8 deletions Cursorial.Core.Tests/Output/TextSizingWriterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,12 @@ public void Write_ScaleTwo_EmitsSEqualsTwo()
}

[Fact]
public void Write_WidthOnly_EmitsNoWKey()
public void Write_Width_EmitsW_TheSpanWidthOfTheSequence()
{
// 'w' is unsupported by decision (spec-verified 2026-08-02: whole-sequence width, not
// per-cluster; sub-cell layouts unmeasurable in whole cells) — a Width-only sizing emits
// an empty metadata block, identical to normal text.
var s = Encode(w => TextSizingWriter.Write(w, new TextSizing(Width: 2), "🐈".AsSpan()));
Assert.Equal("\x1b]66;;🐈\x1b\\", s);
// 'w' is the width in cells of the WHOLE text of the sequence ("all the text in that escape code must
// be rendered in s·w cells" — maintainer-verified against kitty, 2026-09-12): a cat forced into two cells.
var s = Encode(w => TextSizingWriter.Write(w, TextSizing.FixedWidth(2), "🐈".AsSpan()));
Assert.Equal("\x1b]66;w=2;🐈\x1b\\", s);
}

[Fact]
Expand All @@ -57,7 +56,7 @@ public void Write_AllNonDefaultsEmittedInSpecOrder()
Vertical: TextSizingVerticalAlignment.Center,
Horizontal: TextSizingHorizontalAlignment.Right);
var s = Encode(w => TextSizingWriter.Write(w, sizing, "x".AsSpan()));
Assert.Equal("\x1b]66;s=2:n=1:d=2:v=2:h=1;x\x1b\\", s); // no w= — unsupported by decision
Assert.Equal("\x1b]66;s=2:w=1:n=1:d=2:v=2:h=1;x\x1b\\", s);
}

[Fact]
Expand Down Expand Up @@ -96,4 +95,124 @@ public void WriteSplit_OversizedText_SplitsIntoMultipleSequences()
Assert.Contains(big.AsSpan()[VtOutputSequences.KittyTextSizing.MaxTextBytes..], s);
Assert.Contains("\x1b\\\x1b]66;;", s); // back-to-back close+open at split boundary
}
}

// ───────────────────────────── packing (several glyphs per cell) ─────────────────────────────

[Fact] // A packed half-size superscript: two glyphs cost one cell — one sequence, w=1.
public void WriteSplit_PackedSuperscript_TwoHalfGlyphs_ClaimOneCell()
{
var s = Encode(w => TextSizingWriter.WriteSplit(w, TextSizing.Superscript(), "12"));
Assert.Equal("\x1b]66;w=1:n=1:d=2;12\x1b\\", s);
Assert.Equal(1, TextSizing.Superscript().SpanColumns("12"));
}

[Fact] // Three half glyphs round up to two cells; the footprint and the emitted w agree.
public void WriteSplit_PackedSubscript_ThreeHalfGlyphs_ClaimTwoCells()
{
var s = Encode(w => TextSizingWriter.WriteSplit(w, TextSizing.Subscript(), "123"));
Assert.Equal("\x1b]66;w=2:n=1:d=2:v=1;123\x1b\\", s);
Assert.Equal(2, TextSizing.Subscript().SpanColumns("123"));
}

[Fact] // The spec caps w at 7 per sequence: fifteen half glyphs go out as a full seven-cell sequence + a one-cell tail.
public void WriteSplit_Packed_LongRun_ChunksAtSevenCells()
{
var text = "123456789012345"; // 15 half-size glyphs = 7.5 cells
var s = Encode(w => TextSizingWriter.WriteSplit(w, TextSizing.Superscript(), text));
Assert.Equal("\x1b]66;w=7:n=1:d=2;12345678901234\x1b\\\x1b]66;w=1:n=1:d=2;5\x1b\\", s);
Assert.Equal(8, TextSizing.Superscript().SpanColumns(text)); // 7 + 1
var chunks = TextSizingPacking.Chunks(TextSizing.Superscript(), text);
Assert.Equal(2, chunks.Count);
Assert.Equal((0, 14, 7), (chunks[0].Start, chunks[0].Length, chunks[0].Cells));
Assert.Equal((14, 1, 1), (chunks[1].Start, chunks[1].Length, chunks[1].Cells));
}

[Fact] // Packing at normal size claims the natural width, never splitting a wide cluster.
public void WriteSplit_PackedNormalSize_ClaimsNaturalWidth_WideClustersWhole()
{
var sizing = new TextSizing(Packed: true);
var s = Encode(w => TextSizingWriter.WriteSplit(w, sizing, "a🐈b"));
Assert.Equal("\x1b]66;w=4;a🐈b\x1b\\", s);
Assert.Equal(4, sizing.SpanColumns("a🐈b"));
}

[Fact] // Scale multiplies the packed footprint: a double-size packed span of two half glyphs is 2 columns × 2 rows.
public void SpanSize_ScaleMultipliesThePackedWidth()
{
var sizing = new TextSizing(Scale: 2, Numerator: 1, Denominator: 2, Packed: true);
Assert.Equal((2, 2), sizing.SpanSize("12"));
var s = Encode(w => TextSizingWriter.WriteSplit(w, sizing, "12"));
Assert.Equal("\x1b]66;s=2:w=1:n=1:d=2;12\x1b\\", s);
}

[Fact] // An explicit width pins the sequence; Packed is then ignored.
public void SpanColumns_ExplicitWidth_WinsOverPacked()
{
var sizing = new TextSizing(Width: 3, Numerator: 1, Denominator: 2, Packed: true);
Assert.Equal(3, sizing.SpanColumns("1"));
var s = Encode(w => TextSizingWriter.WriteSplit(w, sizing, "1"));
Assert.Equal("\x1b]66;w=3:n=1:d=2;1\x1b\\", s);
}

// ───────────────────────────── capability gate ─────────────────────────────

[Fact]
public void IsSupported_WidthAndPacking_NeedTheWCapability()
{
var scaleOnly = Cursorial.Output.Capabilities.OutputCapabilities.None with { TextSizing = new(Width: false, Scale: true) };
var both = Cursorial.Output.Capabilities.OutputCapabilities.None with { TextSizing = new(Width: true, Scale: true) };

Assert.False(TextSizing.FixedWidth(2).IsSupported(scaleOnly));
Assert.True(TextSizing.FixedWidth(2).IsSupported(both));
Assert.False(TextSizing.Superscript().IsSupported(scaleOnly));
Assert.True(TextSizing.Superscript().IsSupported(both));
Assert.True(TextSizing.Double.IsSupported(scaleOnly));
Assert.False(TextSizing.Normal.IsSupported(both)); // nothing to draw differently — the fallback is the better path
}

// ───────────────────────────── string form / converter ─────────────────────────────

[Theory]
[InlineData("Normal", "Normal")]
[InlineData("double", "s=2")]
[InlineData("3x", "s=3")]
[InlineData("Superscript", "n=1:d=2:packed")]
[InlineData("subscript 1/3", "n=1:d=3:v=1:packed")]
[InlineData("s=2:n=1:d=2:v=2:h=1:w=1", "s=2:n=1:d=2:v=2:h=1:w=1")]
[InlineData("s=2, v=bottom, h=center", "s=2:v=1:h=2")]
[InlineData("2x 1/2 center packed", "s=2:n=1:d=2:v=2:packed")]
[InlineData("w=2", "w=2")]
[InlineData("P=1 n=1 d=4", "n=1:d=4:packed")]
public void Parse_NamedAndMetadataForms_RoundTripThroughToString(string input, string canonical)
{
var sizing = TextSizing.Parse(input);
Assert.Equal(canonical, sizing.ToString());
Assert.Equal(sizing, TextSizing.Parse(canonical));
}

[Theory]
[InlineData("")]
[InlineData("huge")]
[InlineData("8x")]
[InlineData("w=8")]
[InlineData("n=1")] // a fraction needs both n and d
[InlineData("2/2")] // the spec requires d > n
[InlineData("s=0")]
[InlineData("v=3")]
public void TryParse_RejectsBadInput(string input)
{
Assert.False(TextSizing.TryParse(input, out _, out var error));
Assert.False(string.IsNullOrEmpty(error));
Assert.Throws<FormatException>(() => TextSizing.Parse(input));
}

[Fact]
public void Converter_ConvertsBothWays()
{
var converter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(TextSizing));
Assert.IsType<TextSizingConverter>(converter);
var sizing = Assert.IsType<TextSizing>(converter.ConvertFromInvariantString("superscript"));
Assert.Equal(TextSizing.Superscript(), sizing);
Assert.Equal("n=1:d=2:packed", converter.ConvertToInvariantString(sizing));
}
}
30 changes: 21 additions & 9 deletions Cursorial.Core/Output/TextSizingWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
/// </para>
/// <para>
/// The spec caps a single OSC 66 payload at 4096 UTF-8 bytes. Longer strings MUST be split
/// across multiple emissions to avoid truncation. <see cref="Write"/> handles that

Check warning on line 23 in Cursorial.Core/Output/TextSizingWriter.cs

View workflow job for this annotation

GitHub Actions / Test on ubuntu-latest

Ambiguous reference in cref attribute: 'Write'. Assuming 'Cursorial.Output.TextSizingWriter.Write(System.Buffers.IBufferWriter<byte>, in Cursorial.Text.TextSizing, System.ReadOnlySpan<char>)', but could have also matched other overloads including 'Cursorial.Output.TextSizingWriter.Write(System.Buffers.IBufferWriter<byte>, in Cursorial.Text.TextSizing, byte, System.ReadOnlySpan<char>)'.

Check warning on line 23 in Cursorial.Core/Output/TextSizingWriter.cs

View workflow job for this annotation

GitHub Actions / Test on windows-latest

Ambiguous reference in cref attribute: 'Write'. Assuming 'Cursorial.Output.TextSizingWriter.Write(System.Buffers.IBufferWriter<byte>, in Cursorial.Text.TextSizing, System.ReadOnlySpan<char>)', but could have also matched other overloads including 'Cursorial.Output.TextSizingWriter.Write(System.Buffers.IBufferWriter<byte>, in Cursorial.Text.TextSizing, byte, System.ReadOnlySpan<char>)'.

Check warning on line 23 in Cursorial.Core/Output/TextSizingWriter.cs

View workflow job for this annotation

GitHub Actions / Test on macos-latest

Ambiguous reference in cref attribute: 'Write'. Assuming 'Cursorial.Output.TextSizingWriter.Write(System.Buffers.IBufferWriter<byte>, in Cursorial.Text.TextSizing, System.ReadOnlySpan<char>)', but could have also matched other overloads including 'Cursorial.Output.TextSizingWriter.Write(System.Buffers.IBufferWriter<byte>, in Cursorial.Text.TextSizing, byte, System.ReadOnlySpan<char>)'.
/// automatically by chunking on grapheme boundaries; the bare <see cref="TextSizingCapabilities"/> overloads
/// emit a single sequence and assume the caller already respects the cap.
/// </para>
Expand All @@ -34,6 +34,10 @@
/// use <see cref="WriteSplit"/> if you can't guarantee that.
/// </summary>
public static void Write(IBufferWriter<byte> writer, in TextSizing sizing, ReadOnlySpan<char> text)
=> Write(writer, sizing, sizing.Width, text);

// One sequence with an explicit w (0 = none) — the packed path supplies each chunk's computed width.
private static void Write(IBufferWriter<byte> writer, in TextSizing sizing, byte width, ReadOnlySpan<char> text)
{
ArgumentNullException.ThrowIfNull(writer);

Expand All @@ -50,7 +54,7 @@
int written = 0;
prefix.CopyTo(buffer[written..]);
written += prefix.Length;
WriteMetadata(sizing, buffer, ref written);
WriteMetadata(sizing, width, buffer, ref written);
buffer[written++] = (byte) ';';
written += Encoding.UTF8.GetBytes(text, buffer[written..]);
st.CopyTo(buffer[written..]);
Expand Down Expand Up @@ -81,6 +85,16 @@
return;
}

// Packed: one sequence per packing chunk, each claiming its computed w (never above the spec's seven
// cells) — the same chunks TextSizing.SpanColumns measured, so the footprint told to the terminal is
// the footprint layout reserved.
if (sizing.Packed && sizing.Width == 0)
{
foreach (var chunk in TextSizingPacking.Chunks(sizing, text))
Write(writer, sizing, (byte) chunk.Cells, text.Slice(chunk.Start, chunk.Length));
return;
}

// Quick path: whole string fits in one emission.
int totalUtf8 = Encoding.UTF8.GetByteCount(text);

Expand Down Expand Up @@ -128,20 +142,18 @@
}
}

private static void WriteMetadata(in TextSizing sizing, Span<byte> buffer, ref int written)
private static void WriteMetadata(in TextSizing sizing, byte width, Span<byte> buffer, ref int written)
{
bool first = true;

if (sizing.Scale != 0 && sizing.Scale != 1)
EmitKv((byte) 's', sizing.Scale, buffer, ref written, ref first);

// 'w' is deliberately NOT emitted. Per the OSC 66 spec it is the fixed width of the
// ENTIRE sequence (all text renders in s·w × s cells, chunk-per-escape for longer
// text), not a per-cluster width — and the sub-cell layouts it enables (fixed width +
// n/d, no scale) cannot be measured in whole cells, which is the only unit this
// framework's layout speaks. Emitting a key our measurement cannot honor would make
// fragments lie about their footprint, so the parameter is unsupported by decision
// (maintainer, 2026-08-02).
// 'w' — the width in cells of the WHOLE text of this sequence (spec: "all the text in that escape code
// must be rendered in s·w cells"; maintainer-verified against kitty 2026-09-12). An explicit Width is
// emitted as given; a Packed sizing has the writer compute it per chunk (WriteSplit → Write(…, width)).
if (width != 0)
EmitKv((byte) 'w', width, buffer, ref written, ref first);

if (sizing.Numerator != 0)
EmitKv((byte) 'n', sizing.Numerator, buffer, ref written, ref first);
Expand Down
Loading
Loading