diff --git a/src/XTerm.NET.Tests/VtTestBehaviourTests.cs b/src/XTerm.NET.Tests/VtTestBehaviourTests.cs index eda0ca2..3bbea05 100644 --- a/src/XTerm.NET.Tests/VtTestBehaviourTests.cs +++ b/src/XTerm.NET.Tests/VtTestBehaviourTests.cs @@ -20,6 +20,8 @@ namespace XTerm.Tests; public class VtTestBehaviourTests { private const string Esc = "\u001b"; + private const string ShiftOut = "\u000e"; // SO -- invoke 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 }); @@ -289,12 +291,15 @@ public void Special_graphics_maps_the_control_pictures_too() [Fact] public void The_96_character_set_designators_are_a_separate_space() { + // Both halves designate G1 and invoke it with SO, so they differ by ONE thing: the + // space the designator came from. Designating G0 for one and G1 for the other left + // the comparison carrying a second difference it was not trying to test. 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)); } @@ -389,4 +394,59 @@ public void A_national_set_answers_to_both_of_its_designators() Assert.Equal("£à°", terminal.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)); + + // DECNRCM moving BETWEEN the save and the restore, both directions. This is the half the + // doc comment claims and the three cases above do not reach: they move the mode after the + // DECRC, so replaying a saved TABLE would satisfy them. Here the table saved and the table + // wanted are different, and only re-resolving the designation produces the second one. + var modeOnAfterSave = Sized(30, 3); + modeOnAfterSave.Write($"{Esc})R{Esc}7"); // French designated with NRC OFF + modeOnAfterSave.Write($"{Esc}[?42h{Esc}8"); // NRC on, then restore + modeOnAfterSave.Write($"{ShiftOut}@{ShiftIn}"); + Assert.Equal("à", modeOnAfterSave.GetLine(0)); // French, not the ASCII it saved + + var modeOffAfterSave = Sized(30, 3); + modeOffAfterSave.Write($"{Esc}[?42h{Esc})R{Esc}7"); // French designated with NRC ON + modeOffAfterSave.Write($"{Esc}[?42l{Esc}8"); // NRC off, then restore + modeOffAfterSave.Write($"{ShiftOut}@{ShiftIn}"); + Assert.Equal("@", modeOffAfterSave.GetLine(0)); // ASCII, not the French it saved + } } diff --git a/src/XTerm.NET/Buffer/TerminalBuffer.cs b/src/XTerm.NET/Buffer/TerminalBuffer.cs index 5532852..e59abdf 100644 --- a/src/XTerm.NET/Buffer/TerminalBuffer.cs +++ b/src/XTerm.NET/Buffer/TerminalBuffer.cs @@ -136,10 +136,23 @@ 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 - /// DECRC to leave the designations alone. + /// 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. + /// + /// Internal, unlike the rest of this class: it is DECSC scratch that a consumer cannot do + /// anything with, and it was public only by having been written that way. Narrowing it now + /// costs nothing -- the property arrived in #93, after v1.2, so no released package has + /// ever carried it -- and it stops the shape of an internal detail being an API question + /// every time DECSC learns something new. /// - public Dictionary?[]? Designations { get; set; } + internal (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 0d8592a..e1e2203 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 0a5ce96..4abe1b6 100644 --- a/src/XTerm.NET/InputHandler.Print.cs +++ b/src/XTerm.NET/InputHandler.Print.cs @@ -709,17 +709,8 @@ private bool TryPairRegionalIndicator(string data, int cellX) return true; } - private void SetCharset(CharsetMode mode, string charsetId) - { - // The ID is kept, not just the table it resolves to. A national set means one thing - // 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; - _ninetySixSets.Remove(mode); - _charsets[mode] = Charsets.GetCharset(charsetId, _terminal.NationalReplacementCharsets); - RefreshActiveCharset(); - } + private void SetCharset(CharsetMode mode, string charsetId) => + Designate(mode, charsetId, ninetySix: false); /// /// Designates a 96-character set: ESC - Ps (G1), ESC . Ps (G2), @@ -731,14 +722,40 @@ private void SetCharset(CharsetMode mode, string charsetId) /// 96-set space and the United Kingdom set in the 94-set one. Anything else is left as /// ASCII rather than guessed at. /// - private void SetNinetySixCharset(CharsetMode mode, string charsetId) + private void SetNinetySixCharset(CharsetMode mode, string charsetId) => + Designate(mode, charsetId, ninetySix: true); + + /// Records a designation and resolves it, for both spaces. + /// + /// The ID is kept, not just the table it resolves to. A national set means one thing 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. + /// + private void Designate(CharsetMode mode, string charsetId, bool ninetySix) { _charsetIds[mode] = charsetId; - _ninetySixSets.Add(mode); - _charsets[mode] = Charsets.ASCII; + if (ninetySix) + _ninetySixSets.Add(mode); + else + _ninetySixSets.Remove(mode); + + _charsets[mode] = Resolve(charsetId, ninetySix); RefreshActiveCharset(); } + /// What a designation means RIGHT NOW, given the mode state. + /// + /// One answer for the three callers that need it -- designation, DECRC and DECNRCM -- because + /// the question is the same one and they got different answers while each resolved separately. + /// The space is half the question: 'A' is ISO Latin-1 after ESC - and the United Kingdom set + /// after ESC (, so an identifier without the space it came from cannot be resolved at all. + /// + private Dictionary? Resolve(string charsetId, bool ninetySix) => + ninetySix + ? Charsets.ASCII + : Charsets.GetCharset(charsetId, _terminal.NationalReplacementCharsets); + /// Re-resolves every designation, for when DECNRCM changes under them. /// /// Through the space each was designated in. 'A' is ISO Latin-1 after ESC - and the @@ -749,16 +766,40 @@ private void SetNinetySixCharset(CharsetMode mode, string charsetId) /// internal void RefreshDesignatedCharsets() { - foreach (var mode in _charsetIds.Keys.ToList()) - { - _charsets[mode] = _ninetySixSets.Contains(mode) - ? Charsets.ASCII - : Charsets.GetCharset(_charsetIds[mode], _terminal.NationalReplacementCharsets); - } + foreach (var mode in GSets) + _charsets[mode] = Resolve(_charsetIds[mode], _ninetySixSets.Contains(mode)); RefreshActiveCharset(); } + /// The designation a G-set is holding, for DECSC to save. + internal (string Id, bool NinetySix) DesignationOf(CharsetMode mode) => + (_charsetIds[mode], _ninetySixSets.Contains(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, arbitrarily far from the DECRC that caused it. + /// + /// Resolving at restore time rather than replaying the saved table is also the right answer + /// when DECNRCM moved BETWEEN the save and the restore. + /// + internal void RestoreDesignation(CharsetMode mode, (string Id, bool NinetySix) designation) + { + _charsetIds[mode] = designation.Id; + if (designation.NinetySix) + _ninetySixSets.Add(mode); + else + _ninetySixSets.Remove(mode); + + _charsets[mode] = Resolve(designation.Id, designation.NinetySix); + } + /// /// Shift Out - Select G1 character set (SO, 0x0E). /// @@ -812,16 +853,22 @@ public void InvokeSingleShift(CharsetMode mode) /// public void ResetCharsets() { - _charsetIds.Clear(); _ninetySixSets.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 is what keeps every walk over the four total. + _charsetIds[mode] = UsAsciiId; + _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 086cf73..12bda2c 100644 --- a/src/XTerm.NET/InputHandler.cs +++ b/src/XTerm.NET/InputHandler.cs @@ -41,12 +41,20 @@ public partial class InputHandler /// What each G-set was DESIGNATED as, by its escape identifier. /// - /// Kept alongside the resolved tables because a designation outlives its resolution: a + /// 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 program that designated it does not designate again when the mode changes. + /// + /// All four slots are always present, seeded to US ASCII, so a designation is a value + /// rather than a value-or-absent. DECSC, DECRC and DECNRCM each walk every slot, and "never + /// designated" and "designated B" mean the same thing to all three. /// 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]; + /// Which G-sets were designated as 96-character sets. /// /// The identifier alone does not say: 'A' is the UK set in one space and ISO Latin-1 in the @@ -117,8 +125,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(); } ///