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
57 changes: 57 additions & 0 deletions src/XTerm.NET.Tests/VtTestBehaviourTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -449,4 +449,61 @@ public void DECRC_restores_what_was_designated_not_what_it_resolved_to()
modeOffAfterSave.Write($"{ShiftOut}@{ShiftIn}");
Assert.Equal("@", modeOffAfterSave.GetLine(0)); // ASCII, not the French it saved
}

/// <summary>
/// A single shift is spent by the next graphic character, and by nothing else short of a reset.
/// </summary>
/// <remarks>
/// <para>Four ways in and three answers: SI cancelled a pending shift, SO and the locking
/// shifts did not, and RIS could not reach it at all -- the pending state held the TABLE G2
/// had resolved to, so a reset put the tables back and the shift went on pointing at the old
/// one. ESC * 0, SS2, RIS, 'q' printed a line-drawing dash on a terminal that had just been
/// reset to ASCII.</para>
///
/// <para>The VT510 manual scopes a single shift to "the next graphic character". A locking
/// shift is not one, and neither is a designation -- so a designation between the shift and
/// the character it shifts belongs to that character, which is the last case here.</para>
/// </remarks>
[Fact]
public void A_single_shift_is_spent_by_the_next_graphic_character_and_nothing_else()
{
const string so = "\u000e";
const string si = "\u000f";

// RIS reaches it. Line drawing in G2, shift pending, reset -- the 'q' is a letter again.
var reset = Sized(20, 3);
reset.Write($"{Esc}*0{Esc}N{Esc}c" + "q");
Assert.Equal("q", reset.GetLine(0));

// The three locking shifts leave it standing, and now agree with each other.
foreach (var shift in new[] { si, so, $"{Esc}n" })
{
var terminal = Sized(20, 3);
terminal.Write($"{Esc}*0{Esc}N{shift}q");
Assert.Equal("─", terminal.GetLine(0));
}

// A designation after the shift counts: SS2 invokes G2, and what G2 holds is a question
// with an answer at print time rather than at shift time.
var late = Sized(20, 3);
late.Write($"{Esc}N{Esc}*0" + "q");
Assert.Equal("─", late.GetLine(0));

// And it really is spent, on one character and not two.
var once = Sized(20, 3);
once.Write($"{Esc}*0{Esc}N" + "qq");
Assert.Equal("─q", once.GetLine(0));

// A SUPPLEMENTARY character spends it too. It reaches Print as two UTF-16 code units,
// and the clear that spends the shift used to sit inside the single-code-unit branch --
// so the shift survived the emoji and landed on whatever came next. That does not skip
// the shift, it MOVES it: the q below drew as a box-drawing glyph on a terminal whose G2
// the program had finished with, the same class of bug as the RIS case above.
//
// The emoji itself is untranslated, because a 94-character set has no entry outside the
// BMP -- spending the shift and translating through it are different things.
var supplementary = Sized(20, 3);
supplementary.Write($"{Esc}*0{Esc}N" + "\U0001F600q");
Assert.Equal("\U0001F600q", supplementary.GetLine(0));
}
}
54 changes: 39 additions & 15 deletions src/XTerm.NET/InputHandler.Print.cs
Original file line number Diff line number Diff line change
Expand Up @@ -392,18 +392,32 @@ public void Print(string data)
var translatedData = data;
if (data.Length == 1)
{
// A single shift outranks GL for this character and is spent doing it.
if (_singleShiftPending)
// A single shift outranks GL for this character and is spent doing it. Resolved
// here rather than when the shift arrived, so a designation in between counts.
if (_singleShift is { } shifted)
{
translatedData = Charsets.TranslateChar(data[0], _singleShiftCharset);
_singleShiftCharset = null;
_singleShiftPending = false;
translatedData = Charsets.TranslateChar(data[0], _charsets.GetValueOrDefault(shifted));
_singleShift = null;
Comment thread
JohnCampionJr marked this conversation as resolved.
}
else
{
translatedData = Charsets.TranslateChar(data[0], _activeCharset);
}
}
else if (_singleShift is not null)
{
// A supplementary character is a graphic character too, and spends the shift like any
// other -- it just has nothing to spend it ON, because a 94- or 96-character set has no
// entry outside the BMP and TranslateChar takes a single code unit. Leaving the shift
// standing through it does not skip the shift, it MOVES it: the character after the
// emoji gets translated instead, so `SS2 <emoji> q` drew q as a box-drawing glyph on a
// terminal whose G2 the program had finished with.
//
// Second, after the length test rather than before it, because that is the branch every
// ordinary character takes -- see CLAUDE.md's first section. This one runs only for a
// surrogate pair, and only to clear a field.
_singleShift = null;
}

var width = GetStringCellWidth(translatedData);

Expand Down Expand Up @@ -812,11 +826,14 @@ public void ShiftOut()
/// <summary>
/// Shift In - Select G0 character set (SI, 0x0F).
/// </summary>
/// <remarks>
/// A pending single shift survives this, as it survives SO and the locking shifts. SI used
/// to cancel one and the other three did not, which is three answers to one question: a
/// single shift is spent by the next GRAPHIC character, and a locking shift is not one.
/// </remarks>
public void ShiftIn()
{
_currentCharset = CharsetMode.G0;
_singleShiftCharset = null;
_singleShiftPending = false;
RefreshActiveCharset();
}

Expand All @@ -838,21 +855,28 @@ public void LockingShift(CharsetMode mode)
/// SS2 (ESC N) and SS3 (ESC O) - invoke G2 or G3 for the NEXT character only.
/// </summary>
/// <remarks>
/// The single shift is held pending rather than swapped in, so it expires by being consumed
/// instead of by something remembering to put the old set back. A shift with no character
/// after it simply never fires.
/// <para>The single shift is held pending rather than swapped in, so it expires by being
/// consumed instead of by something remembering to put the old set back. A shift with no
/// character after it simply never fires.</para>
///
/// <para>Consumption is the ONLY thing that spends it, short of a reset. The VT510 manual
/// says a single shift maps its G-set "for the next graphic character", and neither a
/// locking shift nor a designation is one.</para>
/// </remarks>
public void InvokeSingleShift(CharsetMode mode)
{
_singleShiftCharset = _charsets.GetValueOrDefault(mode);
_singleShiftPending = true;
}
public void InvokeSingleShift(CharsetMode mode) => _singleShift = mode;

/// <summary>
/// Resets charset state to defaults.
/// </summary>
public void ResetCharsets()
{
// A pending single shift is charset state, and RIS is exactly how someone recovers from
// a program that died between the shift and the character it was going to shift.
//
// _charsetIds is no longer cleared here: #149 made the loop below SEED every slot with the
// US ASCII designation rather than leave it absent, so clearing first would only empty a
// dictionary that is about to have all four entries written.
_singleShift = null;
_ninetySixSets.Clear();
foreach (var mode in GSets)
{
Expand Down
20 changes: 12 additions & 8 deletions src/XTerm.NET/InputHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,21 @@ public partial class InputHandler
private readonly HashSet<CharsetMode> _ninetySixSets = new();

/// <summary>
/// The set a SINGLE shift has invoked for the next printed character, or null.
/// The G-set a SINGLE shift has invoked for the next printed character, or null.
/// </summary>
/// <remarks>
/// Separate from <see cref="_activeCharset"/> because it outranks it for exactly one
/// <para>Separate from <see cref="_activeCharset"/> because it outranks it for exactly one
/// character and then stops: SS2 and SS3 shift the character that follows and nothing
/// after it. Holding it as pending state rather than swapping the active set is what makes
/// "and then stops" automatic instead of something the print path has to remember to undo.
/// "and then stops" automatic instead of something the print path has to remember to undo.</para>
///
/// <para>The G-SET, not the table it resolved to when the shift arrived. A designation
/// between the shift and the character it shifts belongs to that character -- SS2 invokes
/// G2, and what G2 holds is a question with an answer at print time. Holding the table also
/// meant a reset could not reach it: RIS put the tables back and the pending shift went on
/// pointing at the one it had captured.</para>
/// </remarks>
private Dictionary<char, string>? _singleShiftCharset;

private bool _singleShiftPending;
private CharsetMode? _singleShift;
private CharsetMode _currentCharset;

// Variation selector and combining character constants
Expand Down Expand Up @@ -188,7 +192,7 @@ internal void PrintAsciiRun(ReadOnlySpan<byte> data)
// path can only stop. Rare enough to hand to Print rather than teach twice -- the default
// is on, so nothing in normal output takes this branch.
if (!UseRunPrinting || _terminal.InsertMode || _activeCharset is not null
|| _singleShiftPending
|| _singleShift is not null
|| _buffer.HasMultiRowSizedRuns || !_terminal.Options.Wraparound)
{
foreach (var b in data)
Expand Down Expand Up @@ -296,7 +300,7 @@ internal void PrintAsciiRun(string data, int start, int count)
// path can only stop. Rare enough to hand to Print rather than teach twice -- the default
// is on, so nothing in normal output takes this branch.
if (!UseRunPrinting || _terminal.InsertMode || _activeCharset is not null
|| _singleShiftPending
|| _singleShift is not null
|| _buffer.HasMultiRowSizedRuns || !_terminal.Options.Wraparound)
{
for (var k = 0; k < count; k++)
Expand Down
Loading