diff --git a/src/XTerm.NET.Tests/VtTestBehaviourTests.cs b/src/XTerm.NET.Tests/VtTestBehaviourTests.cs index 41981bb..f0a1f03 100644 --- a/src/XTerm.NET.Tests/VtTestBehaviourTests.cs +++ b/src/XTerm.NET.Tests/VtTestBehaviourTests.cs @@ -21,6 +21,11 @@ public class VtTestBehaviourTests { private const string Esc = "\u001b"; + // Written as escapes rather than as the bytes themselves: a literal control character in a + // source file survives nothing that touches the file on the way here. + private const string ShiftOut = "\u000e"; // SO, invokes G1 into GL + private const string ShiftIn = "\u000f"; // SI, back to G0 + private static Terminal Sized(int cols = 40, int rows = 6) => new(new TerminalOptions { Cols = cols, Rows = rows }); @@ -290,11 +295,90 @@ public void Special_graphics_maps_the_control_pictures_too() public void The_96_character_set_designators_are_a_separate_space() { var uk = Sized(30, 3); - uk.Write($"{Esc}(A#@["); + uk.Write($"{Esc})A{ShiftOut}#@[{ShiftIn}"); Assert.Equal("£@[", uk.GetLine(0)); var latin1 = Sized(30, 3); - latin1.Write($"{Esc}-A#@["); + latin1.Write($"{Esc}-A{ShiftOut}#@[{ShiftIn}"); Assert.Equal("#@[", latin1.GetLine(0)); } + + /// + /// A 96-set designation survives DECNRCM moving under it. + /// + /// + /// DECNRCM re-resolves every designation, because a national set means one thing with the mode + /// set and ASCII without it. That re-resolution has to know which SPACE each identifier came + /// from, and it did not: 'A' designated as a 96-set is Latin-1 and stays Latin-1 however the + /// mode moves, while re-asking the 94-set lookup for it answers United Kingdom. So the + /// collision the 96-set designators were given their own space to avoid came back the first + /// time DECNRCM moved -- and stayed, because resetting the mode re-resolves it the same wrong + /// way. + /// + /// The national replacement sets are all 94-set, so there is no DECNRCM state in which a + /// 96-set designation means anything else; both directions are asserted rather than only the + /// one that was broken. The French half is here so the two cannot agree for the boring reason + /// that nothing is being re-resolved at all. + /// + [Fact] + public void A_96_set_designation_is_not_re_resolved_as_a_94_set() + { + var enabled = Sized(30, 3); + enabled.Write($"{Esc}-A"); + enabled.Write($"{Esc}[?42h"); // DECNRCM set, after the designation + enabled.Write($"{ShiftOut}#@[{ShiftIn}"); + Assert.Equal("#@[", enabled.GetLine(0)); + + var andBack = Sized(30, 3); + andBack.Write($"{Esc}-A"); + andBack.Write($"{Esc}[?42h"); + andBack.Write($"{Esc}[?42l"); // and reset again + andBack.Write($"{ShiftOut}#@[{ShiftIn}"); + Assert.Equal("#@[", andBack.GetLine(0)); + + var french = Sized(30, 3); + french.Write($"{Esc})R"); // French: a 94-set that DOES move + french.Write($"{Esc}[?42h"); + french.Write($"{ShiftOut}#@[{ShiftIn}"); + Assert.Equal("£à°", french.GetLine(0)); + } + + /// + /// DECRC puts back the DESIGNATION, so a later DECNRCM re-resolves what was restored. + /// + /// + /// DECSC saved the table each G-set had resolved to rather than what it was designated as, so + /// the identifier behind a restored slot stayed as whatever had been designated AFTER the + /// save. The screen was right and the state behind it was not, which is why this needs a mode + /// change to show at all: the next DECNRCM re-resolves the restored slot into the wrong set, + /// arbitrarily far from the DECRC that caused it. + /// + /// Both spaces, because they fail differently. The 94-set pair loses line drawing to a + /// national set -- ESC ( 0, DECSC, ESC ( R, DECRC draws borders until the mode moves and then + /// draws letters. The 96-set pair is the identifier collision again: Latin-1 restored, then + /// re-resolved as the United Kingdom set. + /// + [Fact] + public void DECRC_restores_what_was_designated_not_what_it_resolved_to() + { + var graphics = Sized(30, 3); + graphics.Write($"{Esc})0{Esc}7{Esc})R{Esc}8"); // graphics, DECSC, French, DECRC + graphics.Write($"{Esc}[?42h"); // DECNRCM, which re-resolves + graphics.Write($"{ShiftOut}qqq{ShiftIn}"); + Assert.Equal("───", graphics.GetLine(0)); + + var latin1 = Sized(30, 3); + latin1.Write($"{Esc}-A{Esc}7{Esc})A{Esc}8"); // Latin-1, DECSC, UK, DECRC + latin1.Write($"{Esc}[?42h"); + latin1.Write($"{ShiftOut}#@[{ShiftIn}"); + Assert.Equal("#@[", latin1.GetLine(0)); + + // And the restore itself, which was never the broken half: without the mode change both + // of the above already came back right, and a test that stopped there would pass on the + // defect. + var immediate = Sized(30, 3); + immediate.Write($"{Esc})0{Esc}7{Esc})R{Esc}8"); + immediate.Write($"{ShiftOut}qqq{ShiftIn}"); + Assert.Equal("───", immediate.GetLine(0)); + } } diff --git a/src/XTerm.NET/Buffer/TerminalBuffer.cs b/src/XTerm.NET/Buffer/TerminalBuffer.cs index 5532852..2544d85 100644 --- a/src/XTerm.NET/Buffer/TerminalBuffer.cs +++ b/src/XTerm.NET/Buffer/TerminalBuffer.cs @@ -136,10 +136,17 @@ public class SavedCursor /// and reused after: DECSC is not rare -- a full-screen program saves and restores the /// cursor on every redraw -- and copying a dictionary allocated once per save. There are /// exactly four G-slots and the enum numbers them from zero, so the save is four - /// reference writes. Null means DECSC has not run on this screen, which is what tells + /// writes. Null means DECSC has not run on this screen, which is what tells /// DECRC to leave the designations alone. + /// + /// The DESIGNATION each slot held, not the table it had resolved to. The two differ once + /// a mode moves under them: DECNRCM re-resolves every designation, so a slot restored as + /// a table carries whatever identifier was designated AFTER the save, and the next + /// DECNRCM re-resolves the restored slot into that instead. The identifier carries the + /// space it came from for the same reason -- 'A' is the United Kingdom set in the 94-set + /// space and ISO Latin-1 in the 96-set one. /// - public Dictionary?[]? Designations { get; set; } + public (string Id, bool NinetySix)[]? Designations { get; set; } public SavedCursor() { diff --git a/src/XTerm.NET/InputHandler.Csi.cs b/src/XTerm.NET/InputHandler.Csi.cs index c24aafd..2eff0f0 100644 --- a/src/XTerm.NET/InputHandler.Csi.cs +++ b/src/XTerm.NET/InputHandler.Csi.cs @@ -1609,9 +1609,9 @@ private void SaveCursor() // which set is selected, so saving _currentCharset alone restored a pointer to a table // the program had since replaced: a TUI that saved the cursor mid-border finished the box // in letters. - var designations = _buffer.SavedCursorState.Designations ??= new Dictionary?[4]; + var designations = _buffer.SavedCursorState.Designations ??= new (string, bool)[4]; for (var slot = 0; slot < designations.Length; slot++) - designations[slot] = _charsets.GetValueOrDefault((CharsetMode)slot); + designations[slot] = DesignationOf((CharsetMode)slot); _buffer.SavedCursorState.OriginMode = _terminal.OriginMode; _buffer.SavedCursorState.PendingWrap = _buffer.PendingWrap; } @@ -1632,7 +1632,7 @@ private void RestoreCursor() if (designations is not null) { for (var slot = 0; slot < designations.Length; slot++) - _charsets[(CharsetMode)slot] = designations[slot]; + RestoreDesignation((CharsetMode)slot, designations[slot]); } _currentCharset = _buffer.SavedCursorState.Charset; diff --git a/src/XTerm.NET/InputHandler.Print.cs b/src/XTerm.NET/InputHandler.Print.cs index c9729eb..b1fc992 100644 --- a/src/XTerm.NET/InputHandler.Print.cs +++ b/src/XTerm.NET/InputHandler.Print.cs @@ -715,9 +715,7 @@ private void SetCharset(CharsetMode mode, string charsetId) // with DECNRCM set and ASCII without it, so the designation has to outlive the // resolution -- a program designating French and then enabling NRC mode expects // French, and it never designates again. - _charsetIds[mode] = charsetId; - _charsets[mode] = Charsets.GetCharset(charsetId, _terminal.NationalReplacementCharsets); - RefreshActiveCharset(); + Designate(mode, charsetId, ninetySix: false); } /// @@ -732,20 +730,57 @@ private void SetCharset(CharsetMode mode, string charsetId) /// private void SetNinetySixCharset(CharsetMode mode, string charsetId) { - _charsetIds[mode] = charsetId; - _charsets[mode] = Charsets.ASCII; + Designate(mode, charsetId, ninetySix: true); + } + + private void Designate(CharsetMode mode, string charsetId, bool ninetySix) + { + var designation = (charsetId, ninetySix); + _charsetIds[mode] = designation; + _charsets[mode] = Resolve(designation); RefreshActiveCharset(); } + /// + /// The table a designation resolves to NOW -- which depends on DECNRCM, and so is asked + /// again every time that mode moves rather than being decided once at designation time. + /// + private Dictionary? Resolve((string Id, bool NinetySix) designation) => + // The 96-set space has its own identifiers, and only ASCII-transparent members: routing + // one through the 94-set lookup is what turns a Latin-1 designation into the UK set. + // DECNRCM does not reach here either -- the national replacement sets are all 94-set. + designation.NinetySix + ? Charsets.ASCII + : Charsets.GetCharset(designation.Id, _terminal.NationalReplacementCharsets); + /// Re-resolves every designation, for when DECNRCM changes under them. internal void RefreshDesignatedCharsets() { - foreach (var mode in _charsetIds.Keys.ToList()) - _charsets[mode] = Charsets.GetCharset(_charsetIds[mode], _terminal.NationalReplacementCharsets); + foreach (var mode in GSets) + _charsets[mode] = Resolve(_charsetIds[mode]); RefreshActiveCharset(); } + /// The designation a G-set is holding, for DECSC to save. + internal (string Id, bool NinetySix) DesignationOf(CharsetMode mode) => _charsetIds[mode]; + + /// + /// Puts a saved designation back, for DECRC, resolving it against the mode state as it is NOW. + /// + /// + /// The DESIGNATION is what DECSC saves, not the table it had resolved to. Saving the table + /// restores the right glyphs and leaves the identifier behind it stale, so the next DECNRCM + /// re-resolves the restored slot from whatever was designated after the save -- and a program + /// doing ESC ( 0, DECSC, ESC ( R, DECRC gets its line drawing back and then loses it again the + /// first time the mode moves, which is further from the fault than any test looks. + /// + internal void RestoreDesignation(CharsetMode mode, (string Id, bool NinetySix) designation) + { + _charsetIds[mode] = designation; + _charsets[mode] = Resolve(designation); + } + /// /// Shift Out - Select G1 character set (SO, 0x0E). /// @@ -799,15 +834,21 @@ public void InvokeSingleShift(CharsetMode mode) /// public void ResetCharsets() { - _charsetIds.Clear(); - _charsets[CharsetMode.G0] = Charsets.ASCII; - _charsets[CharsetMode.G1] = Charsets.ASCII; - _charsets[CharsetMode.G2] = Charsets.ASCII; - _charsets[CharsetMode.G3] = Charsets.ASCII; + foreach (var mode in GSets) + { + // Seeded rather than cleared: US ASCII IS the designation every slot starts with, and + // saying so keeps every walk over the four total. + _charsetIds[mode] = (UsAsciiId, NinetySix: false); + _charsets[mode] = Charsets.ASCII; + } + _currentCharset = CharsetMode.G0; RefreshActiveCharset(); } + /// The identifier US ASCII is designated by, which is where every G-set starts. + private const string UsAsciiId = "B"; + /// /// Prints the payload of an OSC 66 whose metadata could not be parsed, as ordinary text. /// diff --git a/src/XTerm.NET/InputHandler.cs b/src/XTerm.NET/InputHandler.cs index f3482d0..cfa6023 100644 --- a/src/XTerm.NET/InputHandler.cs +++ b/src/XTerm.NET/InputHandler.cs @@ -44,8 +44,21 @@ public partial class InputHandler /// Kept alongside the resolved tables because a designation outlives its resolution: a /// national set resolves to ASCII while DECNRCM is reset and to itself once it is set, and /// the program that designated it does not designate again when the mode changes. + /// + /// The SPACE the identifier came from is kept with it, because the identifier alone does + /// not say which set it names: 'A' is the United Kingdom set after ESC ( and ISO Latin-1 + /// after ESC -. Re-resolving without it turns a Latin-1 designation into UK the first + /// time DECNRCM moves. + /// + /// All four slots are always present, seeded to US ASCII, so a designation is a value + /// rather than a value-or-absent. Both DECRC and DECNRCM walk every slot, and "never + /// designated" and "designated B" mean the same thing to both. /// - private readonly Dictionary _charsetIds = new(); + private readonly Dictionary _charsetIds = new(); + + /// The four G-sets, for the walks that touch all of them. + private static readonly CharsetMode[] GSets = + [CharsetMode.G0, CharsetMode.G1, CharsetMode.G2, CharsetMode.G3]; /// /// The set a SINGLE shift has invoked for the next printed character, or null. @@ -110,8 +123,8 @@ public InputHandler(Terminal terminal) { CharsetMode.G3, Charsets.ASCII } }; - _currentCharset = CharsetMode.G0; // G0 is active by default - RefreshActiveCharset(); + // And the designations behind them, which ResetCharsets seeds alongside the tables. + ResetCharsets(); } ///