diff --git a/src/XTerm.NET.Tests/VtTestBehaviourTests.cs b/src/XTerm.NET.Tests/VtTestBehaviourTests.cs
index 3bbea05..f28afb5 100644
--- a/src/XTerm.NET.Tests/VtTestBehaviourTests.cs
+++ b/src/XTerm.NET.Tests/VtTestBehaviourTests.cs
@@ -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
}
+
+ ///
+ /// A single shift is spent by the next graphic character, and by nothing else short of a reset.
+ ///
+ ///
+ /// 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.
+ ///
+ /// 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.
+ ///
+ [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));
+ }
}
diff --git a/src/XTerm.NET/InputHandler.Print.cs b/src/XTerm.NET/InputHandler.Print.cs
index 4abe1b6..00c6fd6 100644
--- a/src/XTerm.NET/InputHandler.Print.cs
+++ b/src/XTerm.NET/InputHandler.Print.cs
@@ -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;
}
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 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);
@@ -812,11 +826,14 @@ public void ShiftOut()
///
/// Shift In - Select G0 character set (SI, 0x0F).
///
+ ///
+ /// 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.
+ ///
public void ShiftIn()
{
_currentCharset = CharsetMode.G0;
- _singleShiftCharset = null;
- _singleShiftPending = false;
RefreshActiveCharset();
}
@@ -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.
///
///
- /// 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.
+ /// 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.
+ ///
+ /// 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.
///
- public void InvokeSingleShift(CharsetMode mode)
- {
- _singleShiftCharset = _charsets.GetValueOrDefault(mode);
- _singleShiftPending = true;
- }
+ public void InvokeSingleShift(CharsetMode mode) => _singleShift = mode;
///
/// Resets charset state to defaults.
///
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)
{
diff --git a/src/XTerm.NET/InputHandler.cs b/src/XTerm.NET/InputHandler.cs
index 103ccc7..e70fad4 100644
--- a/src/XTerm.NET/InputHandler.cs
+++ b/src/XTerm.NET/InputHandler.cs
@@ -63,17 +63,21 @@ public partial class InputHandler
private readonly HashSet _ninetySixSets = new();
///
- /// 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.
///
///
- /// Separate from because it outranks it for exactly one
+ /// Separate from 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.
+ ///
+ /// 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.
///
- private Dictionary? _singleShiftCharset;
-
- private bool _singleShiftPending;
+ private CharsetMode? _singleShift;
private CharsetMode _currentCharset;
// Variation selector and combining character constants
@@ -188,7 +192,7 @@ internal void PrintAsciiRun(ReadOnlySpan 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)
@@ -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++)